From da37ed61bf6018d01d7bb30f50630c5f8b26366b Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 9 Aug 2026 11:48:15 -0400 Subject: [PATCH] =?UTF-8?q?A=20reserved=20key=20comes=20to=20life=20?= =?UTF-8?q?=E2=80=94=20the=20card=20window's=20sidebar=20grows=20a=20Label?= =?UTF-8?q?s=20section,=20and=20`labels`=20stops=20being=20somebody=20else?= =?UTF-8?q?'s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- Kanban/App/AppModel.swift | 15 + Kanban/App/CardWindowHost.swift | 3 + Kanban/Changes/ChangeNarrator.swift | 24 +- Kanban/History/HistoryPhrase.swift | 14 + Kanban/History/HistoryStaleness.swift | 17 + Kanban/LiveStore/BannerCenter.swift | 9 + Kanban/LiveStore/BoardStore.swift | 156 ++++++ Kanban/LiveStore/LabelIndex.swift | 205 ++++++++ Kanban/LiveStore/LabelRecents.swift | 77 +++ Kanban/Storage/BackgroundField.swift | 7 +- Kanban/Storage/BoardLoader.swift | 2 + Kanban/Storage/BoardModel.swift | 19 + Kanban/Storage/BoardWriter.swift | 22 +- Kanban/Storage/FrontmatterDocument.swift | 41 +- Kanban/Storage/FrontmatterFields.swift | 19 + Kanban/Storage/LabelsField.swift | 224 ++++++++ Kanban/UI/Card/CardLabelsSection.swift | 294 +++++++++++ Kanban/UI/Card/CardWindowView.swift | 31 +- Kanban/UI/LabelCommand.swift | 89 ++++ KanbanTests/BoardWriterTests.swift | 12 +- KanbanTests/CardLabelsTests.swift | 629 +++++++++++++++++++++++ KanbanTests/CardSidebarTests.swift | 22 +- KanbanTests/FixtureBoardTests.swift | 12 +- KanbanTests/FrontmatterTests.swift | 14 +- 24 files changed, 1928 insertions(+), 29 deletions(-) create mode 100644 Kanban/LiveStore/LabelIndex.swift create mode 100644 Kanban/LiveStore/LabelRecents.swift create mode 100644 Kanban/Storage/LabelsField.swift create mode 100644 Kanban/UI/Card/CardLabelsSection.swift create mode 100644 Kanban/UI/LabelCommand.swift create mode 100644 KanbanTests/CardLabelsTests.swift diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index fbf7897..a16fea8 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -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) diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 2d6c650..e294cc2 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -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, diff --git a/Kanban/Changes/ChangeNarrator.swift b/Kanban/Changes/ChangeNarrator.swift index 8ca1619..8f03b28 100644 --- a/Kanban/Changes/ChangeNarrator.swift +++ b/Kanban/Changes/ChangeNarrator.swift @@ -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 { diff --git a/Kanban/History/HistoryPhrase.swift b/Kanban/History/HistoryPhrase.swift index a9531af..b7b0422 100644 --- a/Kanban/History/HistoryPhrase.swift +++ b/Kanban/History/HistoryPhrase.swift @@ -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). /// diff --git a/Kanban/History/HistoryStaleness.swift b/Kanban/History/HistoryStaleness.swift index 020dffa..b91e8c8 100644 --- a/Kanban/History/HistoryStaleness.swift +++ b/Kanban/History/HistoryStaleness.swift @@ -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 diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 378ca51..b97c43f 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -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* — diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 3db3450..da2f3c1 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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? { diff --git a/Kanban/LiveStore/LabelIndex.swift b/Kanban/LiveStore/LabelIndex.swift new file mode 100644 index 0000000..ed43a22 --- /dev/null +++ b/Kanban/LiveStore/LabelIndex.swift @@ -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) + } +} diff --git a/Kanban/LiveStore/LabelRecents.swift b/Kanban/LiveStore/LabelRecents.swift new file mode 100644 index 0000000..34825db --- /dev/null +++ b/Kanban/LiveStore/LabelRecents.swift @@ -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)) + } +} diff --git a/Kanban/Storage/BackgroundField.swift b/Kanban/Storage/BackgroundField.swift index a37d82c..7ce0e47 100644 --- a/Kanban/Storage/BackgroundField.swift +++ b/Kanban/Storage/BackgroundField.swift @@ -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 diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 06980a8..b33b79c 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -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, diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index 7ecf6e1..f813834 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -271,6 +271,25 @@ public struct Card: Identifiable, Sendable, Equatable { /// key, and the bytes stay as written. public let hero: FieldValue + /// **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 diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 01832ef..62d5830 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -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) diff --git a/Kanban/Storage/FrontmatterDocument.swift b/Kanban/Storage/FrontmatterDocument.swift index 4183702..0605b71 100644 --- a/Kanban/Storage/FrontmatterDocument.swift +++ b/Kanban/Storage/FrontmatterDocument.swift @@ -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 = [ - 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, ] } diff --git a/Kanban/Storage/FrontmatterFields.swift b/Kanban/Storage/FrontmatterFields.swift index a1d17dc..e591f71 100644 --- a/Kanban/Storage/FrontmatterFields.swift +++ b/Kanban/Storage/FrontmatterFields.swift @@ -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 { read(FrontmatterKeys.created) { value, _ in Self.date(value) } } public var modified: FieldValue { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } } public var deleted: FieldValue { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } } diff --git a/Kanban/Storage/LabelsField.swift b/Kanban/Storage/LabelsField.swift new file mode 100644 index 0000000..07c826c --- /dev/null +++ b/Kanban/Storage/LabelsField.swift @@ -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 = [] + 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: ", ") + "]")) + } +} diff --git a/Kanban/UI/Card/CardLabelsSection.swift b/Kanban/UI/Card/CardLabelsSection.swift new file mode 100644 index 0000000..4f8c4d9 --- /dev/null +++ b/Kanban/UI/Card/CardLabelsSection.swift @@ -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 + } +} diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index ec7daba..c14d2e8 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -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)) diff --git a/Kanban/UI/LabelCommand.swift b/Kanban/UI/LabelCommand.swift new file mode 100644 index 0000000..84dc4e7 --- /dev/null +++ b/Kanban/UI/LabelCommand.swift @@ -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 + } +} diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index ea5548c..993d8b7 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -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. diff --git a/KanbanTests/CardLabelsTests.swift b/KanbanTests/CardLabelsTests.swift new file mode 100644 index 0000000..7655fb0 --- /dev/null +++ b/KanbanTests/CardLabelsTests.swift @@ -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)) + } +} diff --git a/KanbanTests/CardSidebarTests.swift b/KanbanTests/CardSidebarTests.swift index 8041cd6..9e9f9ce 100644 --- a/KanbanTests/CardSidebarTests.swift +++ b/KanbanTests/CardSidebarTests.swift @@ -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") diff --git a/KanbanTests/FixtureBoardTests.swift b/KanbanTests/FixtureBoardTests.swift index 0415125..1253b55 100644 --- a/KanbanTests/FixtureBoardTests.swift +++ b/KanbanTests/FixtureBoardTests.swift @@ -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 { diff --git a/KanbanTests/FrontmatterTests.swift b/KanbanTests/FrontmatterTests.swift index 8b285f4..b6dbd0e 100644 --- a/KanbanTests/FrontmatterTests.swift +++ b/KanbanTests/FrontmatterTests.swift @@ -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()) } }