A reserved key comes to life — the card window's sidebar grows a Labels section, and labels stops being somebody else's

`labels` has been a reserved tracker key since the rewrite: preserved verbatim, never
interpreted, drawn only as an anonymous row in the Details section beside `assignees` and
`due`. The owner's cards claim it for first-party use, so it joins the schema — read
leniently (a list of names, a bare scalar coercing to one, a mapping malformed and
preserved), written canonically (a quoted flow list in the order the user arranged, no
auto-sort), and removed outright when the last label goes, the way an expanded lane drops
`collapsed`.

Identity is case-insensitive and display is case-preserving, so a card carries `bug` once
however many ways the board spells it, and entries the reading cannot name ride through the
write untouched at the tail.

The section sits second, above Details — which is the point rather than a layout preference:
Details is where keys the app does *not* own are shown, and this key just stopped being one.
Rows rather than chips, because the sidebar is twenty-six characters wide. The add field
autocompletes against the board's own used-labels universe, derived from every live and
trashed card with no store beside the files, and says out loud when Return would mint a word
the board has never used.

Writes ride a `.relabel` operation of their own, because the commit composer has said
"Relabel card 'X'" since long before there was a control to press — and now the undo row says
it too.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 12:06:01 -04:00
parent fd31ed24f4
commit da37ed61bf
24 changed files with 1928 additions and 29 deletions
+15
View File
@@ -169,6 +169,14 @@ public enum AppPreferences {
/// the key is declared here with its neighbours for `WindowID`'s reason.
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
/// **The recently applied labels** the tie-break half of the card menu's "most frequently and
/// recently used" twelve (`LabelRecents`, which owns the list rule; `LabelRanking`;
/// `FrontmatterKeys.labels`, activated 2026-08-09). `quickStyleBackgroundsKey`'s neighbour in every
/// respect: an array of strings, most-recent-first, app-wide, a user preference and never board
/// data the labels a board *has* are derived from its own cards (`LabelIndex`), and this is only
/// a memory of what this user reached for.
public static let labelRecentsKey = "labelRecents"
/// **The board's zoom level** "app-wide and persisted across restarts" (11-command-nexus.md
/// View Actual Size; 03-board-ui.md Layout zoom). A rung on `BoardZoom.levels`, stored as
/// the multiplier itself. Read and written by `BoardZoomStore`, which owns the ladder's rules; the
@@ -347,6 +355,12 @@ public final class AppModel {
/// board-scoped and this list deliberately is not.
public let styleRecents: StyleRecents
/// The recently applied labels, app-wide (`LabelRecents`; `FrontmatterKeys.labels`). Owned here
/// for `styleRecents`' reason exactly app-scoped, persisted beside it, and reached by the card
/// context menu and the card window's sidebar through the environment, since a `BoardStore` is
/// board-scoped and this list deliberately is not.
public let labelRecents: LabelRecents
/// The board's app-wide zoom level (03-board-ui.md Layout zoom). Owned here for
/// `styleRecents`' reason exactly: app-scoped, persisted beside it, and reached by every board
/// window through the environment while the menu rows and the toolbar buttons, which live
@@ -688,6 +702,7 @@ public final class AppModel {
) {
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
styleRecents = StyleRecents(defaults: preferences)
labelRecents = LabelRecents(defaults: preferences)
zoom = BoardZoomStore(defaults: preferences)
appearance = AppearanceStore(defaults: preferences)
printProfiles = PrintProfileStore(defaults: preferences)
+3
View File
@@ -492,6 +492,9 @@ struct CardWindowHost: View {
// editor feeds are app-wide state (02-architecture.md § Per-board app state), so
// they come from the model every window shares rather than from this board's store.
recents: appModel.styleRecents,
// The same argument one field over: the labels this user reached for lately are
// app-wide state (`LabelRecents`), so they come from the model every window shares.
labelRecents: appModel.labelRecents,
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
bodyPresentation: bodyPresentation,
bodySession: session.body,
+22 -2
View File
@@ -745,6 +745,16 @@ enum ChangeNarrator {
/// `created`, `modified-by` and the on-touch heal's backfilled `kind` are all schema-owned, and a
/// diff touching only those composes nothing (06 the bookkeeping rule).
///
/// **`labels` is the one key added back by hand** (2026-08-09): it joined `schemaOwned` when the
/// owner claimed it for first-party use (`FrontmatterKeys.labels`), which took it out of
/// `unknownFields` and would have taken the **Relabel** event with it the one event here that
/// 06-history-undo.md names verbatim, and which predates the control by months precisely because
/// the composer is origin-agnostic. Being schema-owned changes where the *sidebar* renders the key
/// and nothing about whether a change to it is worth narrating; an agent, a tracker sync and the
/// app's own submenu all still move it, and all three still say "Relabel". `assignees` and `due`
/// are untouched they are still reserved, still unknown keys, and still arrive through the
/// dictionary above.
///
/// Values compare **as written** (`rawValue`) rather than as parsed YAML: the composer's business
/// is that the file changed, and re-deriving an equivalence between `[a, b]` and `[a,b]` would be
/// a second YAML semantics to keep honest.
@@ -757,8 +767,18 @@ enum ChangeNarrator {
kinds: (label: Kind, assignee: Kind, due: Kind, customKey: Kind),
paths: [String]
) -> [Event] {
let before = Dictionary(previous.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last })
let after = Dictionary(current.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last })
var before = Dictionary(previous.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last })
var after = Dictionary(current.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last })
// `labels`, put back by hand see the note above. Absent stays absent (no entry), and the
// text is `FrontmatterField.rawValue`'s own rule restated, so a key with no span of its own
// (an uneditable whole-frontmatter flow mapping) compares by the parse's rendering exactly as
// its neighbours in the dictionary do.
func labelsText(of document: FrontmatterDocument) -> String? {
guard let value = document.value(for: Keys.labels) else { return nil }
return document.rawValue(for: Keys.labels) ?? value.description
}
before[Keys.labels] = labelsText(of: previous)
after[Keys.labels] = labelsText(of: current)
guard before != after else { return [] }
func phrase(_ verb: String, _ render: (String) -> String) -> String {
+14
View File
@@ -55,6 +55,20 @@ public enum HistoryPhrase {
case reorder = "Reorder"
case rename = "Rename"
case restyle = "Restyle"
/// A card's `labels` list rewritten the sidebar's chips and the context menu's submenu
/// (`FrontmatterKeys.labels`, activated 2026-08-09; `BoardStore.setLabels(_:onCard:on:)`).
///
/// **Its own verb rather than `restyle`**, on `collapse`'s reasoning: the row has to read back
/// the gesture it undoes, and "Undo Restyle Card" after clicking a label would name a control
/// the user never touched. The word is 06-history-undo.md Commit messages' own for a change
/// to this key the composer has said "Relabel card 'X'" since before there was a control to
/// press (`ChangeNarrator`) so the menu row and the change journal now speak the same one.
///
/// **One verb for both directions**, unlike `collapse`/`expand`: adding and removing a label
/// are the same control pressed twice, where collapsing and expanding are two menu rows with
/// two words.
case relabel = "Relabel"
case resize = "Resize"
/// A lane folded to its slim strip (03-board-ui.md § Lane Collapsed lanes).
///
+17
View File
@@ -55,6 +55,20 @@ public enum ExpectedField: Sendable, Equatable {
/// everywhere else here.
case hero(String?)
/// `labels` the card's label list, whole (`BoardStore.setLabels(_:onCard:on:)`, the key's
/// 2026-08-09 activation).
///
/// **The whole list, not one label**, which is what makes the comparison right: a labels write
/// rewrites the key's entire value, so the after-value it declares is the entire value it wrote. A
/// per-label expectation would let a step that added `bug` cross safely onto a card somebody else
/// has since retagged completely, which is exactly the foreign edit staleness exists to catch.
///
/// `nil` is the removed key what taking the last label off leaves behind (the remove-at-default
/// family; `FrontmatterKeys.labels`). A card whose list has been emptied but whose key survives for
/// preserved non-name entries reads `.valid([])` and matches `[]`, not `nil`: those are different
/// bytes and the step knows which one it wrote.
case labels([String]?)
/// `icon` the styling gesture's symbol dimension.
case icon(String?)
@@ -79,6 +93,7 @@ public enum ExpectedField: Sendable, Equatable {
case .background: .background
case .backgroundImage: .backgroundImage
case .hero: .hero
case .labels: .labels
case .icon: .icon
case .iconColor: .iconColor
case .body: .body
@@ -95,6 +110,7 @@ public enum ExpectedField: Sendable, Equatable {
case background
case backgroundImage
case hero
case labels
case icon
case iconColor
case body
@@ -360,6 +376,7 @@ public enum HistoryStaleness {
case let .background(expected): equal(document.background, expected)
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
case let .hero(expected): equal(document.hero, expected)
case let .labels(expected): equal(document.labels, expected)
case let .icon(expected): equal(document.icon, expected)
case let .iconColor(expected): equal(document.iconColor, expected)
case let .body(expected): document.body == expected
+9
View File
@@ -927,6 +927,15 @@ public final class BannerCenter {
if let title { "Couldn't update '\(title)' to the current format" } else { "Couldn't update an item to the current format" }
case let .style(title):
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
case let .relabel(title):
// **"relabel", in both directions** `.collapse`/`.expand`'s two-word split turned down
// for the reason `WriteOperation.relabel` gives: adding and removing a label are the same
// control pressed twice, so one verb is the honest one. The word is 06-history-undo.md's
// own for a change to this key ("Relabel card 'X'"), which the commit composer has spoken
// since before there was a control to press. It says "the card" rather than "the item"
// where every other row here says the latter, because labels are a card field and nothing
// else can reach this sentence.
if let title { "Couldn't relabel '\(title)'" } else { "Couldn't relabel the card" }
case .setBoardBackground:
// **"generate", because that is the button they pressed**, and no title because there is
// one board and they are looking at it. It deliberately says nothing about the *file*
+156
View File
@@ -256,6 +256,33 @@ public final class BoardStore: HealHost {
/// path see the type's doc comment for why. A failed reload leaves it exactly as it was.
public private(set) var snapshot: BoardModel
/// **The board's used-labels universe, cached** (`LabelIndex`; `FrontmatterKeys.labels`, activated
/// 2026-08-09) the one derived value in this store that exists purely so a *view body* need not
/// derive it.
///
/// ### Why it is stored rather than computed
///
/// The context menu's `labels` submenu has to name twelve labels, and `.contextMenu`'s content
/// closure **is not lazy**: SwiftUI evaluates it on every ordinary body pass of every card face,
/// not only when a menu opens (`CardFaceView.copyLinkEnabled`'s doc comment, and the O(board)
/// regression `BoardRenderPerformanceTests.selectionStillRepaints` caught the hard way). A
/// computed property here would put a whole-board walk inside that closure, once per face, on
/// every pass the exact shape of the cost this file's render work exists to shed.
///
/// So the walk happens **once per applied snapshot**, here, and a face reads a value that is
/// already built. The remaining per-face work is `LabelRanking.ranked`, whose size is the board's
/// *label vocabulary* tens of entries, bounded by how many distinct labels exist and not by how
/// many cards there are.
///
/// ### The assignment is equality-gated, and that is load-bearing
///
/// `@Observable` notifies on **every** set, equal or not (`BoardZoomStore.setLevel`'s own note), so
/// an ungated re-derivation on each reload would invalidate every card face that reads this on
/// every reload reintroducing board-wide invalidation through the back door. Gated, this
/// property changes only when the board's labels genuinely change, which is a user-visible content
/// change that was going to re-render those faces anyway.
public private(set) var labelIndex: LabelIndex
/// How many snapshots this store has **applied**, ever a counter, not a version.
///
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
@@ -782,6 +809,7 @@ public final class BoardStore: HealHost {
self.rootURL = rootURL
self.rootKey = BoardRootKey(rootURL)
self.snapshot = result.model
self.labelIndex = LabelIndex.derive(from: result.model)
self.loadWarnings = result.warnings
self.defects = result.defects
// The opening walk was cold by definition; what it parsed is the first reload's memo.
@@ -1020,6 +1048,13 @@ public final class BoardStore: HealHost {
)) {
snapshot = result.model
snapshotGeneration += 1
// **The used-labels universe, re-derived with the tree it describes** inside the
// same transaction as the snapshot for the reason the re-grounding below is: a view
// woken by the snapshot's change must never observe a universe still describing the
// old one. Equality-gated, which is what keeps a reload that moved a card from
// invalidating every face that reads it (see the property's own note).
let labels = LabelIndex.derive(from: result.model)
if labels != labelIndex { labelIndex = labels }
// The one place transient state is re-grounded. It goes last, after `snapshot` is
// the new one, because a view woken by the snapshot's change must never observe a
// selection still pointing at the old tree.
@@ -4275,6 +4310,127 @@ public final class BoardStore: HealHost {
return true
}
// MARK: - Labels
/// **Rewrites a card's `labels` list** the one write behind both label surfaces, the card
/// window's sidebar section and the board card menu's submenu (`FrontmatterKeys.labels`, activated
/// 2026-08-09; `CardLabels` has the rules, `FrontmatterDocument.setLabels` the canonical form).
///
/// ### It is `setHero`'s shape, one key over
///
/// One key on one card's `index.md`, through `updateIndex` inside one `performWrite` bracket,
/// registering one step. Everything that makes that shape right there makes it right here: the
/// churn rounds back as one app-mediated reload, the read-only lock refuses it like every other
/// mutation, its failures reach the banner strip, and the step's before-value is the list the
/// snapshot last read.
///
/// **It takes `.relabel`, not `.style`.** A restyle picks an appearance; this edits what the card
/// is about, and the vocabulary has had a separate word for it since before there was a control
/// (`WriteOperation.relabel`; 06-history-undo.md's "Relabel card 'X'").
///
/// ### The list is the unit
///
/// The parameter is the **whole** new list rather than a label plus a direction, and every caller
/// composes it through `CardLabels` (`toggling`, `adding`, `removing`) from the list the snapshot
/// already shows. That keeps the "case-insensitive uniqueness, first spelling wins, order of
/// addition preserved" rules in exactly one place, and it makes the undo step's after-value
/// trivially honest: the step declares the list it wrote (`ExpectedField.labels`).
///
/// **A no-op costs nothing.** A call whose normalized list equals what the card already reads
/// writes nothing, reloads nothing and registers no undo step `applyStyle`'s own
/// redundant-dimension rule, which matters more here than there because a menu row toggled twice
/// in a row is a thing users do.
///
/// ### The guards are `setHero`'s
///
/// **The board container and only it** a trashed card's labels are not editable from a window
/// that is dismissing itself and a lane id is refused because labels are a card field
/// (`Card.labels`).
///
/// - Parameter names: the card's whole new label list, in the order it should be written.
/// - Parameter window: the card window whose stack the step belongs on, when the gesture came from
/// one (13-native-undo.md Rules two levels). A window-issued gesture also anchors by **card
/// identity** rather than by path, `setHero`'s rule verbatim, so a lane move under an open window
/// never stales it.
/// - Returns: whether bytes reached disk.
@discardableResult
public func setLabels(_ names: [String], onCard cardID: ItemID, on window: CardWindowUndo? = nil) -> Bool {
guard let item = Self.boardItem(cardID, in: snapshot),
let card = item.cardID,
let subject = Self.card(cardID, in: snapshot)
else { return false }
let prior = subject.labels
let next = CardLabels.deduplicated(names)
// A malformed prior matches no list, so a first write onto `labels: {a: 1}` always lands
// which is right: the value had no reading, and the user is replacing it with one.
guard prior.value != next else { return false }
// **Whether the key is held open by entries the reading cannot name** read off the
// snapshot's own document, exactly as `prior` is, so the two facts the expectations are built
// from come from one reading of one file. It decides whether an emptied list *removes* the key
// or leaves `[]` behind, which is the difference between two after-values a step could declare
// (`CardLabels.readingAfterWrite`).
let preserving = !subject.document.preservedLabelEntries.isEmpty
let landedReading = CardLabels.readingAfterWrite(next, preservingEntries: preserving)
let restoredReading = CardLabels.readingAfterWrite(prior.value ?? [], preservingEntries: preserving)
let folder = rootURL
.appendingPathComponent(item.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
let anchor: HistoryAnchor = window != nil ? .card(cardID) : .path(folder)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// `.relabel(title: nil)`: `updateIndex` enriches it off the document it reads, so a failure
// names the card by the title it still has.
try BoardWriter.updateIndex(inItemFolder: folder, operation: .relabel(title: nil)) { document in
document.setLabels(next)
}
}
guard landed != nil else { return false }
registerStep(
HistoryPhrase.name(.relabel, kind: .card),
subject: item.title,
on: window,
undoExpects: [.present(anchor, .labels(landedReading))],
redoExpects: [.present(anchor, .labels(restoredReading))]
) { store in
try BoardWriter.updateIndex(
inItemFolder: try store.requiredFolder(for: anchor, .relabel(title: nil)),
operation: .relabel(title: nil)
) { document in
// **A malformed prior inverses to the removed key**, `restoreCollapsed`'s rule and the
// one the redo expectation above is written against: this app writes `labels` in
// exactly one shape, and reproducing somebody's `labels: {a: 1}` would be the undo
// inventing a value. The reading is restored exactly either way both read as no
// labels which is what an inverse owes the user.
document.setLabels(prior.value ?? [])
}
} redo: { store in
try BoardWriter.updateIndex(
inItemFolder: try store.requiredFolder(for: anchor, .relabel(title: nil)),
operation: .relabel(title: nil)
) { document in
document.setLabels(next)
}
}
return true
}
/// The labels a card currently carries, as the last reload read them `[]` for a card with no
/// key, an empty list, or a value with no list reading at all.
///
/// The one read every label surface starts from, so the sidebar's chips, the menu's checkmarks and
/// the write path's "what am I toggling against" can never disagree. It looks in **both**
/// containers (`cardBodyTarget`'s walk, through `Self.card`/`boardItem`'s live-only lookup plus the
/// trash), because the card window stays open over a card that was trashed under it and its
/// sidebar must keep showing the truth even though `setLabels` will refuse to write there.
public func labels(ofCard cardID: ItemID) -> [String] {
if let card = Self.card(cardID, in: snapshot) { return card.labels.value ?? [] }
return snapshot.trash.first { $0.id == cardID }?.labels.value ?? []
}
/// One live board-side card off a snapshot, by identity `boardItem`'s sibling for a caller that
/// needs the card's *fields* rather than its position.
nonisolated static func card(_ id: ItemID, in snapshot: BoardModel) -> Card? {
+205
View File
@@ -0,0 +1,205 @@
import Foundation
// MARK: - One label's standing on a board
/// One label the board actually uses, and how many cards use it.
public struct LabelTally: Sendable, Equatable {
/// The label as it is **spelled on the board** the first spelling the derivation met, in board
/// order (lanes left to right, each lane's cards top to bottom, then the trash).
///
/// Case-insensitive uniqueness is a per-*card* rule (`CardLabels`), so two cards may perfectly
/// legally spell one label two ways. The universe has to pick one to offer, and the first is the
/// only choice that does not depend on how many cards happen to carry each variant a count-based
/// pick would make a menu row's capitalisation flicker as cards are tagged.
public let name: String
/// How many **cards** carry it never how many times it appears, which is the same number: a
/// card's own list is deduplicated by the reading.
public let count: Int
public init(name: String, count: Int) {
self.name = name
self.count = count
}
}
// MARK: - The universe
/// **Every label the board uses, with its frequency** the "used labels" universe both label
/// surfaces draw from (the sidebar's autocomplete and the context menu's twelve rows plus its More
/// dialog).
///
/// ### Derived from the snapshot, and there is no other store
///
/// The owner's ruling, and the format's own posture: the files *are* the board, so the set of labels
/// in use is a fact about the cards rather than a list the app maintains beside them. A label exists
/// because a card carries it and stops existing when the last card drops it there is no rename, no
/// palette, no orphan to garbage-collect, and an agent that hand-writes `labels: [spike]` into an
/// `index.md` has created a label as fully as the menu can.
///
/// **The trash counts.** `BoardModel.trash` is walked beside `lanes` because a trashed card is "an
/// ordinary card in a special place" (03-board-ui.md § Trash) and, more to the point, because the
/// alternative is worse: deleting the only card carrying `spike` would silently evict the label from
/// the autocomplete, and restoring the card would bring it back a vocabulary that flickers with the
/// trash is not a vocabulary. `trashedLanes` is deliberately *not* walked: a trashed lane is an opaque
/// unit whose cards the snapshot never loads (`TrashedLane`), so there is nothing there to count.
///
/// ### Cost
///
/// One pass over every live and trashed card, reading a field the loader already parsed the same
/// cost class as any other whole-board derivation, and paid **once per snapshot** (`BoardStore`
/// caches it, equality-gated) rather than once per view body. That is what keeps the context menu's
/// builder free of O(board) work: everything a card face reads is this value, already built.
public struct LabelIndex: Sendable, Equatable {
/// Every used label, **ordered by frequency descending, then case-insensitively alphabetically**
/// a total, snapshot-only order, so two runs over the same board produce the identical array.
///
/// The alphabetical tie-break is here rather than left to the ranking because this ordering has to
/// be deterministic on its own: it is what `universe` shows and what the ranking starts from, and
/// a derivation whose ties fell out of dictionary iteration order would make the More dialog
/// reshuffle itself between reloads that changed nothing.
public let tallies: [LabelTally]
public init(tallies: [LabelTally]) {
self.tallies = tallies
}
/// The empty board's index no cards, or no card carrying a label.
public static let empty = LabelIndex(tallies: [])
/// Every used label's display name, in `tallies`' order.
public var names: [String] { tallies.map(\.name) }
public var isEmpty: Bool { tallies.isEmpty }
/// The universe as the More dialog lists it: **alphabetically, case-insensitively**, because a
/// dialog whose whole job is "find the one you mean among all of them" is a lookup, and a
/// frequency order is a lookup you cannot do.
public var alphabetical: [String] {
tallies.map(\.name).sorted { Self.precedesAlphabetically($0, $1) }
}
/// Whether the board already uses a case-insensitively equal name the create field's "this is
/// not new" test.
public func contains(_ name: String) -> Bool {
CardLabels.contains(name, in: names)
}
/// The board's own spelling of a name, when it has one so a user who types `BUG` into the create
/// field adds the `bug` the rest of the board already says, rather than minting a second variant.
public func canonicalSpelling(of name: String) -> String? {
guard let name = CardLabels.normalized(name) else { return nil }
let key = CardLabels.canonical(name)
return tallies.first { CardLabels.canonical($0.name) == key }?.name
}
// MARK: Derivation
/// The universe of `snapshot`, live cards and trash alike.
public static func derive(from snapshot: BoardModel) -> LabelIndex {
var order: [String] = []
var counts: [String: Int] = [:]
var spellings: [String: String] = [:]
func absorb(_ card: Card) {
for name in card.labels.value ?? [] {
let key = CardLabels.canonical(name)
if spellings[key] == nil {
spellings[key] = name
order.append(key)
}
counts[key, default: 0] += 1
}
}
for lane in snapshot.lanes {
for card in lane.cards { absorb(card) }
}
for card in snapshot.trash { absorb(card) }
let tallies = order.map { key in
LabelTally(name: spellings[key] ?? key, count: counts[key] ?? 0)
}
return LabelIndex(tallies: tallies.sorted(by: precedes))
}
/// Frequency descending, then case-insensitively alphabetical, then by raw spelling a **total**
/// order, which is what makes the derivation reproducible.
private static func precedes(_ lhs: LabelTally, _ rhs: LabelTally) -> Bool {
if lhs.count != rhs.count { return lhs.count > rhs.count }
return precedesAlphabetically(lhs.name, rhs.name)
}
/// Case-insensitive alphabetical, with the raw spelling as the last resort so `Bug` and `bug`
/// never compare equal and the sort stays total.
static func precedesAlphabetically(_ lhs: String, _ rhs: String) -> Bool {
let folded = CardLabels.canonical(lhs).compare(CardLabels.canonical(rhs))
if folded != .orderedSame { return folded == .orderedAscending }
return lhs < rhs
}
}
// MARK: - The menu's twelve
/// **"The most frequently and recently used labels"** the owner's phrase for the card context
/// menu's twelve rows, turned into arithmetic.
///
/// ### The ranking, exactly
///
/// 1. **Frequency, descending** how many of the board's cards (live and trashed) carry the label
/// (`LabelIndex`).
/// 2. **Recency, as the tie-break** among labels used by the same number of cards, the one this
/// user applied most recently comes first (`LabelRecents`, an app-side MRU updated on every apply).
/// A label the MRU has never seen sorts behind every label it has.
/// 3. **Case-insensitively alphabetical**, then raw spelling so the order is *total* and a menu
/// never reshuffles between two openings that changed nothing.
///
/// Then the first twelve.
///
/// ### Why frequency leads and recency follows, and what that costs
///
/// This is the ruling as given, implemented as given. It is worth being explicit about the
/// consequence, because it is visible: on a board with more than twelve labels in heavy use, a label
/// **invented today** will not appear in these rows until enough cards carry it to beat the
/// twelfth-most-common one even though it is by far the most *recently* used. The recency half only
/// separates labels that are already tied on frequency, which on a board with an even spread of usage
/// is often most of them, and on a lopsided board is almost none.
///
/// The alternative a blended score, or reserving a slot or two for pure recency would surface a
/// fresh label immediately at the cost of a menu whose contents move under the user's hand. Neither
/// is obviously right, so the literal reading of the ruling is what shipped, and the observation is
/// journaled for the owner rather than quietly designed around. **More** is the escape hatch either
/// way: every used label is one row further in, and the create field is beside it.
public enum LabelRanking {
/// How many rows the context menu's `labels` submenu shows before its separator the owner's
/// "up to 12".
public static let menuLimit = 12
/// The top `limit` of `index`, ranked as the type comment describes.
///
/// - Parameter recents: most-recent-first, `LabelRecents.labels`' own order. Entries naming labels
/// the board no longer uses are simply never matched the MRU is app-wide and a board is not
/// obliged to know about labels from another one.
public static func ranked(_ index: LabelIndex, recents: [String], limit: Int = menuLimit) -> [String] {
guard limit > 0 else { return [] }
// Position in the MRU, by canonical name built once rather than searched per comparison, so
// the sort stays O(n log n) instead of O(n² ) in the number of distinct labels.
var recency: [String: Int] = [:]
for (position, name) in recents.enumerated() {
let key = CardLabels.canonical(name)
if recency[key] == nil { recency[key] = position }
}
let ordered = index.tallies.sorted { lhs, rhs in
if lhs.count != rhs.count { return lhs.count > rhs.count }
let left = recency[CardLabels.canonical(lhs.name)] ?? Int.max
let right = recency[CardLabels.canonical(rhs.name)] ?? Int.max
if left != right { return left < right }
return LabelIndex.precedesAlphabetically(lhs.name, rhs.name)
}
return ordered.prefix(limit).map(\.name)
}
}
+77
View File
@@ -0,0 +1,77 @@
import Foundation
import Observation
/// The labels this user applied lately, most-recent-first the **tie-break** half of "most
/// frequently and recently used" (`LabelRanking`; `FrontmatterKeys.labels`, activated 2026-08-09).
///
/// `StyleRecents`' shape exactly, for its reasons: the list *rule* is a static pure function, this
/// object is its persistence and its observability, and `defaults` is injectable so a test drives a
/// suite of its own rather than the user's.
///
/// ### App-wide, not per board and it does not matter much
///
/// A board's label *universe* is board data, derived from its own cards (`LabelIndex`). This is not
/// that. It is a memory of what this **user** reached for, and it exists only to order labels that are
/// already tied on frequency within one board's universe so an entry naming a label another board
/// uses simply never matches anything here and is inert. App-wide is `StyleRecents`' own posture ("no
/// board owns the list"), it needs no per-board storage key, and it survives a board being closed and
/// reopened, which a window-lived list would not.
///
/// ### What enters it
///
/// **Applications, not removals.** Taking `bug` off a card is not evidence the user is reaching for
/// `bug`; recording it would push a label the user is actively getting rid of to the front of the very
/// menu they are trying to leave. So `LabelCommand` records on the add half of a toggle and on a
/// create, and never on a remove the same "the None well is not a colour" carve-out `StyleRecents`
/// makes one field over.
@MainActor
@Observable
public final class LabelRecents {
/// How many the list keeps. **Twice the menu's twelve**, deliberately: unlike `StyleRecents`' six,
/// this list is never *displayed* it only breaks ties inside a twelve-row menu so its useful
/// depth is "enough to order a tie group that could fill the menu", and a cap equal to the menu
/// size would leave the thirteenth-most-recent label indistinguishable from one never used at all.
public static let cap = 24
/// The labels, most-recent-first. Display spellings, exactly as written to frontmatter.
public private(set) var labels: [String]
@ObservationIgnored
private let defaults: UserDefaults
/// - Parameter defaults: the domain to persist in. Injected for `StyleRecents`' reason a test
/// must be able to hold its own without touching the user's.
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
// Anything but an array of strings reads as an empty list rather than as an error
// `StyleRecents`' rule and its reason: a hand-edited or truncated preference must never be why
// a context menu cannot open.
labels = (defaults.array(forKey: AppPreferences.labelRecentsKey) as? [String]) ?? []
}
/// Records `name` as the most recently applied label and persists the new list.
public func record(_ name: String) {
let updated = Self.updated(labels, with: name)
guard updated != labels else { return }
labels = updated
defaults.set(labels, forKey: AppPreferences.labelRecentsKey)
}
/// The list rule, as a pure function: `name` to the front, its **case-insensitive** earlier
/// occurrence removed, the tail truncated to `cap`.
///
/// Case-insensitive because that is what a label's identity is (`CardLabels.canonical`), and a list
/// holding both `Bug` and `bug` would spend two of its slots ordering one label against itself. The
/// spelling kept is the one just applied, which is the board's own by construction every write
/// path resolves a typed name against `LabelIndex.canonicalSpelling(of:)` before it lands.
///
/// A name that trims to nothing returns the list unchanged: it is not a label anybody applied.
public static func updated(_ list: [String], with name: String, cap: Int = cap) -> [String] {
guard let name = CardLabels.normalized(name) else { return list }
let key = CardLabels.canonical(name)
var updated = list.filter { CardLabels.canonical($0) != key }
updated.insert(name, at: 0)
return Array(updated.prefix(cap))
}
}
+6 -1
View File
@@ -155,7 +155,12 @@ extension FrontmatterDocument {
/// preserved subkey's number or timestamp is emitted by the same code that writes `order` and
/// `created`. Nested collections recurse, which keeps an unknown subkey holding a list from
/// being flattened into its `description`.
private static func flowText(_ value: YAMLValue) -> String {
///
/// **Internal rather than private since 2026-08-09**: `labels` writes a flow *sequence*
/// (`LabelsField.swift`) and carries preserved non-name entries through it, which is the identical
/// question one collection over a second copy of this switch would be a second opinion about how
/// somebody else's YAML is re-emitted.
static func flowText(_ value: YAMLValue) -> String {
switch value {
case .null: "null"
case let .bool(value): FrontmatterValue.bool(value).yamlText
+2
View File
@@ -838,6 +838,7 @@ public enum BoardLoader: Sendable {
icon: document.icon,
iconColor: document.iconColor,
hero: document.hero,
labels: document.labels,
order: order,
attachments: entry.attachments,
commentCount: entry.commentCount,
@@ -1150,6 +1151,7 @@ public enum BoardLoader: Sendable {
icon: document.icon,
iconColor: document.iconColor,
hero: document.hero,
labels: document.labels,
order: order,
attachments: attachments,
commentCount: commentCount,
+19
View File
@@ -271,6 +271,25 @@ public struct Card: Identifiable, Sendable, Equatable {
/// key, and the bytes stay as written.
public let hero: FieldValue<String>
/// **The card's labels** the names the card is tagged with (`FrontmatterKeys.labels`, whose doc
/// comment carries the key's own story; `CardLabels` has the rules; the reading is
/// `FrontmatterDocument.labels`).
///
/// **Card-level only**, `hero`'s posture and for its kind of reason: a label is a property of a
/// piece of work, and neither a lane nor a board is one. The key on a lane or a board stays an
/// ordinary unknown one, preserved verbatim and shown in no sidebar the way it always was.
///
/// The *shape* rather than the answer, so the coerce tier can report a value that had no list
/// reading at all (a mapping); `.missing` and `.valid([])` are both "no labels" and render
/// identically, which is why nothing in the app branches on the difference but the two are
/// distinct bytes on disk and the field keeps them apart.
///
/// **The board face does not draw these** (as of the activation, 2026-08-09). Chips on the card
/// face are their own design question the attachments and comments chips set that vocabulary and
/// a label list is a different shape of thing so this rides in the snapshot for the card window's
/// sidebar, the context menu's submenu, and the board-wide used-labels universe those two share.
public let labels: FieldValue<[String]>
/// Rank within its lane, ascending = top-to-bottom the reading, not necessarily the key. See
/// `Lane.order`'s doc comment; the same reasoning applies here, and a card is where it matters
/// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all
+21 -1
View File
@@ -3025,6 +3025,24 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case migrateTombstone(title: String?)
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
/// **A card's `labels` list being rewritten** the sidebar section's chips and the context menu's
/// submenu, the two surfaces the key's 2026-08-09 activation shipped with
/// (`FrontmatterKeys.labels`; `BoardStore.setLabels(_:onCard:on:)`).
///
/// **Its own case rather than a fold into `.style`**, on the vocabulary's standing reasoning. A
/// restyle picks an appearance; this edits what the card *is about*, and 06-history-undo.md's
/// commit-message composer has had a separate word for it since long before there was a control
/// "Relabel card 'X'" (§ Commit messages, named there among the three keys "invisible in the UI
/// though they are"). The word already existed; this is the gesture arriving to claim it. A banner
/// telling someone the app "couldn't style 'Fix login'" after they clicked a label row would name a
/// control they never touched.
///
/// **One case for both directions**, unlike `.collapse`/`.expand`: adding and removing a label are
/// the same control pressed twice a chip's and a menu row's checkmark both just rewrite the
/// list where collapse and expand are two menu rows with two words. `title` is the card's, filled
/// in by `updateIndex` off the document it reads.
case relabel(title: String?)
/// **A generated board background landing** the PNG written into the board folder and the
/// `background` mapping's two subkeys pointed at it, one bracket
/// (`BoardStore.applyGeneratedBackground`; `FacetsGenerator`).
@@ -3359,6 +3377,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .purge: .purge(title: title)
case .migrateTombstone: .migrateTombstone(title: title)
case .style: .style(title: title)
case .relabel: .relabel(title: title)
case .resize: .resize(title: title)
case .collapse: .collapse(title: title)
case .expand: .expand(title: title)
@@ -3410,7 +3429,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
// reading to be about and `.deleteComment`'s move into `comments/.trash/` stamps for the
// plain container reason its board-level twin does.
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
.style, .relabel, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
.exportBoard, .importBoard,
.importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
@@ -3441,6 +3460,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .purge(title): Self.phrase("purge", title)
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
case let .style(title): Self.phrase("style", title)
case let .relabel(title): Self.phrase("relabel", title)
case .setBoardBackground: "set this board's background"
case let .resize(title): Self.phrase("resize", title)
case let .collapse(title): Self.phrase("collapse", title)
+39 -2
View File
@@ -593,6 +593,42 @@ public enum FrontmatterKeys {
/// the reason it always did it is Lanework's to interpret, not an unknown key riding along.
public static let hero = "hero"
/// **A card's labels** (01-storage-format.md § Frontmatter's card table the amendment this key's
/// activation owes; `CardLabels`, `FrontmatterDocument.labels`) a YAML list of names:
/// `labels: ["bug", "ui"]`.
///
/// ### It used to be reserved, and that reversal is the design event
///
/// Through 2026-08-09 this was one of the **reserved tracker keys** beside `assignees`, `due`,
/// `remote` and `remote-state` (01 § Enhanced schema): "the pathfinder shipped them natively; the
/// rewrite reserves them instead", preserved verbatim, never interpreted, and shown only as an
/// anonymous row in the card window's Details section like any other unknown key. The owner's cards
/// ("in card window sidebar add ability to manage card's labels", "add labels submenu to card
/// context menu") claim it for **first-party use**, which retires the reservation for this one name
/// `assignees`, `due` and the two `remote` keys are untouched and stay exactly as reserved as they
/// were.
///
/// **It therefore joins `schemaOwned`**, which is the whole of what stops the Details section
/// drawing a second, raw copy of a field the sidebar now renders properly (`CardDetails.rows`
/// subtracts exactly that set). `hero`'s precedent, and for `hero`'s reason: it is Lanework's to
/// interpret, not an unknown key riding along.
///
/// ### The grammar
///
/// **Read leniently, written canonically** the shape of every lenient field here, one collection
/// up. A sequence reads as its string entries in file order; a bare scalar coerces to a
/// one-element list (`labels: bug`); a mapping has no list reading at all and is `.malformed`.
/// Names are **case-preserving for display and case-insensitively unique within a card**, and
/// entries the reading cannot make a name of are preserved on the way out but never rendered
/// (`CardLabels`).
///
/// **Written as a flow list, in the user's own order** no auto-sort: the order is the order of
/// addition, which is a thing the user arranged and the app has no business restating
/// alphabetically. **Removing the last label removes the key**, the remove-at-default family's rule
/// beside `collapsed`'s expand and the None well's `background`: an absent key is the no-labels
/// reading, so `labels: []` written by the app would be noise every unlabelled card had to carry.
public static let labels = "labels"
public static let created = "created"
public static let modified = "modified"
public static let modifiedBy = "modified-by"
@@ -620,7 +656,8 @@ public enum FrontmatterKeys {
public static let kind = "kind"
/// The reserved tracker keys `remote` (board, card) and `remote-state` (lane) of the enhanced
/// schema's future connectors (01-storage-format.md § Enhanced schema).
/// schema's future connectors (01-storage-format.md § Enhanced schema). **`labels` left this
/// family on 2026-08-09** when the owner claimed it for first-party use; see that key above.
///
/// **Named here without joining `schemaOwned`**, which is the ruling rather than an oversight: 01
/// says the app "treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in
@@ -652,7 +689,7 @@ public enum FrontmatterKeys {
public static let author = "author"
public static let schemaOwned: Set<String> = [
schema, title, order, width, collapsed, hero, created, modified, modifiedBy, deleted,
schema, title, order, width, collapsed, hero, labels, created, modified, modifiedBy, deleted,
background, icon, iconColor, kind,
]
}
+19
View File
@@ -117,6 +117,7 @@ extension FrontmatterDocument {
record(FrontmatterKeys.icon, icon)
record(FrontmatterKeys.iconColor, iconColor)
record(FrontmatterKeys.hero, hero)
record(FrontmatterKeys.labels, labels)
record(FrontmatterKeys.kind, kind)
return found
}
@@ -270,6 +271,24 @@ extension FrontmatterDocument {
}
}
/// **A card's labels** (`FrontmatterKeys.labels`, whose doc comment carries the key's own story
/// including the 2026-08-09 reversal that took it off the reserved-tracker-keys list; the rules
/// themselves are `CardLabels`).
///
/// The lenient family's shape, one collection up. A **sequence** reads as its string entries in
/// file order, case-insensitively deduplicated with the first spelling winning. A **bare scalar**
/// coerces to a one-element list (`labels: bug`) the same "any scalar has a sensible reading"
/// instinct `title` has, narrowed to strings for `CardLabels.name(of:)`'s stated reason. A
/// **mapping**, and a non-string scalar, have no list reading at all: `.malformed`, rendering as no
/// labels, bytes untouched, and a coerce-tier trace left behind.
///
/// A list holding nothing the reader can name `labels: [{a: 1}]` is `.valid([])` rather than
/// malformed: the author wrote a list, which is the right shape, and it happens to contain no
/// names. Those entries survive the next write (`FrontmatterDocument.setLabels`).
public var labels: FieldValue<[String]> {
read(FrontmatterKeys.labels) { value, _ in CardLabels.reading(of: value) }
}
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
+224
View File
@@ -0,0 +1,224 @@
import Foundation
/// **The rules of a card's `labels` list** the pure rulebook behind the read in
/// `FrontmatterFields.swift` and the write below (`FrontmatterKeys.labels` has the key's own story,
/// including why it stopped being a reserved tracker key on 2026-08-09).
///
/// Everything here is a static function over values: no document, no filesystem, no store. The two
/// surfaces that manage labels the card window's sidebar section and the card context menu's
/// submenu both reduce to `toggling`/`adding`/`removing` over a `[String]`, so neither of them can
/// invent a second answer to "does this card already have that label?".
public enum CardLabels {
// MARK: - One name
/// A name as the app stores it: **trimmed of surrounding whitespace, and never empty**.
///
/// `nil` is "there is no name here" an empty string, or a string that is nothing but spaces.
/// That is not a label anybody meant: it would render as a blank chip, match nothing in the
/// used-labels universe, and be unremovable by clicking the thing it does not draw. The empty
/// rename's own rule (`title`: a name that trims to nothing writes no key at all) one collection
/// down.
///
/// **Interior whitespace is left exactly as typed.** `"needs review"` is a perfectly good label
/// and the app has no business folding it to one word or to a hyphen the schema stores names,
/// not identifiers.
public static func normalized(_ text: String) -> String? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
/// The comparison key: a name case-folded, so `Bug` and `bug` are one label.
///
/// **Case-preserving display, case-insensitive uniqueness** (the owner's ruling). The fold is
/// locale-independent on purpose `ItemID.canonicalValue`'s posture, for its reason: this decides
/// identity, and an identity that changed with the user's region would make the same two files
/// mean different things on two machines.
public static func canonical(_ name: String) -> String {
name.lowercased()
}
/// Whether a case-insensitively equal name is already in the list.
public static func contains(_ name: String, in list: [String]) -> Bool {
guard let name = normalized(name) else { return false }
let key = canonical(name)
return list.contains { canonical($0) == key }
}
// MARK: - One list
/// The list with case-insensitive duplicates collapsed, **first spelling wins**, order otherwise
/// preserved.
///
/// The reading applies this, so a hand-written `labels: [Bug, bug]` shows one chip rather than two
/// that cannot be told apart and the next app write to the key lands the collapsed form, which is
/// the on-touch heal every other lenient field already performs by rewriting what it read.
///
/// *First* spelling rather than last, unlike the duplicate-key rule one level up
/// (`FrontmatterDocument.parse`'s last-wins): a key written twice is a file with two answers and the
/// later one is the author's correction, while a list is one value whose members are in an order the
/// author chose so the first occurrence is the one that holds its place.
public static func deduplicated(_ list: [String]) -> [String] {
var seen: Set<String> = []
var result: [String] = []
for entry in list {
guard let name = normalized(entry), seen.insert(canonical(name)).inserted else { continue }
result.append(name)
}
return result
}
/// `name` appended to the list, or the list unchanged when a case-insensitive twin is already
/// there.
///
/// **Appended, never sorted in** the order of addition is the user's arrangement (the key's own
/// no-auto-sort rule), and **the existing spelling wins** a case clash: adding `Bug` to a card that
/// already carries `bug` is a no-op rather than a silent respelling, because the user is adding a
/// label they already have and nothing about that gesture asks to rename it.
public static func adding(_ name: String, to list: [String]) -> [String] {
guard let name = normalized(name), !contains(name, in: list) else { return deduplicated(list) }
return deduplicated(list) + [name]
}
/// The list without any case-insensitive match for `name`.
public static func removing(_ name: String, from list: [String]) -> [String] {
guard let name = normalized(name) else { return deduplicated(list) }
let key = canonical(name)
return deduplicated(list).filter { canonical($0) != key }
}
/// `removing` when the label is there, `adding` when it is not the context menu's row and the
/// dialog's checkbox both press exactly this.
public static func toggling(_ name: String, in list: [String]) -> [String] {
contains(name, in: list) ? removing(name, from: list) : adding(name, to: list)
}
// MARK: - Reading a parsed value
/// One sequence entry's reading as a label, or `nil` when it has none.
///
/// **Only a YAML string is a name**, which is a deliberate narrowing of the scalar-coercion family
/// every other lenient field belongs to (`title: 2048` reads as `"2048"`). Two reasons, and the
/// owner's ruling says the same thing in one sentence ("non-string entries preserved untouched but
/// not rendered"):
///
/// - A single-valued field has one value, so coercing it is the only way to have a reading at all.
/// A list has members, and a member that is not a name can simply be **kept** which is a better
/// outcome than guessing, because nothing is lost either way.
/// - `labels: [2026, ui]` is far more likely to be somebody's structured entry than a card tagged
/// with a number, and the app inventing the string `"2026"` for it would make that entry
/// unremovable-by-intent: the chip would say one thing and the file another.
///
/// **The reading a `setLabels(_:)` will leave behind** `nil` when the write removes the key.
///
/// It exists because an undo step has to declare the after-value its write produced
/// (`ExpectedField.labels`), and for this key that is not simply the list handed in: an empty list
/// removes the key, whose reading is an *absence* rather than an empty list unless entries the
/// reading cannot name are holding the key open, in which case the reading really is `[]`. Two
/// different bytes, two different expectations, and a step that declared the wrong one would skip
/// itself the first time somebody took the last label off a card.
///
/// A pure function rather than a re-read of the written document, so the prediction and the write
/// are the same rule stated once `FrontmatterDocument.setLabels` branches on exactly these two
/// facts.
public static func readingAfterWrite(_ names: [String], preservingEntries: Bool) -> [String]? {
let names = deduplicated(names)
guard !names.isEmpty || preservingEntries else { return nil }
return names
}
/// Also `nil` for a null, a nested sequence, a mapping, and anything that trims to nothing.
static func name(of entry: YAMLValue) -> String? {
guard case let .string(text) = entry else { return nil }
return normalized(text)
}
/// A parsed `labels` value's reading, or `nil` when it has **none at all** the transform behind
/// `FrontmatterDocument.labels`'s `.valid`/`.malformed` split.
///
/// - a **sequence** reads as its string entries, deduplicated, in file order (`[]` for a list with
/// no names in it, which is a perfectly good "no labels" and not a failure);
/// - a **bare scalar** coerces to a one-element list `labels: bug` is a card with one label, the
/// single-value shape an author or an agent reaches for first, and refusing it would make the
/// most forgivable spelling the one shape that renders nothing;
/// - a **mapping** has no list reading and is the one shape that lands `.malformed`, rendering as
/// no labels and leaving the coerce tier's trace.
///
/// A non-string *scalar* at the top level (`labels: 3`) is deliberately **not** coerced, for
/// `name(of:)`'s reason: it reads as no list, so it is malformed and preserved rather than becoming
/// a card labelled "3".
static func reading(of value: YAMLValue) -> [String]? {
switch value {
case let .sequence(entries):
return deduplicated(entries.compactMap(name(of:)))
case let .string(text):
return normalized(text).map { [$0] } ?? []
default:
return nil
}
}
}
// MARK: - The write side
/// **The write side of `labels`** the read side is `FrontmatterDocument.labels`
/// (FrontmatterFields.swift) and the rules are `CardLabels` above. `BackgroundField.swift`'s shape and
/// its reasoning, one collection over.
extension FrontmatterDocument {
/// The entries of the current `labels` value the reading could make no name of what
/// `setLabels(_:)` carries through untouched.
///
/// Empty for every shape that is not a sequence: a bare scalar has one entry and the reading
/// already made a name (or nothing) of it, and a mapping is the malformed shape a forward write
/// **replaces** outright the same malformed-value-cleared posture `icon` has ("choosing any well
/// replaces it", 03-board-ui.md § Styling Controls), and the right one here, since the reader
/// could not make a list of it either.
var preservedLabelEntries: [YAMLValue] {
guard case let .sequence(entries)? = value(for: FrontmatterKeys.labels) else { return [] }
return entries.filter { CardLabels.name(of: $0) == nil }
}
/// Writes the card's labels the **one** mutation every labels surface goes through.
///
/// ### Canonical form
///
/// A single-line **flow sequence** of double-quoted names, in the order given:
/// `labels: ["bug", "needs review"]`. Flow because the span editor rewrites a key's value with one
/// line's worth of text and `FrontmatterValue` has no sequence case to rewrite it with the same
/// `.raw` escape and the same narrow yield of the verbatim promise `setStyleValue` makes for
/// `background`'s mapping, documented at length in `BackgroundField.swift`. Double-quoted for that
/// file's reason exactly: `,` and `]` end a plain scalar in flow context, so a label with a comma
/// in it would otherwise break the collection it is written into.
///
/// **No sort.** The caller's order is the file's order, because the caller's order is the user's:
/// labels accumulate in the sequence they were applied, and an alphabetical rewrite on every touch
/// would be the app rearranging something the user arranged.
///
/// ### The empty case removes the key
///
/// Removing the last label removes `labels` outright rather than writing `labels: []` the
/// remove-at-default family (`collapsed`'s expand, the None well's `background`, a one-unit
/// `width`): an absent key is exactly the no-labels reading, and an empty list on every unlabelled
/// card would be noise the format does not need. The one exception is a list that still holds
/// preserved non-name entries, which keeps the key so those entries survive.
///
/// ### What survives, and what does not
///
/// Entries with no name reading are carried through **at the tail**, after the names. Their
/// *values* survive; their spelling does not a block sequence collapses to flow form, quoting is
/// normalized, and an entry's own inline comment is lost with the line it sat on. Tail rather than
/// in place because a name list has no positions worth preserving *for the names* (the user just
/// rearranged them by definition), and interleaving preserved entries back among them would make
/// the user's order depend on somebody else's structured data.
public mutating func setLabels(_ names: [String]) {
let names = CardLabels.deduplicated(names)
let preserved = preservedLabelEntries
guard !names.isEmpty || !preserved.isEmpty else {
remove(FrontmatterKeys.labels)
return
}
let entries = names.map { FrontmatterValue.emitQuoted($0) } + preserved.map(Self.flowText)
set(FrontmatterKeys.labels, to: .raw("[" + entries.joined(separator: ", ") + "]"))
}
}
+294
View File
@@ -0,0 +1,294 @@
import SwiftUI
// MARK: - The seam
/// **What the Labels section shows**, as pure functions over values the section's rules, stated
/// where a test can hold them rather than as `if`s inside a view body (`CardDetails`' own posture).
enum CardLabelsPicker {
/// How many suggestions the add field offers at once. Small on purpose: the sidebar is 26
/// characters wide, the list pushes the sections below it down while it is open, and a field whose
/// suggestion list is longer than the section it sits in has stopped being a hint.
static let suggestionLimit = 6
/// The labels the add field offers for `query`, in `universe`'s own order (frequency first
/// `LabelIndex`), never including one the card already carries.
///
/// **An empty query offers the board's top labels rather than nothing.** Opening the field on a
/// board that already has a vocabulary should show it that is the difference between an
/// autocomplete and a blank box, and it is the sidebar's answer to the same question the context
/// menu answers with its twelve rows.
///
/// Matching is a **case- and diacritic-insensitive substring**, which is board search's own rule
/// (04-interactions.md § Search) and therefore the one match semantics this app has.
static func suggestions(
for query: String,
universe: [String],
existing: [String],
limit: Int = suggestionLimit
) -> [String] {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
let candidates = universe.filter { !CardLabels.contains($0, in: existing) }
guard !query.isEmpty else { return Array(candidates.prefix(limit)) }
let matches = candidates.filter {
$0.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
}
return Array(matches.prefix(limit))
}
/// Whether committing `query` would **create** a label the board has never used what the field's
/// footer says out loud, so nobody mints a near-duplicate by accident.
///
/// `false` for a name that trims to nothing (there is nothing to create) and for one the board
/// already spells some way (that is an *apply*, and the board's spelling is what lands
/// `LabelCommand.resolved`).
static func createsNewLabel(_ query: String, universe: [String]) -> Bool {
guard let name = CardLabels.normalized(query) else { return false }
return !CardLabels.contains(name, in: universe)
}
}
// MARK: - The section
/// The sidebar's **Labels** section: the card's labels as removable rows, plus a quiet add field with
/// autocomplete against the board's used-labels universe and free-text creation
/// (`FrontmatterKeys.labels`, activated 2026-08-09 the key's own doc comment has the reversal's
/// story; 05-card-window.md The attributes sidebar owes an amendment naming this section).
///
/// ### Where it sits, and why
///
/// **Second: after Style, before Details, above Attachments.** The stack reads Style · Labels ·
/// Details · Attachments, and each boundary is a decision:
///
/// - **After Style** rather than before it, because Style is the section every card has and the one a
/// user learns the sidebar by. Labels are the first *content* attribute, and content comes after
/// appearance in a stack whose top is the card's look.
/// - **Before Details**, which is the load-bearing one. Details is the section for keys the app does
/// **not** own ("the raw source outlet is the write path for frontmatter the app doesn't own"), and
/// `labels` just stopped being one of those. Putting a first-party, editable section below the
/// read-only overflow bin would read as an afterthought appended to the unknowns; putting it above
/// says what is true the app owns this key now, and Details is what is left over.
/// - **Above Attachments**, which is settled and not mine to move: Attachments stays at the bottom of
/// the stack (reordered there 2026-08-09, Pipeline card 8f26b029) because it is the section that
/// grows without bound and advertises a drop surface.
///
/// The `VStack`'s child order *is* the Tab order and VoiceOver's reading order, so the paragraph above
/// is also the accessibility decision.
///
/// ### Rows, not chips
///
/// The scope allowed either. The sidebar is **26 characters wide** (`CardWindowMetrics.sidebarWidth`)
/// and every other section in it is a vertical stack of full-width rows Details' key-over-value
/// pairs, Attachments' thumbnail rows. A flowing chip cloud in a column that narrow either wraps every
/// second label onto its own line (which is a row, drawn worse) or truncates names to four characters.
/// So: one label per row, a quiet leading tag glyph, the name, and a remove button at the trailing
/// edge `AttachmentRow`'s silhouette, which is the idiom this column already teaches.
///
/// ### Present when empty, unlike Details
///
/// Details disappears on a card with no unknown keys because "there is nothing to teach" there. This
/// section stays, with a one-line hint, for **Attachments'** reason instead: it advertises an
/// affordance the user has to be able to find. A card window that showed no Labels section until the
/// card already had labels would leave the add affordance reachable only from the board's context
/// menu which is a different window.
struct CardLabelsSection: View {
let store: BoardStore
let recents: LabelRecents
let cardID: ItemID
/// The card's labels as the last reload read them passed in rather than looked up, so this
/// section renders exactly what the window's own `Card` value says, like every other section here.
let labels: [String]
/// **This window's undo stack** (13-native-undo.md Rules two levels) a label applied here is
/// a gesture *issued in this window*, so its step joins the window's session and reaches board
/// history only inside the coarse close step. `CardStyleSection.undo`'s reason verbatim.
let undo: CardWindowUndo
/// Whether the add field is showing. Local view state, `SymbolPicker`'s own posture: the window's
/// card cannot vanish out from under its own window, and when it does the window goes with it
/// (`CardWindowFate`), so there is no session to keep.
@State private var isAdding = false
/// What is typed in the add field.
@State private var draft = ""
@FocusState private var fieldFocused: Bool
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
/// The read-only lock and the board's inline-editing rule alike `CardStyleSection`'s symbol row
/// takes the same predicate, and 02-architecture.md's every-entry-point rule does not care which
/// entry point.
private var isEditable: Bool { store.acceptsBoardMutations }
var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "Labels") {
addAffordance
}
if labels.isEmpty, !isAdding {
emptyHint
} else {
rows
}
if isAdding {
addField
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Header
/// The header's quiet `+` `CardAttachmentsSection.addAffordance`'s twin, down to the glyph and
/// its weight, because two sections one apart in the same column offering "add one of these"
/// through two different controls would be the sidebar disagreeing with itself.
///
/// It **toggles** the field rather than only opening it, so the same key the user reached for puts
/// the field away again Escape does too, but a control that can only open is a control that
/// leaves litter.
private var addAffordance: some View {
Button {
isAdding.toggle()
if isAdding {
fieldFocused = true
} else {
draft = ""
}
} label: {
Image(systemName: "plus")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(!isEditable)
.help("Add Label")
.accessibilityLabel("Add Label")
}
// MARK: - Empty
/// One line, `CardAttachmentsSection.emptyHint`'s shape: it names the affordance that is on screen
/// rather than a menu path, because unlike Add Attachment there is no menu-bar row for this yet
/// the other way to reach it is the board's card context menu, which is a different window and no
/// use to somebody reading this one.
private var emptyHint: some View {
Text("No labels. Press + to add one.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// MARK: - Rows
private var rows: some View {
VStack(alignment: .leading, spacing: 1) {
ForEach(labels, id: \.self) { name in
row(name)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func row(_ name: String) -> some View {
HStack(spacing: 4) {
Image(systemName: "tag")
.font(.caption)
.foregroundStyle(.secondary)
Text(name)
.font(.callout)
.lineLimit(1)
.truncationMode(.middle)
.help(name)
Spacer(minLength: 4)
Button {
LabelCommand.remove(name, fromCard: cardID, in: store, recents: recents, on: undo)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(!isEditable)
.help("Remove Label")
.accessibilityLabel("Remove \(name)")
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .contain)
.accessibilityLabel(name)
}
// MARK: - Adding
/// The add field and its suggestions.
///
/// **Return commits, Escape cancels** the inline editors' grammar (04-interactions.md Grammar),
/// which is the one commit vocabulary this app has. Committing does *not* close the field: adding
/// several labels in a row is the common case, so the field clears and stays, and the `+` (or
/// Escape) is what puts it away.
@ViewBuilder
private var addField: some View {
VStack(alignment: .leading, spacing: 2) {
TextField("Label", text: $draft)
.textFieldStyle(.roundedBorder)
.font(.callout)
.focused($fieldFocused)
.disabled(!isEditable)
.onSubmit { commit() }
.onExitCommand {
draft = ""
isAdding = false
}
.accessibilityLabel("New Label")
ForEach(suggestions, id: \.self) { name in
Button {
apply(name)
} label: {
Text(name)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.disabled(!isEditable)
}
if CardLabelsPicker.createsNewLabel(draft, universe: store.labelIndex.names) {
// The only feedback that matters here: everything else in this field applies a label
// that already exists somewhere on the board, and this one mints a new word for it.
Text("Return creates “\(draft.trimmingCharacters(in: .whitespacesAndNewlines))")
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var suggestions: [String] {
CardLabelsPicker.suggestions(
for: draft,
universe: store.labelIndex.names,
existing: labels
)
}
/// Return's landing: apply what is typed, clear, stay open.
private func commit() {
guard CardLabels.normalized(draft) != nil else { return }
apply(draft)
}
private func apply(_ name: String) {
LabelCommand.add(name, onCard: cardID, in: store, recents: recents, on: undo)
draft = ""
fieldFocused = true
}
}
+25 -6
View File
@@ -84,6 +84,10 @@ struct CardWindowView: View {
/// Controls) app state, not board state, which is why it arrives beside the store rather than
/// on it.
let recents: StyleRecents
/// The app-wide recently-applied labels the Labels section feeds (`LabelRecents`;
/// `FrontmatterKeys.labels`) `recents`' neighbour in every respect, app state rather than board
/// state, arriving beside the store for that field's reason exactly.
let labelRecents: LabelRecents
/// The card's folder on disk what relative images and links in the body resolve against
/// (05 Preview). `nil` only where a caller has no board root to build it from.
let cardFolder: URL?
@@ -449,8 +453,8 @@ struct CardWindowView: View {
// MARK: - Attributes sidebar
/// The sidebar's sections: **Style, Details, Attachments** Attachments at the bottom of the
/// stack (reordered 2026-08-09, Pipeline card 8f26b029), Actions gone entirely (retired the same
/// The sidebar's sections: **Style, Labels, Details, Attachments** Attachments at the bottom of
/// the stack (reordered 2026-08-09, Pipeline card 8f26b029), Actions gone entirely (retired the same
/// day, Pipeline card bcd3b323): its Delete and Reveal in Finder are now the card window's own
/// toolbar items (`CardToolbar`), reachable from Customize and, for Delete, on by default. This
/// is exactly the `VStack`'s child order, so keyboard Tab order and VoiceOver's reading order
@@ -462,10 +466,15 @@ struct CardWindowView: View {
/// with Actions at the bottom an owed amendment, tracked on the two cards' own threads rather
/// than made here.
///
/// One of the two remaining sections is conditional, and the condition is the section's own
/// rather than a rule restated here: **Details** renders nothing when the card carries no unknown
/// frontmatter keys ("shown only when any exist"). Style and Attachments are unconditional, so
/// the composition a user learns on one card is the composition they get on the next.
/// One of the four sections is conditional, and the condition is the section's own rather than a
/// rule restated here: **Details** renders nothing when the card carries no unknown frontmatter
/// keys ("shown only when any exist"). Style, Labels and Attachments are unconditional, so the
/// composition a user learns on one card is the composition they get on the next.
///
/// **Labels joined 2026-08-09** (Pipeline card a4462d28), sitting second see
/// `CardLabelsSection`'s own doc comment for why it goes after Style and, load-bearingly, *above*
/// Details: `labels` stopped being an unknown key that day, and Details is the section for keys the
/// app does not own.
///
/// The History section that once sat between Details and Actions left with app-managed git
/// (strategy/01-git-excision.md, 2026-08-08); View History (`FutureCommands.swift`) is the only
@@ -475,6 +484,16 @@ struct CardWindowView: View {
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
CardStyleSection(store: store, recents: recents, cardID: card.id, undo: undo)
// The snapshot's own reading, not a store lookup: this window's `Card` is the value
// the last reload produced, and every other section here renders off it.
CardLabelsSection(
store: store,
recents: labelRecents,
cardID: card.id,
labels: card.labels.value ?? [],
undo: undo
)
// The snapshot's own document, not a re-read: the loader parsed this file, unknown
// keys and their order included, and `Card` has carried it since (`BoardModel`).
CardDetailsSection(rows: CardDetails.rows(of: card.document))
+89
View File
@@ -0,0 +1,89 @@
import Foundation
/// **The one funnel every label gesture goes through** the write plus the recents record, so no
/// anchor can do one without the other (`StyleCommand`'s shape and its reason exactly:
/// `StyleEditor.swift`).
///
/// Two anchors exist today and they are in different windows the card window's sidebar section and
/// the board card menu's `labels` submenu, plus that submenu's More dialog. Every one of them ends
/// up here, which is what keeps "the MRU is updated on every label apply" a fact rather than three
/// call sites' good intentions.
///
/// ### Recording the add half only
///
/// A **removal records nothing**. The MRU exists to answer "what is this user reaching for", and
/// taking `bug` off a card is evidence of the opposite recording it would float a label to the top
/// of the very menu the user is trying to get away from. `StyleRecents`' own "the None well is not a
/// colour" carve-out, one field over (`LabelRecents`).
///
/// ### Spelling is the board's, not the typist's
///
/// A name typed into the sidebar's field or the dialog's create box is resolved against the board's
/// own universe first (`LabelIndex.canonicalSpelling(of:)`), so typing `BUG` onto a board that already
/// says `bug` tags the card `bug` rather than minting a second variant nothing can tell apart. Only a
/// genuinely new name keeps the typist's capitalisation which is exactly right, because for a new
/// label the typist *is* the board.
@MainActor
enum LabelCommand {
/// Adds or removes `name` on one card, whichever the card's current list calls for the context
/// menu's rows and the dialog's checkboxes.
///
/// - Parameter undo: the issuing window's own stack, for the one anchor that has one the card
/// window's sidebar (13-native-undo.md Rules two levels). `nil`, which every board-side
/// anchor passes, is the board's stack.
/// - Returns: whether bytes reached disk.
@discardableResult
static func toggle(
_ name: String,
onCard cardID: ItemID,
in store: BoardStore,
recents: LabelRecents,
on undo: CardWindowUndo? = nil
) -> Bool {
let current = store.labels(ofCard: cardID)
guard CardLabels.contains(name, in: current) else {
return add(name, onCard: cardID, in: store, recents: recents, on: undo)
}
return store.setLabels(CardLabels.removing(name, from: current), onCard: cardID, on: undo)
}
/// Adds `name` to one card the sidebar's add field and the dialog's create box, which both mean
/// "put this on the card" rather than "flip whatever it is now".
///
/// A name the card already carries is a no-op that **still records**: the user reached for it, and
/// the MRU's whole subject is what they reach for.
@discardableResult
static func add(
_ name: String,
onCard cardID: ItemID,
in store: BoardStore,
recents: LabelRecents,
on undo: CardWindowUndo? = nil
) -> Bool {
guard let name = resolved(name, in: store) else { return false }
recents.record(name)
let current = store.labels(ofCard: cardID)
return store.setLabels(CardLabels.adding(name, to: current), onCard: cardID, on: undo)
}
/// Removes `name` from one card the chip's . Records nothing (see the type comment).
@discardableResult
static func remove(
_ name: String,
fromCard cardID: ItemID,
in store: BoardStore,
recents _: LabelRecents,
on undo: CardWindowUndo? = nil
) -> Bool {
let current = store.labels(ofCard: cardID)
return store.setLabels(CardLabels.removing(name, from: current), onCard: cardID, on: undo)
}
/// A typed name in the board's own spelling see the type comment. `nil` for a name that trims to
/// nothing, which is not a label anybody meant.
static func resolved(_ name: String, in store: BoardStore) -> String? {
guard let name = CardLabels.normalized(name) else { return nil }
return store.labelIndex.canonicalSpelling(of: name) ?? name
}
}
+9 -3
View File
@@ -109,7 +109,11 @@ struct BoardWriterPreservationTests {
#expect(after.contains("title: Renamed\n"))
let document = try FrontmatterDocument.parse(after)
#expect(document.unknownFields.map(\.key) == ["project", "sphere", "labels"])
// `labels` left this list on 2026-08-09 it is schema-owned now (`FrontmatterKeys.labels`),
// which is exactly why the `keys` and `rawValue` assertions below still name it: the write path
// preserves it verbatim like any other key it did not touch, and only its *classification*
// moved.
#expect(document.unknownFields.map(\.key) == ["project", "sphere"])
#expect(document.keys == [
"schema", "title", "order", "project", "sphere", "labels", "created", "modified",
])
@@ -1169,7 +1173,8 @@ struct BoardWriterMoveTests {
#expect(document.created == source.created)
#expect(document.modifiedBy == .missing)
#expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60)
#expect(document.unknownFields.map(\.key) == ["project", "labels"])
#expect(document.unknownFields.map(\.key) == ["project"], "`labels` is schema-owned since 2026-08-09")
#expect(document.rawValue(for: FrontmatterKeys.labels) == "[a, b, c]", "and still preserved verbatim")
#expect(after.contains("project: lanework # agent overlay\n"))
// The move rewrites exactly one file: a destination sibling is not even opened.
@@ -1529,7 +1534,8 @@ struct BoardWriterCopyTests {
#expect(document.created == sourceDocument.created)
#expect(document.modifiedBy == .missing)
#expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60)
#expect(document.unknownFields.map(\.key) == ["project", "labels"])
#expect(document.unknownFields.map(\.key) == ["project"], "`labels` is schema-owned since 2026-08-09")
#expect(document.rawValue(for: FrontmatterKeys.labels) == "[a, b, c]", "and still preserved verbatim")
#expect(document.body == "Card One body — with *markdown*.\n")
// The source is never touched, on any path.
+629
View File
@@ -0,0 +1,629 @@
import Foundation
import Testing
@testable import Kanban
/// **The `labels` key, activated** (2026-08-09, Pipeline cards a4462d28 and 28c79ffe) the schema's
/// reading and writing, the rules of a list of names, the board-wide used-labels universe, the menu's
/// ranking, and the store write that lands it.
///
/// The key's own story is `FrontmatterKeys.labels`: it was a reserved tracker key beside `assignees`,
/// `due` and `remote` until the owner claimed it for first-party use, which is the design event these
/// tests exist to pin. Everything here is a pure seam except the last suite, which writes real bytes.
// MARK: - The name rules
@Suite("Labels ▸ one name")
struct CardLabelNameTests {
@Test("A name is trimmed, and nothing is not a name")
func normalization() {
#expect(CardLabels.normalized("bug") == "bug")
#expect(CardLabels.normalized(" bug ") == "bug")
#expect(CardLabels.normalized("needs review") == "needs review", "interior spaces are the author's")
#expect(CardLabels.normalized("") == nil)
#expect(CardLabels.normalized(" ") == nil)
#expect(CardLabels.normalized("\n\t") == nil)
}
@Test("Identity is case-insensitive; display is not")
func caseFolding() {
#expect(CardLabels.canonical("Bug") == CardLabels.canonical("bug"))
#expect(CardLabels.contains("BUG", in: ["bug"]))
#expect(CardLabels.contains("bug", in: ["Bug"]))
#expect(!CardLabels.contains("bugs", in: ["bug"]))
#expect(!CardLabels.contains(" ", in: ["bug"]))
}
}
// MARK: - The list rules
@Suite("Labels ▸ one list")
struct CardLabelListTests {
@Test("Duplicates collapse case-insensitively, and the first spelling holds its place")
func deduplication() {
#expect(CardLabels.deduplicated(["Bug", "ui", "bug"]) == ["Bug", "ui"])
#expect(CardLabels.deduplicated([" bug ", "BUG"]) == ["bug"])
#expect(CardLabels.deduplicated(["a", "", " ", "b"]) == ["a", "b"])
#expect(CardLabels.deduplicated([]) == [])
}
@Test("Adding appends — never sorts, never respells an existing label")
func adding() {
#expect(CardLabels.adding("ui", to: ["bug"]) == ["bug", "ui"])
// Alphabetically `a` belongs first; the user put it last, so it goes last.
#expect(CardLabels.adding("a", to: ["z"]) == ["z", "a"])
#expect(CardLabels.adding("Bug", to: ["bug"]) == ["bug"], "the existing spelling wins")
#expect(CardLabels.adding(" ", to: ["bug"]) == ["bug"])
}
@Test("Removing takes every case-variant, and toggling is the pair")
func removingAndToggling() {
#expect(CardLabels.removing("BUG", from: ["bug", "ui"]) == ["ui"])
#expect(CardLabels.removing("nope", from: ["bug"]) == ["bug"])
#expect(CardLabels.toggling("ui", in: ["bug"]) == ["bug", "ui"])
#expect(CardLabels.toggling("UI", in: ["bug", "ui"]) == ["bug"])
#expect(CardLabels.toggling("ui", in: CardLabels.toggling("ui", in: ["bug"])) == ["bug"], "twice is a no-op")
}
}
// MARK: - Reading
@Suite("Labels ▸ the schema's reading")
struct CardLabelReadingTests {
private func document(_ frontmatter: String) throws -> FrontmatterDocument {
try FrontmatterDocument.parse("---\nschema: 1\n\(frontmatter)---\nBody.\n")
}
@Test("Both list spellings read the same")
func listForms() throws {
#expect(try document("labels: [bug, ui]\n").labels == .valid(["bug", "ui"]))
#expect(try document("labels: [\"bug\", \"needs review\"]\n").labels == .valid(["bug", "needs review"]))
#expect(try document("labels:\n - bug\n - ui\n").labels == .valid(["bug", "ui"]))
#expect(try document("labels: []\n").labels == .valid([]))
}
@Test("A bare scalar coerces to one label")
func scalarCoercion() throws {
#expect(try document("labels: bug\n").labels == .valid(["bug"]))
#expect(try document("labels: \"needs review\"\n").labels == .valid(["needs review"]))
#expect(try document("labels: \" bug \"\n").labels == .valid(["bug"]), "trimmed like any other name")
}
@Test("Absent, and an explicit null, are both no labels")
func absence() throws {
#expect(try document("").labels.isMissing)
#expect(try document("labels:\n").labels.isMissing)
#expect(try document("labels: null\n").labels.isMissing)
}
@Test("A mapping — and a non-string scalar — have no list reading at all")
func malformedShapes() throws {
#expect(try document("labels: {a: 1}\n").labels.isMalformed)
// Deliberately not coerced to `["3"]`: see `CardLabels.name(of:)`. A card is not labelled
// with a number because somebody wrote one.
#expect(try document("labels: 3\n").labels.isMalformed)
#expect(try document("labels: true\n").labels.isMalformed)
}
@Test("Entries with no name reading are skipped, not fatal")
func nonStringEntries() throws {
#expect(try document("labels: [bug, 3, {a: 1}, ui]\n").labels == .valid(["bug", "ui"]))
// A list of nothing but unreadable entries is a list, and a list with no names in it is
// `.valid([])` the author wrote the right shape.
#expect(try document("labels: [{a: 1}]\n").labels == .valid([]))
}
@Test("The reading deduplicates case-insensitively, first spelling winning")
func readingDeduplicates() throws {
#expect(try document("labels: [Bug, ui, bug]\n").labels == .valid(["Bug", "ui"]))
}
@Test("A shape with no reading leaves the coerce tier's trace")
func coerceTrace() throws {
let coerced = try document("labels: {a: 1}\n").coercedFields
#expect(coerced.contains { $0.key == FrontmatterKeys.labels })
#expect(try document("labels: [bug]\n").coercedFields.isEmpty)
}
@Test("`labels` is schema-owned, so Details never draws a second copy of it")
func schemaOwnership() throws {
let parsed = try document("labels: [bug]\nproject: lanework\n")
#expect(FrontmatterKeys.schemaOwned.contains(FrontmatterKeys.labels))
#expect(parsed.unknownFields.map(\.key) == ["project"])
#expect(CardDetails.rows(of: parsed).map(\.key) == ["project"])
// The three still-reserved tracker keys are untouched by the reversal.
for reserved in [FrontmatterKeys.remote, "assignees", "due"] {
#expect(!FrontmatterKeys.schemaOwned.contains(reserved), "\(reserved) is still reserved")
}
}
@Test("The snapshot carries the reading on the card itself")
func theCardCarriesIt() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\n---\n")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\nkind: card\norder: 1024\nlabels: [bug, ui]\n---\n")
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes.first?.cards.first?.labels == .valid(["bug", "ui"]))
}
}
// MARK: - Writing
@Suite("Labels ▸ the canonical write")
struct CardLabelWriteTests {
private func rewritten(_ frontmatter: String, to names: [String]) throws -> String {
var document = try FrontmatterDocument.parse("---\nschema: 1\n\(frontmatter)---\nBody.\n")
document.setLabels(names)
return document.serialized()
}
@Test("Names land as a quoted flow list, in the order given")
func canonicalForm() throws {
#expect(try rewritten("", to: ["bug", "ui"]).contains("labels: [\"bug\", \"ui\"]\n"))
// No sort: the caller's order is the user's order.
#expect(try rewritten("", to: ["z", "a"]).contains("labels: [\"z\", \"a\"]\n"))
// Quoting is what makes a name with a comma safe inside a flow collection.
#expect(try rewritten("", to: ["a, b"]).contains("labels: [\"a, b\"]\n"))
}
@Test("A rewrite replaces the value in place and touches nothing else")
func byteSurgical() throws {
let after = try rewritten(
"title: Fix login\nlabels: [old]\nproject: lanework # agent overlay\n",
to: ["new"]
)
#expect(after == """
---
schema: 1
title: Fix login
labels: ["new"]
project: lanework # agent overlay
---
Body.
""")
}
@Test("The last label removes the key — the remove-at-default family")
func emptyRemovesTheKey() throws {
let after = try rewritten("labels: [bug]\nproject: lanework\n", to: [])
#expect(!after.contains("labels"))
#expect(after.contains("project: lanework\n"))
// And a document that never had the key does not grow one.
#expect(try rewritten("", to: []) == "---\nschema: 1\n---\nBody.\n")
}
@Test("Entries with no name reading ride through, at the tail")
func preservedEntries() throws {
#expect(try rewritten("labels: [bug, 3, {a: 1}]\n", to: ["ui"])
.contains("labels: [\"ui\", 3, {a: 1}]\n"))
// And they keep the key alive even when every name is gone.
#expect(try rewritten("labels: [bug, 3]\n", to: []).contains("labels: [3]\n"))
}
@Test("A malformed value is replaced outright — the malformed-value-cleared posture")
func malformedIsReplaced() throws {
#expect(try rewritten("labels: {a: 1}\n", to: ["bug"]).contains("labels: [\"bug\"]\n"))
#expect(try rewritten("labels: bug\n", to: ["bug", "ui"]).contains("labels: [\"bug\", \"ui\"]\n"))
}
@Test("The write applies the list rules, so no caller can land a duplicate")
func writeDeduplicates() throws {
#expect(try rewritten("", to: ["Bug", "bug", " ", "ui"]).contains("labels: [\"Bug\", \"ui\"]\n"))
}
@Test("Write, read, write — the canonical form is a fixed point")
func roundTrip() throws {
var document = try FrontmatterDocument.parse("---\nschema: 1\nlabels:\n - bug\n - ui\n---\nBody.\n")
let read = try #require(document.labels.value)
document.setLabels(read)
let once = document.serialized()
var again = try FrontmatterDocument.parse(once)
again.setLabels(try #require(again.labels.value))
#expect(again.serialized() == once)
#expect(again.labels == .valid(["bug", "ui"]))
}
}
// MARK: - The universe
@Suite("Labels ▸ the used-labels universe")
struct LabelIndexTests {
private func board(_ labelsPerCard: [[String]], trash: [[String]] = []) throws -> BoardModel {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\nkind: lane\norder: 1024\n---\n")
func text(_ labels: [String], order: Int) -> String {
let list = labels.map { "\"\($0)\"" }.joined(separator: ", ")
let key = labels.isEmpty ? "" : "labels: [\(list)]\n"
return "---\nschema: 1\nkind: card\norder: \(order)\n\(key)---\n"
}
for (offset, labels) in labelsPerCard.enumerated() {
try fixture.item("\(Ident.lane1)/\(Self.identifier(offset))", text(labels, order: (offset + 1) * 1024))
}
for (offset, labels) in trash.enumerated() {
try fixture.item(".trash/\(Self.identifier(100 + offset))", text(labels, order: (offset + 1) * 1024))
}
return try BoardLoader.load(boardRoot: fixture.root).model
}
private static func identifier(_ n: Int) -> String {
let hex = String(format: "%012x", n)
return "aaaaaaaa-aaaa-4aaa-8aaa-\(hex)"
}
@Test("A board with no labels has an empty universe")
func empty() throws {
#expect(LabelIndex.derive(from: try board([[], []])) == LabelIndex.empty)
#expect(LabelIndex.empty.isEmpty)
#expect(LabelIndex.empty.alphabetical.isEmpty)
}
@Test("Every live card contributes, and frequency is a card count")
func counting() throws {
let index = LabelIndex.derive(from: try board([["bug", "ui"], ["bug"], ["bug", "ops"]]))
#expect(index.names == ["bug", "ops", "ui"], "frequency first, then alphabetical")
#expect(index.tallies.map(\.count) == [3, 1, 1])
}
@Test("The trash counts — deleting the last card carrying a label does not evict it")
func theTrashCounts() throws {
let index = LabelIndex.derive(from: try board([["bug"]], trash: [["spike"], ["bug"]]))
#expect(index.names == ["bug", "spike"])
#expect(index.tallies.first?.count == 2)
}
@Test("Two cards spelling one label two ways are one label, in the first spelling seen")
func spellingIsBoardOrder() throws {
let index = LabelIndex.derive(from: try board([["Bug"], ["bug"], ["bug"]]))
#expect(index.names == ["Bug"])
#expect(index.tallies.first?.count == 3)
#expect(index.contains("BUG"))
#expect(index.canonicalSpelling(of: "bug") == "Bug")
#expect(index.canonicalSpelling(of: "nope") == nil)
}
@Test("The derivation is a total order, so two runs agree")
func deterministic() throws {
let model = try board([["b", "a"], ["a", "b"], ["c"]])
#expect(LabelIndex.derive(from: model) == LabelIndex.derive(from: model))
#expect(LabelIndex.derive(from: model).names == ["a", "b", "c"])
}
@Test("The dialog's listing is alphabetical, not frequency-ordered")
func alphabetical() throws {
let index = LabelIndex.derive(from: try board([["zebra", "apple"], ["zebra"]]))
#expect(index.names == ["zebra", "apple"], "frequency for the menu")
#expect(index.alphabetical == ["apple", "zebra"], "lookup order for the dialog")
}
}
// MARK: - The ranking
@Suite("Labels ▸ the menu's twelve")
struct LabelRankingTests {
private func index(_ pairs: [(String, Int)]) -> LabelIndex {
LabelIndex(tallies: pairs.map { LabelTally(name: $0.0, count: $0.1) }
.sorted { $0.count != $1.count ? $0.count > $1.count : $0.name < $1.name })
}
@Test("Frequency leads")
func frequencyLeads() {
let ranked = LabelRanking.ranked(index([("rare", 1), ("common", 9)]), recents: ["rare"])
#expect(ranked == ["common", "rare"], "recency does not outrank frequency")
}
@Test("Recency breaks a frequency tie, most recent first")
func recencyBreaksTies() {
let tied = index([("a", 2), ("b", 2), ("c", 2)])
#expect(LabelRanking.ranked(tied, recents: ["c", "b"]) == ["c", "b", "a"])
#expect(LabelRanking.ranked(tied, recents: []) == ["a", "b", "c"], "alphabetical with no MRU")
}
@Test("A label the MRU has never seen sorts behind every label it has")
func unseenSortsLast() {
let tied = index([("known", 1), ("unknown", 1)])
#expect(LabelRanking.ranked(tied, recents: ["known"]) == ["known", "unknown"])
}
@Test("MRU entries naming labels this board does not use are inert")
func foreignRecentsAreIgnored() {
let tied = index([("a", 1), ("b", 1)])
#expect(LabelRanking.ranked(tied, recents: ["from-another-board", "b"]) == ["b", "a"])
}
@Test("Recency matching is case-insensitive, like every other label comparison")
func recencyFolds() {
let tied = index([("Bug", 1), ("ui", 1)])
#expect(LabelRanking.ranked(tied, recents: ["BUG"]) == ["Bug", "ui"])
}
@Test("The limit is twelve, and it truncates the ranking rather than the universe")
func theLimit() {
let many = index((0 ..< 20).map { (String(format: "label%02d", $0), 20 - $0) })
#expect(LabelRanking.menuLimit == 12)
#expect(LabelRanking.ranked(many, recents: []).count == 12)
#expect(LabelRanking.ranked(many, recents: []).first == "label00", "the most used")
#expect(LabelRanking.ranked(many, recents: [], limit: 0).isEmpty)
#expect(LabelRanking.ranked(LabelIndex.empty, recents: ["a"]).isEmpty)
}
/// The consequence the ranking's own doc comment names out loud, pinned so it cannot change
/// silently: a brand-new label does **not** jump the queue on a board with a full menu.
@Test("A freshly invented label waits its turn behind twelve busier ones")
func freshLabelsDoNotJumpTheQueue() {
var pairs = (0 ..< 12).map { (String(format: "busy%02d", $0), 5) }
pairs.append(("brand-new", 1))
let ranked = LabelRanking.ranked(index(pairs), recents: ["brand-new"])
#expect(!ranked.contains("brand-new"))
#expect(ranked.count == 12)
}
}
// MARK: - The MRU
@Suite("Labels ▸ the recents list")
struct LabelRecentsTests {
/// `@MainActor` for the same reason `StyleRecents`' own rule is reached that way: the function is
/// pure, but it lives on a `@MainActor` class, so the isolation rides along.
@MainActor
@Test("Move to front, deduped case-insensitively, capped")
func theListRule() {
#expect(LabelRecents.updated([], with: "bug") == ["bug"])
#expect(LabelRecents.updated(["a", "b"], with: "b") == ["b", "a"])
#expect(LabelRecents.updated(["Bug", "a"], with: "bug") == ["bug", "a"], "the new spelling lands")
#expect(LabelRecents.updated(["a"], with: " ") == ["a"])
#expect(LabelRecents.updated((0 ..< 30).map(String.init), with: "x", cap: 3) == ["x", "0", "1"])
}
@MainActor
@Test("Recording persists, and an unchanged list writes nothing observable")
func persistence() {
let suite = "LabelRecentsTests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let recents = LabelRecents(defaults: defaults)
#expect(recents.labels.isEmpty)
recents.record("bug")
recents.record("ui")
#expect(recents.labels == ["ui", "bug"])
recents.record("ui")
#expect(recents.labels == ["ui", "bug"], "already at the front")
#expect(LabelRecents(defaults: defaults).labels == ["ui", "bug"], "read back from the domain")
}
@MainActor
@Test("A hand-broken preference is an empty list, never a crash")
func lenientRead() {
let suite = "LabelRecentsTests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
defaults.set(42, forKey: AppPreferences.labelRecentsKey)
#expect(LabelRecents(defaults: defaults).labels.isEmpty)
}
}
// MARK: - The write vocabulary
@Suite("Labels ▸ the write operation")
struct LabelWriteOperationTests {
@Test("`.relabel` is its own word, enriched with the card's title, and it stamps")
func theOperation() {
#expect(WriteOperation.relabel(title: nil).withTitle("Fix login") == .relabel(title: "Fix login"))
#expect(!WriteOperation.relabel(title: "Fix login").rewritesOrderOnly, "a labels change is content")
#expect(WriteOperation.relabel(title: "Fix login").description == "relabel 'Fix login'")
#expect(WriteOperation.relabel(title: nil).description == "relabel")
}
@Test("The undo step's field compares the whole list")
func theExpectedField() throws {
let document = try FrontmatterDocument.parse("---\nschema: 1\nlabels: [\"bug\", \"ui\"]\n---\n")
#expect(HistoryStaleness.matches(.labels(["bug", "ui"]), in: document))
#expect(!HistoryStaleness.matches(.labels(["bug"]), in: document), "a foreign retag stales it")
#expect(!HistoryStaleness.matches(.labels(nil), in: document))
let bare = try FrontmatterDocument.parse("---\nschema: 1\n---\n")
#expect(HistoryStaleness.matches(.labels(nil), in: bare))
#expect(!HistoryStaleness.matches(.labels([]), in: bare), "an absent key and an empty list differ")
let broken = try FrontmatterDocument.parse("---\nschema: 1\nlabels: {a: 1}\n---\n")
#expect(!HistoryStaleness.matches(.labels(nil), in: broken), "a malformed value matches nothing")
}
@Test("The menu row and the change journal say the same word")
func thePhrase() {
#expect(HistoryPhrase.name(.relabel, kind: .card) == "Relabel Card")
#expect(HistoryPhrase.Verb.relabel.rawValue == "Relabel")
}
}
// MARK: - The store write
@MainActor
@Suite("Labels ▸ the store write")
struct LabelStoreWriteTests {
private func labels(of card: String, lane: String, in fixture: WriterFixture) throws -> FieldValue<[String]> {
try FrontmatterDocument.parse(fixture.indexText("\(lane)/\(card)")).labels
}
/// The reload between two writes is not ceremony: the no-op guard reads the **snapshot**, which is
/// one reload behind every write the app makes (the one-way flow) `SetAsHeroTests`' own note.
private func settle(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
}
@Test("Setting a list writes the canonical form; emptying it takes the key")
func setThenEmpty() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.setLabels(["bug", "ui"], onCard: clipboardCard1))
#expect(try labels(of: Ident.card1, lane: Ident.lane1, in: fixture) == .valid(["bug", "ui"]))
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("labels: [\"bug\", \"ui\"]"))
await settle(store)
#expect(store.setLabels([], onCard: clipboardCard1))
#expect(try labels(of: Ident.card1, lane: Ident.lane1, in: fixture).isMissing)
#expect(store.banners.oneShots.isEmpty)
}
@Test("Writing the list a card already has is a no-op — no write, no step")
func redundantWritesAreFree() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// `Item.rich` ships `labels: [a, b, c]`, so the card already reads exactly this.
#expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c"])
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
#expect(!store.setLabels(["a", "b", "c"], onCard: clipboardCard1))
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
// And the normalization runs before the comparison, so a differently-spelled no-op is one too.
#expect(!store.setLabels(["a", "b", "c", "A"], onCard: clipboardCard1))
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
}
@Test("A write stamps `modified` and clears foreign attribution, like any content write")
func itStamps() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.setLabels(["bug"], onCard: clipboardCard1))
let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)"))
#expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60)
#expect(document.modifiedBy == .missing, "`Item.rich` wrote `modified-by: claude`")
#expect(document.title == .valid("First"), "and nothing else moved")
}
@Test("The guards are the hero's: the board container, and cards only")
func theGuards() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(!store.setLabels(["bug"], onCard: clipboardCard3), "in the trash")
#expect(!store.setLabels(["bug"], onCard: clipboardLane1), "a lane has no labels")
#expect(!store.setLabels(["bug"], onCard: ItemID(rawValue: Ident.indexless)), "names nothing")
}
@Test("The read seam sees both containers, even where the write refuses")
func readingSpansContainers() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c"])
#expect(store.labels(ofCard: clipboardCard3).isEmpty, "the trash fixture carries no labels key")
#expect(store.labels(ofCard: ItemID(rawValue: Ident.indexless)).isEmpty)
}
@Test("The cached universe is derived at open and re-derived on a landing")
func theCachedUniverse() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Every `Item.rich` card ships `labels: [a, b, c]`; the trash resident ships none.
#expect(store.labelIndex.names == ["a", "b", "c"])
#expect(store.labelIndex.tallies.first?.count == 3, "the three `Item.rich` cards; the trash resident has none")
#expect(store.setLabels(["a", "b", "c", "spike"], onCard: clipboardCard1))
await settle(store)
#expect(store.labelIndex.contains("spike"))
#expect(store.labelIndex.names.last == "spike", "one card carries it, so it ranks last")
}
@Test("⌘Z puts the previous list back, and ⇧⌘Z the new one")
func undoAndRedo() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
#expect(store.setLabels(["bug"], onCard: clipboardCard1))
#expect(try labels(of: Ident.card1, lane: Ident.lane1, in: fixture) == .valid(["bug"]))
#expect(history.undoActionName == "Relabel Card")
history.undo()
#expect(try labels(of: Ident.card1, lane: Ident.lane1, in: fixture) == .valid(["a", "b", "c"]))
history.redo()
#expect(try labels(of: Ident.card1, lane: Ident.lane1, in: fixture) == .valid(["bug"]))
}
@Test("Undoing the write that removed the last label restores it; undoing a first write removes the key")
func undoAcrossTheKeysBoundary() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
// `card4` is `Item.rich` too, so start by clearing it, then undo across the key's removal.
#expect(store.setLabels([], onCard: clipboardCard4))
#expect(try labels(of: Ident.card4, lane: Ident.lane2, in: fixture).isMissing)
history.undo()
#expect(try labels(of: Ident.card4, lane: Ident.lane2, in: fixture) == .valid(["a", "b", "c"]))
}
}
// MARK: - The sidebar's picker
@Suite("Labels ▸ the sidebar's add field")
struct CardLabelsPickerTests {
private let universe = ["bug", "ui", "backend", "needs review"]
@Test("An empty query offers the board's own vocabulary")
func emptyQuery() {
#expect(CardLabelsPicker.suggestions(for: "", universe: universe, existing: []) == universe)
#expect(CardLabelsPicker.suggestions(for: " ", universe: universe, existing: []) == universe)
}
@Test("Matching is a case-insensitive substring, board search's own rule")
func matching() {
#expect(CardLabelsPicker.suggestions(for: "U", universe: universe, existing: []) == ["bug", "ui"])
#expect(CardLabelsPicker.suggestions(for: "review", universe: universe, existing: []) == ["needs review"])
#expect(CardLabelsPicker.suggestions(for: "zzz", universe: universe, existing: []).isEmpty)
}
@Test("A label the card already carries is never offered")
func existingIsExcluded() {
#expect(CardLabelsPicker.suggestions(for: "", universe: universe, existing: ["BUG"])
== ["ui", "backend", "needs review"])
}
@Test("The list is capped")
func theCap() {
let many = (0 ..< 30).map { "label\($0)" }
#expect(CardLabelsPicker.suggestions(for: "label", universe: many, existing: []).count
== CardLabelsPicker.suggestionLimit)
#expect(CardLabelsPicker.suggestions(for: "", universe: many, existing: [], limit: 2).count == 2)
}
@Test("The create hint fires only for a name the board has never spelled")
func theCreateHint() {
#expect(CardLabelsPicker.createsNewLabel("spike", universe: universe))
#expect(!CardLabelsPicker.createsNewLabel("BUG", universe: universe), "that is an apply, not a create")
#expect(!CardLabelsPicker.createsNewLabel(" ", universe: universe))
#expect(!CardLabelsPicker.createsNewLabel("", universe: universe))
}
}
+15 -7
View File
@@ -62,7 +62,7 @@ struct CardDetailsKeyTests {
// twice-written `project` reads once, and it reads where its *winning* occurrence sits: the
// effective view a duplicate collapses to (`FrontmatterDocument.parse`, last-wins), which is
// also the order the file itself takes the moment anything rewrites that key.
#expect(rows.map(\.key) == ["labels", "assignees", "due", "remote", "project"])
#expect(rows.map(\.key) == ["assignees", "due", "remote", "project"])
}
@Test("Reserved enhanced-schema keys are ordinary unknown keys in this version")
@@ -73,9 +73,15 @@ struct CardDetailsKeyTests {
// appear here like any other no special rendering" (05 Details). The day they gain
// meaning they leave this section for a control; until then, hiding them would hide data the
// file plainly has.
for reserved in ["labels", "assignees", "due", "remote"] {
//
// **`labels` is the first to leave, and this is the day** (2026-08-09): the owner claimed it
// for first-party use, it joined `schemaOwned`, and the Labels section is the control the
// sentence above anticipated. Its absence here is therefore the *rule* holding rather than an
// exception to it the three that are still reserved are still here.
for reserved in ["assignees", "due", "remote"] {
#expect(rows.contains { $0.key == reserved }, "\(reserved) belongs in Details")
}
#expect(!rows.contains { $0.key == FrontmatterKeys.labels }, "labels has its own section now")
}
@Test("Every schema-owned key is excluded — including the ones the sidebar shows elsewhere")
@@ -89,7 +95,7 @@ struct CardDetailsKeyTests {
// them would be the same fact stated twice, in a section whose whole premise is "keys the app
// does not own".
#expect(keys.isDisjoint(with: FrontmatterKeys.schemaOwned))
#expect(keys == ["labels", "assignees", "due", "remote", "project"])
#expect(keys == ["assignees", "due", "remote", "project"])
}
@Test("A card with no unknown keys has no section at all")
@@ -133,8 +139,9 @@ struct CardDetailsKeyTests {
let model = try BoardLoader.load(boardRoot: fixture.root).model
let card = try #require(model.lanes.first?.cards.first)
#expect(CardDetails.rows(of: card.document).map(\.key) == ["labels", "assignees", "due", "remote", "project"])
#expect(CardDetails.rows(of: card.document).first?.value == "[a, b, c]")
#expect(CardDetails.rows(of: card.document).map(\.key) == ["assignees", "due", "remote", "project"])
// And the key that left: it is read as a field now, not shown as raw text.
#expect(card.labels.value == ["a", "b", "c"])
}
}
@@ -266,8 +273,9 @@ struct CardDetailsValueTests {
""")
#expect(document.uneditableShape != nil)
#expect(CardDetails.rows(of: document).map(\.key) == ["project", "labels"])
#expect(CardDetails.rows(of: document).map(\.value) == ["lanework", "[a, b]"])
#expect(CardDetails.rows(of: document).map(\.key) == ["project"])
#expect(CardDetails.rows(of: document).map(\.value) == ["lanework"])
#expect(document.labels == .valid(["a", "b"]), "and the schema-owned one still reads")
}
@Test("Nothing a parsed document can hold makes a row throw or vanish")
+9 -3
View File
@@ -158,7 +158,10 @@ struct FixtureRichBoardTests {
// schema-owned keys (schema, title, created, modified, modified-by, background, icon,
// iconColor) are filtered out; only the agent-overlay and reserved keys remain, in the
// order they were written.
#expect(result.model.document.unknownFields.map(\.key) == ["project", "sphere", "labels", "template"])
#expect(result.model.document.unknownFields.map(\.key) == ["project", "sphere", "template"])
// `labels` is schema-owned since 2026-08-09 (`FrontmatterKeys.labels`) filtered out of the
// list above at every level, and preserved in the document exactly as it always was.
#expect(result.model.document.rawValue(for: FrontmatterKeys.labels) != nil)
}
/// The whole-tree round-trip guarantee (01-storage-format.md § Fractal layout: "the app
@@ -324,13 +327,16 @@ struct FixtureUnknownKeyOrderTests {
@Test func unknownKeysPreserveDocumentOrderAtEveryLevel() throws {
let result = try loadFixture("Valid/unknown-key-order.kanban")
let model = result.model
#expect(model.document.unknownFields.map(\.key) == ["project", "sphere", "template", "labels", "custom-note"])
#expect(model.document.unknownFields.map(\.key) == ["project", "sphere", "template", "custom-note"])
let lane = try #require(model.lanes.first)
#expect(lane.document.unknownFields.map(\.key) == ["remote-state", "assignees", "due", "custom"])
let card = try #require(lane.cards.first)
#expect(card.document.unknownFields.map(\.key) == ["labels", "assignees", "due", "remote", "agent-scratch"])
#expect(card.document.unknownFields.map(\.key) == ["assignees", "due", "remote", "agent-scratch"])
// `labels` is schema-owned since 2026-08-09 filtered from the list, read as a field, and
// still sitting exactly where the file put it.
#expect(card.document.labels.value != nil)
}
@Test func everyIndexMdRoundTripsByteIdentically() throws {
+10 -4
View File
@@ -405,14 +405,14 @@ struct FrontmatterAccessTests {
#expect(document.title == .valid("Build the frontmatter engine with byte-perfect round-trip"))
#expect(document.modifiedBy == .valid("claude"))
#expect(document.created.value != nil)
#expect(document.unknownFields.map(\.key) == ["source", "labels"])
#expect(document.unknownFields.map(\.key) == ["source"], "`labels` is schema-owned since 2026-08-09")
#expect(document.body.hasPrefix("Build the Frontmatter component.\n"))
}
@Test func unknownKeysExcludeSchemaOwnedOnes() throws {
let document = try FrontmatterDocument.parse(Fixture.rich)
#expect(document.unknownFields.map(\.key) == [
"project", "sphere", "labels", "template", "notes", "folded", "tagged", "quoted key",
"project", "sphere", "template", "notes", "folded", "tagged", "quoted key",
])
}
@@ -438,8 +438,14 @@ struct FrontmatterAccessTests {
"""
let document = try FrontmatterDocument.parse(text)
// **`labels` left the reserved family on 2026-08-09** when the owner claimed it for
// first-party use (`FrontmatterKeys.labels`) it is schema-owned now, so it is read as a
// field rather than carried as an unknown key. The other four are untouched, and the
// byte-identical round trip below is unaffected either way: classification decides how a key
// is *read*, never how it is written back.
#expect(document.unknownFields.map(\.key)
== ["labels", "assignees", "due", "remote", "remote-state", "template"])
== ["assignees", "due", "remote", "remote-state", "template"])
#expect(document.labels == .valid(["bug", "ui"]))
#expect(document.serialized() == text)
}
@@ -1004,7 +1010,7 @@ struct FrontmatterEditTests {
#expect(reparsed.modifiedBy == .missing)
#expect(reparsed.title == .valid("Renamed"))
#expect(reparsed.unknownFields.map(\.key)
== ["project", "sphere", "labels", "template", "notes", "folded", "tagged", "quoted key"])
== ["project", "sphere", "template", "notes", "folded", "tagged", "quoted key"])
#expect(reparsed.serialized() == document.serialized())
}
}