A reserved key comes to life — the card window's sidebar grows a Labels section, and labels stops being somebody else's
`labels` has been a reserved tracker key since the rewrite: preserved verbatim, never interpreted, drawn only as an anonymous row in the Details section beside `assignees` and `due`. The owner's cards claim it for first-party use, so it joins the schema — read leniently (a list of names, a bare scalar coercing to one, a mapping malformed and preserved), written canonically (a quoted flow list in the order the user arranged, no auto-sort), and removed outright when the last label goes, the way an expanded lane drops `collapsed`. Identity is case-insensitive and display is case-preserving, so a card carries `bug` once however many ways the board spells it, and entries the reading cannot name ride through the write untouched at the tail. The section sits second, above Details — which is the point rather than a layout preference: Details is where keys the app does *not* own are shown, and this key just stopped being one. Rows rather than chips, because the sidebar is twenty-six characters wide. The add field autocompletes against the board's own used-labels universe, derived from every live and trashed card with no store beside the files, and says out loud when Return would mint a word the board has never used. Writes ride a `.relabel` operation of their own, because the commit composer has said "Relabel card 'X'" since long before there was a control to press — and now the undo row says it too. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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* —
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -271,6 +271,25 @@ public struct Card: Identifiable, Sendable, Equatable {
|
||||
/// key, and the bytes stay as written.
|
||||
public let hero: FieldValue<String>
|
||||
|
||||
/// **The card's labels** — the names the card is tagged with (`FrontmatterKeys.labels`, whose doc
|
||||
/// comment carries the key's own story; `CardLabels` has the rules; the reading is
|
||||
/// `FrontmatterDocument.labels`).
|
||||
///
|
||||
/// **Card-level only**, `hero`'s posture and for its kind of reason: a label is a property of a
|
||||
/// piece of work, and neither a lane nor a board is one. The key on a lane or a board stays an
|
||||
/// ordinary unknown one, preserved verbatim and shown in no sidebar the way it always was.
|
||||
///
|
||||
/// The *shape* rather than the answer, so the coerce tier can report a value that had no list
|
||||
/// reading at all (a mapping); `.missing` and `.valid([])` are both "no labels" and render
|
||||
/// identically, which is why nothing in the app branches on the difference — but the two are
|
||||
/// distinct bytes on disk and the field keeps them apart.
|
||||
///
|
||||
/// **The board face does not draw these** (as of the activation, 2026-08-09). Chips on the card
|
||||
/// face are their own design question — the attachments and comments chips set that vocabulary and
|
||||
/// a label list is a different shape of thing — so this rides in the snapshot for the card window's
|
||||
/// sidebar, the context menu's submenu, and the board-wide used-labels universe those two share.
|
||||
public let labels: FieldValue<[String]>
|
||||
|
||||
/// Rank within its lane, ascending = top-to-bottom — the reading, not necessarily the key. See
|
||||
/// `Lane.order`'s doc comment; the same reasoning applies here, and a card is where it matters
|
||||
/// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -593,6 +593,42 @@ public enum FrontmatterKeys {
|
||||
/// the reason it always did — it is Lanework's to interpret, not an unknown key riding along.
|
||||
public static let hero = "hero"
|
||||
|
||||
/// **A card's labels** (01-storage-format.md § Frontmatter's card table — the amendment this key's
|
||||
/// activation owes; `CardLabels`, `FrontmatterDocument.labels`) — a YAML list of names:
|
||||
/// `labels: ["bug", "ui"]`.
|
||||
///
|
||||
/// ### It used to be reserved, and that reversal is the design event
|
||||
///
|
||||
/// Through 2026-08-09 this was one of the **reserved tracker keys** beside `assignees`, `due`,
|
||||
/// `remote` and `remote-state` (01 § Enhanced schema): "the pathfinder shipped them natively; the
|
||||
/// rewrite reserves them instead", preserved verbatim, never interpreted, and shown only as an
|
||||
/// anonymous row in the card window's Details section like any other unknown key. The owner's cards
|
||||
/// ("in card window sidebar add ability to manage card's labels", "add labels submenu to card
|
||||
/// context menu") claim it for **first-party use**, which retires the reservation for this one name
|
||||
/// — `assignees`, `due` and the two `remote` keys are untouched and stay exactly as reserved as they
|
||||
/// were.
|
||||
///
|
||||
/// **It therefore joins `schemaOwned`**, which is the whole of what stops the Details section
|
||||
/// drawing a second, raw copy of a field the sidebar now renders properly (`CardDetails.rows`
|
||||
/// subtracts exactly that set). `hero`'s precedent, and for `hero`'s reason: it is Lanework's to
|
||||
/// interpret, not an unknown key riding along.
|
||||
///
|
||||
/// ### The grammar
|
||||
///
|
||||
/// **Read leniently, written canonically** — the shape of every lenient field here, one collection
|
||||
/// up. A sequence reads as its string entries in file order; a bare scalar coerces to a
|
||||
/// one-element list (`labels: bug`); a mapping has no list reading at all and is `.malformed`.
|
||||
/// Names are **case-preserving for display and case-insensitively unique within a card**, and
|
||||
/// entries the reading cannot make a name of are preserved on the way out but never rendered
|
||||
/// (`CardLabels`).
|
||||
///
|
||||
/// **Written as a flow list, in the user's own order** — no auto-sort: the order is the order of
|
||||
/// addition, which is a thing the user arranged and the app has no business restating
|
||||
/// alphabetically. **Removing the last label removes the key**, the remove-at-default family's rule
|
||||
/// beside `collapsed`'s expand and the None well's `background`: an absent key is the no-labels
|
||||
/// reading, so `labels: []` written by the app would be noise every unlabelled card had to carry.
|
||||
public static let labels = "labels"
|
||||
|
||||
public static let created = "created"
|
||||
public static let modified = "modified"
|
||||
public static let modifiedBy = "modified-by"
|
||||
@@ -620,7 +656,8 @@ public enum FrontmatterKeys {
|
||||
public static let kind = "kind"
|
||||
|
||||
/// The reserved tracker keys — `remote` (board, card) and `remote-state` (lane) — of the enhanced
|
||||
/// schema's future connectors (01-storage-format.md § Enhanced schema).
|
||||
/// schema's future connectors (01-storage-format.md § Enhanced schema). **`labels` left this
|
||||
/// family on 2026-08-09** when the owner claimed it for first-party use; see that key above.
|
||||
///
|
||||
/// **Named here without joining `schemaOwned`**, which is the ruling rather than an oversight: 01
|
||||
/// says the app "treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in
|
||||
@@ -652,7 +689,7 @@ public enum FrontmatterKeys {
|
||||
public static let author = "author"
|
||||
|
||||
public static let schemaOwned: Set<String> = [
|
||||
schema, title, order, width, collapsed, hero, created, modified, modifiedBy, deleted,
|
||||
schema, title, order, width, collapsed, hero, labels, created, modified, modifiedBy, deleted,
|
||||
background, icon, iconColor, kind,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ extension FrontmatterDocument {
|
||||
record(FrontmatterKeys.icon, icon)
|
||||
record(FrontmatterKeys.iconColor, iconColor)
|
||||
record(FrontmatterKeys.hero, hero)
|
||||
record(FrontmatterKeys.labels, labels)
|
||||
record(FrontmatterKeys.kind, kind)
|
||||
return found
|
||||
}
|
||||
@@ -270,6 +271,24 @@ extension FrontmatterDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// **A card's labels** (`FrontmatterKeys.labels`, whose doc comment carries the key's own story —
|
||||
/// including the 2026-08-09 reversal that took it off the reserved-tracker-keys list; the rules
|
||||
/// themselves are `CardLabels`).
|
||||
///
|
||||
/// The lenient family's shape, one collection up. A **sequence** reads as its string entries in
|
||||
/// file order, case-insensitively deduplicated with the first spelling winning. A **bare scalar**
|
||||
/// coerces to a one-element list (`labels: bug`) — the same "any scalar has a sensible reading"
|
||||
/// instinct `title` has, narrowed to strings for `CardLabels.name(of:)`'s stated reason. A
|
||||
/// **mapping**, and a non-string scalar, have no list reading at all: `.malformed`, rendering as no
|
||||
/// labels, bytes untouched, and a coerce-tier trace left behind.
|
||||
///
|
||||
/// A list holding nothing the reader can name — `labels: [{a: 1}]` — is `.valid([])` rather than
|
||||
/// malformed: the author wrote a list, which is the right shape, and it happens to contain no
|
||||
/// names. Those entries survive the next write (`FrontmatterDocument.setLabels`).
|
||||
public var labels: FieldValue<[String]> {
|
||||
read(FrontmatterKeys.labels) { value, _ in CardLabels.reading(of: value) }
|
||||
}
|
||||
|
||||
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
|
||||
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
|
||||
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import Foundation
|
||||
|
||||
/// **The rules of a card's `labels` list** — the pure rulebook behind the read in
|
||||
/// `FrontmatterFields.swift` and the write below (`FrontmatterKeys.labels` has the key's own story,
|
||||
/// including why it stopped being a reserved tracker key on 2026-08-09).
|
||||
///
|
||||
/// Everything here is a static function over values: no document, no filesystem, no store. The two
|
||||
/// surfaces that manage labels — the card window's sidebar section and the card context menu's
|
||||
/// submenu — both reduce to `toggling`/`adding`/`removing` over a `[String]`, so neither of them can
|
||||
/// invent a second answer to "does this card already have that label?".
|
||||
public enum CardLabels {
|
||||
|
||||
// MARK: - One name
|
||||
|
||||
/// A name as the app stores it: **trimmed of surrounding whitespace, and never empty**.
|
||||
///
|
||||
/// `nil` is "there is no name here" — an empty string, or a string that is nothing but spaces.
|
||||
/// That is not a label anybody meant: it would render as a blank chip, match nothing in the
|
||||
/// used-labels universe, and be unremovable by clicking the thing it does not draw. The empty
|
||||
/// rename's own rule (`title`: a name that trims to nothing writes no key at all) one collection
|
||||
/// down.
|
||||
///
|
||||
/// **Interior whitespace is left exactly as typed.** `"needs review"` is a perfectly good label
|
||||
/// and the app has no business folding it to one word or to a hyphen — the schema stores names,
|
||||
/// not identifiers.
|
||||
public static func normalized(_ text: String) -> String? {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
/// The comparison key: a name case-folded, so `Bug` and `bug` are one label.
|
||||
///
|
||||
/// **Case-preserving display, case-insensitive uniqueness** (the owner's ruling). The fold is
|
||||
/// locale-independent on purpose — `ItemID.canonicalValue`'s posture, for its reason: this decides
|
||||
/// identity, and an identity that changed with the user's region would make the same two files
|
||||
/// mean different things on two machines.
|
||||
public static func canonical(_ name: String) -> String {
|
||||
name.lowercased()
|
||||
}
|
||||
|
||||
/// Whether a case-insensitively equal name is already in the list.
|
||||
public static func contains(_ name: String, in list: [String]) -> Bool {
|
||||
guard let name = normalized(name) else { return false }
|
||||
let key = canonical(name)
|
||||
return list.contains { canonical($0) == key }
|
||||
}
|
||||
|
||||
// MARK: - One list
|
||||
|
||||
/// The list with case-insensitive duplicates collapsed, **first spelling wins**, order otherwise
|
||||
/// preserved.
|
||||
///
|
||||
/// The reading applies this, so a hand-written `labels: [Bug, bug]` shows one chip rather than two
|
||||
/// that cannot be told apart — and the next app write to the key lands the collapsed form, which is
|
||||
/// the on-touch heal every other lenient field already performs by rewriting what it read.
|
||||
///
|
||||
/// *First* spelling rather than last, unlike the duplicate-key rule one level up
|
||||
/// (`FrontmatterDocument.parse`'s last-wins): a key written twice is a file with two answers and the
|
||||
/// later one is the author's correction, while a list is one value whose members are in an order the
|
||||
/// author chose — so the first occurrence is the one that holds its place.
|
||||
public static func deduplicated(_ list: [String]) -> [String] {
|
||||
var seen: Set<String> = []
|
||||
var result: [String] = []
|
||||
for entry in list {
|
||||
guard let name = normalized(entry), seen.insert(canonical(name)).inserted else { continue }
|
||||
result.append(name)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// `name` appended to the list, or the list unchanged when a case-insensitive twin is already
|
||||
/// there.
|
||||
///
|
||||
/// **Appended, never sorted in** — the order of addition is the user's arrangement (the key's own
|
||||
/// no-auto-sort rule), and **the existing spelling wins** a case clash: adding `Bug` to a card that
|
||||
/// already carries `bug` is a no-op rather than a silent respelling, because the user is adding a
|
||||
/// label they already have and nothing about that gesture asks to rename it.
|
||||
public static func adding(_ name: String, to list: [String]) -> [String] {
|
||||
guard let name = normalized(name), !contains(name, in: list) else { return deduplicated(list) }
|
||||
return deduplicated(list) + [name]
|
||||
}
|
||||
|
||||
/// The list without any case-insensitive match for `name`.
|
||||
public static func removing(_ name: String, from list: [String]) -> [String] {
|
||||
guard let name = normalized(name) else { return deduplicated(list) }
|
||||
let key = canonical(name)
|
||||
return deduplicated(list).filter { canonical($0) != key }
|
||||
}
|
||||
|
||||
/// `removing` when the label is there, `adding` when it is not — the context menu's row and the
|
||||
/// dialog's checkbox both press exactly this.
|
||||
public static func toggling(_ name: String, in list: [String]) -> [String] {
|
||||
contains(name, in: list) ? removing(name, from: list) : adding(name, to: list)
|
||||
}
|
||||
|
||||
// MARK: - Reading a parsed value
|
||||
|
||||
/// One sequence entry's reading as a label, or `nil` when it has none.
|
||||
///
|
||||
/// **Only a YAML string is a name**, which is a deliberate narrowing of the scalar-coercion family
|
||||
/// every other lenient field belongs to (`title: 2048` reads as `"2048"`). Two reasons, and the
|
||||
/// owner's ruling says the same thing in one sentence ("non-string entries preserved untouched but
|
||||
/// not rendered"):
|
||||
///
|
||||
/// - A single-valued field has one value, so coercing it is the only way to have a reading at all.
|
||||
/// A list has members, and a member that is not a name can simply be **kept** — which is a better
|
||||
/// outcome than guessing, because nothing is lost either way.
|
||||
/// - `labels: [2026, ui]` is far more likely to be somebody's structured entry than a card tagged
|
||||
/// with a number, and the app inventing the string `"2026"` for it would make that entry
|
||||
/// unremovable-by-intent: the chip would say one thing and the file another.
|
||||
///
|
||||
/// **The reading a `setLabels(_:)` will leave behind** — `nil` when the write removes the key.
|
||||
///
|
||||
/// It exists because an undo step has to declare the after-value its write produced
|
||||
/// (`ExpectedField.labels`), and for this key that is not simply the list handed in: an empty list
|
||||
/// removes the key, whose reading is an *absence* rather than an empty list — unless entries the
|
||||
/// reading cannot name are holding the key open, in which case the reading really is `[]`. Two
|
||||
/// different bytes, two different expectations, and a step that declared the wrong one would skip
|
||||
/// itself the first time somebody took the last label off a card.
|
||||
///
|
||||
/// A pure function rather than a re-read of the written document, so the prediction and the write
|
||||
/// are the same rule stated once — `FrontmatterDocument.setLabels` branches on exactly these two
|
||||
/// facts.
|
||||
public static func readingAfterWrite(_ names: [String], preservingEntries: Bool) -> [String]? {
|
||||
let names = deduplicated(names)
|
||||
guard !names.isEmpty || preservingEntries else { return nil }
|
||||
return names
|
||||
}
|
||||
|
||||
/// Also `nil` for a null, a nested sequence, a mapping, and anything that trims to nothing.
|
||||
static func name(of entry: YAMLValue) -> String? {
|
||||
guard case let .string(text) = entry else { return nil }
|
||||
return normalized(text)
|
||||
}
|
||||
|
||||
/// A parsed `labels` value's reading, or `nil` when it has **none at all** — the transform behind
|
||||
/// `FrontmatterDocument.labels`'s `.valid`/`.malformed` split.
|
||||
///
|
||||
/// - a **sequence** reads as its string entries, deduplicated, in file order (`[]` for a list with
|
||||
/// no names in it, which is a perfectly good "no labels" and not a failure);
|
||||
/// - a **bare scalar** coerces to a one-element list — `labels: bug` is a card with one label, the
|
||||
/// single-value shape an author or an agent reaches for first, and refusing it would make the
|
||||
/// most forgivable spelling the one shape that renders nothing;
|
||||
/// - a **mapping** has no list reading and is the one shape that lands `.malformed`, rendering as
|
||||
/// no labels and leaving the coerce tier's trace.
|
||||
///
|
||||
/// A non-string *scalar* at the top level (`labels: 3`) is deliberately **not** coerced, for
|
||||
/// `name(of:)`'s reason: it reads as no list, so it is malformed and preserved rather than becoming
|
||||
/// a card labelled "3".
|
||||
static func reading(of value: YAMLValue) -> [String]? {
|
||||
switch value {
|
||||
case let .sequence(entries):
|
||||
return deduplicated(entries.compactMap(name(of:)))
|
||||
case let .string(text):
|
||||
return normalized(text).map { [$0] } ?? []
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The write side
|
||||
|
||||
/// **The write side of `labels`** — the read side is `FrontmatterDocument.labels`
|
||||
/// (FrontmatterFields.swift) and the rules are `CardLabels` above. `BackgroundField.swift`'s shape and
|
||||
/// its reasoning, one collection over.
|
||||
extension FrontmatterDocument {
|
||||
|
||||
/// The entries of the current `labels` value the reading could make no name of — what
|
||||
/// `setLabels(_:)` carries through untouched.
|
||||
///
|
||||
/// Empty for every shape that is not a sequence: a bare scalar has one entry and the reading
|
||||
/// already made a name (or nothing) of it, and a mapping is the malformed shape a forward write
|
||||
/// **replaces** outright — the same malformed-value-cleared posture `icon` has ("choosing any well
|
||||
/// replaces it", 03-board-ui.md § Styling ▸ Controls), and the right one here, since the reader
|
||||
/// could not make a list of it either.
|
||||
var preservedLabelEntries: [YAMLValue] {
|
||||
guard case let .sequence(entries)? = value(for: FrontmatterKeys.labels) else { return [] }
|
||||
return entries.filter { CardLabels.name(of: $0) == nil }
|
||||
}
|
||||
|
||||
/// Writes the card's labels — the **one** mutation every labels surface goes through.
|
||||
///
|
||||
/// ### Canonical form
|
||||
///
|
||||
/// A single-line **flow sequence** of double-quoted names, in the order given:
|
||||
/// `labels: ["bug", "needs review"]`. Flow because the span editor rewrites a key's value with one
|
||||
/// line's worth of text and `FrontmatterValue` has no sequence case to rewrite it with — the same
|
||||
/// `.raw` escape and the same narrow yield of the verbatim promise `setStyleValue` makes for
|
||||
/// `background`'s mapping, documented at length in `BackgroundField.swift`. Double-quoted for that
|
||||
/// file's reason exactly: `,` and `]` end a plain scalar in flow context, so a label with a comma
|
||||
/// in it would otherwise break the collection it is written into.
|
||||
///
|
||||
/// **No sort.** The caller's order is the file's order, because the caller's order is the user's:
|
||||
/// labels accumulate in the sequence they were applied, and an alphabetical rewrite on every touch
|
||||
/// would be the app rearranging something the user arranged.
|
||||
///
|
||||
/// ### The empty case removes the key
|
||||
///
|
||||
/// Removing the last label removes `labels` outright rather than writing `labels: []` — the
|
||||
/// remove-at-default family (`collapsed`'s expand, the None well's `background`, a one-unit
|
||||
/// `width`): an absent key is exactly the no-labels reading, and an empty list on every unlabelled
|
||||
/// card would be noise the format does not need. The one exception is a list that still holds
|
||||
/// preserved non-name entries, which keeps the key so those entries survive.
|
||||
///
|
||||
/// ### What survives, and what does not
|
||||
///
|
||||
/// Entries with no name reading are carried through **at the tail**, after the names. Their
|
||||
/// *values* survive; their spelling does not — a block sequence collapses to flow form, quoting is
|
||||
/// normalized, and an entry's own inline comment is lost with the line it sat on. Tail rather than
|
||||
/// in place because a name list has no positions worth preserving *for the names* (the user just
|
||||
/// rearranged them by definition), and interleaving preserved entries back among them would make
|
||||
/// the user's order depend on somebody else's structured data.
|
||||
public mutating func setLabels(_ names: [String]) {
|
||||
let names = CardLabels.deduplicated(names)
|
||||
let preserved = preservedLabelEntries
|
||||
guard !names.isEmpty || !preserved.isEmpty else {
|
||||
remove(FrontmatterKeys.labels)
|
||||
return
|
||||
}
|
||||
let entries = names.map { FrontmatterValue.emitQuoted($0) } + preserved.map(Self.flowText)
|
||||
set(FrontmatterKeys.labels, to: .raw("[" + entries.joined(separator: ", ") + "]"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user