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

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

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

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

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

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 12:06:01 -04:00
parent fd31ed24f4
commit da37ed61bf
24 changed files with 1928 additions and 29 deletions
+156
View File
@@ -256,6 +256,33 @@ public final class BoardStore: HealHost {
/// path see the type's doc comment for why. A failed reload leaves it exactly as it was.
public private(set) var snapshot: BoardModel
/// **The board's used-labels universe, cached** (`LabelIndex`; `FrontmatterKeys.labels`, activated
/// 2026-08-09) the one derived value in this store that exists purely so a *view body* need not
/// derive it.
///
/// ### Why it is stored rather than computed
///
/// The context menu's `labels` submenu has to name twelve labels, and `.contextMenu`'s content
/// closure **is not lazy**: SwiftUI evaluates it on every ordinary body pass of every card face,
/// not only when a menu opens (`CardFaceView.copyLinkEnabled`'s doc comment, and the O(board)
/// regression `BoardRenderPerformanceTests.selectionStillRepaints` caught the hard way). A
/// computed property here would put a whole-board walk inside that closure, once per face, on
/// every pass the exact shape of the cost this file's render work exists to shed.
///
/// So the walk happens **once per applied snapshot**, here, and a face reads a value that is
/// already built. The remaining per-face work is `LabelRanking.ranked`, whose size is the board's
/// *label vocabulary* tens of entries, bounded by how many distinct labels exist and not by how
/// many cards there are.
///
/// ### The assignment is equality-gated, and that is load-bearing
///
/// `@Observable` notifies on **every** set, equal or not (`BoardZoomStore.setLevel`'s own note), so
/// an ungated re-derivation on each reload would invalidate every card face that reads this on
/// every reload reintroducing board-wide invalidation through the back door. Gated, this
/// property changes only when the board's labels genuinely change, which is a user-visible content
/// change that was going to re-render those faces anyway.
public private(set) var labelIndex: LabelIndex
/// How many snapshots this store has **applied**, ever a counter, not a version.
///
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
@@ -782,6 +809,7 @@ public final class BoardStore: HealHost {
self.rootURL = rootURL
self.rootKey = BoardRootKey(rootURL)
self.snapshot = result.model
self.labelIndex = LabelIndex.derive(from: result.model)
self.loadWarnings = result.warnings
self.defects = result.defects
// The opening walk was cold by definition; what it parsed is the first reload's memo.
@@ -1020,6 +1048,13 @@ public final class BoardStore: HealHost {
)) {
snapshot = result.model
snapshotGeneration += 1
// **The used-labels universe, re-derived with the tree it describes** inside the
// same transaction as the snapshot for the reason the re-grounding below is: a view
// woken by the snapshot's change must never observe a universe still describing the
// old one. Equality-gated, which is what keeps a reload that moved a card from
// invalidating every face that reads it (see the property's own note).
let labels = LabelIndex.derive(from: result.model)
if labels != labelIndex { labelIndex = labels }
// The one place transient state is re-grounded. It goes last, after `snapshot` is
// the new one, because a view woken by the snapshot's change must never observe a
// selection still pointing at the old tree.
@@ -4275,6 +4310,127 @@ public final class BoardStore: HealHost {
return true
}
// MARK: - Labels
/// **Rewrites a card's `labels` list** the one write behind both label surfaces, the card
/// window's sidebar section and the board card menu's submenu (`FrontmatterKeys.labels`, activated
/// 2026-08-09; `CardLabels` has the rules, `FrontmatterDocument.setLabels` the canonical form).
///
/// ### It is `setHero`'s shape, one key over
///
/// One key on one card's `index.md`, through `updateIndex` inside one `performWrite` bracket,
/// registering one step. Everything that makes that shape right there makes it right here: the
/// churn rounds back as one app-mediated reload, the read-only lock refuses it like every other
/// mutation, its failures reach the banner strip, and the step's before-value is the list the
/// snapshot last read.
///
/// **It takes `.relabel`, not `.style`.** A restyle picks an appearance; this edits what the card
/// is about, and the vocabulary has had a separate word for it since before there was a control
/// (`WriteOperation.relabel`; 06-history-undo.md's "Relabel card 'X'").
///
/// ### The list is the unit
///
/// The parameter is the **whole** new list rather than a label plus a direction, and every caller
/// composes it through `CardLabels` (`toggling`, `adding`, `removing`) from the list the snapshot
/// already shows. That keeps the "case-insensitive uniqueness, first spelling wins, order of
/// addition preserved" rules in exactly one place, and it makes the undo step's after-value
/// trivially honest: the step declares the list it wrote (`ExpectedField.labels`).
///
/// **A no-op costs nothing.** A call whose normalized list equals what the card already reads
/// writes nothing, reloads nothing and registers no undo step `applyStyle`'s own
/// redundant-dimension rule, which matters more here than there because a menu row toggled twice
/// in a row is a thing users do.
///
/// ### The guards are `setHero`'s
///
/// **The board container and only it** a trashed card's labels are not editable from a window
/// that is dismissing itself and a lane id is refused because labels are a card field
/// (`Card.labels`).
///
/// - Parameter names: the card's whole new label list, in the order it should be written.
/// - Parameter window: the card window whose stack the step belongs on, when the gesture came from
/// one (13-native-undo.md Rules two levels). A window-issued gesture also anchors by **card
/// identity** rather than by path, `setHero`'s rule verbatim, so a lane move under an open window
/// never stales it.
/// - Returns: whether bytes reached disk.
@discardableResult
public func setLabels(_ names: [String], onCard cardID: ItemID, on window: CardWindowUndo? = nil) -> Bool {
guard let item = Self.boardItem(cardID, in: snapshot),
let card = item.cardID,
let subject = Self.card(cardID, in: snapshot)
else { return false }
let prior = subject.labels
let next = CardLabels.deduplicated(names)
// A malformed prior matches no list, so a first write onto `labels: {a: 1}` always lands
// which is right: the value had no reading, and the user is replacing it with one.
guard prior.value != next else { return false }
// **Whether the key is held open by entries the reading cannot name** read off the
// snapshot's own document, exactly as `prior` is, so the two facts the expectations are built
// from come from one reading of one file. It decides whether an emptied list *removes* the key
// or leaves `[]` behind, which is the difference between two after-values a step could declare
// (`CardLabels.readingAfterWrite`).
let preserving = !subject.document.preservedLabelEntries.isEmpty
let landedReading = CardLabels.readingAfterWrite(next, preservingEntries: preserving)
let restoredReading = CardLabels.readingAfterWrite(prior.value ?? [], preservingEntries: preserving)
let folder = rootURL
.appendingPathComponent(item.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
let anchor: HistoryAnchor = window != nil ? .card(cardID) : .path(folder)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// `.relabel(title: nil)`: `updateIndex` enriches it off the document it reads, so a failure
// names the card by the title it still has.
try BoardWriter.updateIndex(inItemFolder: folder, operation: .relabel(title: nil)) { document in
document.setLabels(next)
}
}
guard landed != nil else { return false }
registerStep(
HistoryPhrase.name(.relabel, kind: .card),
subject: item.title,
on: window,
undoExpects: [.present(anchor, .labels(landedReading))],
redoExpects: [.present(anchor, .labels(restoredReading))]
) { store in
try BoardWriter.updateIndex(
inItemFolder: try store.requiredFolder(for: anchor, .relabel(title: nil)),
operation: .relabel(title: nil)
) { document in
// **A malformed prior inverses to the removed key**, `restoreCollapsed`'s rule and the
// one the redo expectation above is written against: this app writes `labels` in
// exactly one shape, and reproducing somebody's `labels: {a: 1}` would be the undo
// inventing a value. The reading is restored exactly either way both read as no
// labels which is what an inverse owes the user.
document.setLabels(prior.value ?? [])
}
} redo: { store in
try BoardWriter.updateIndex(
inItemFolder: try store.requiredFolder(for: anchor, .relabel(title: nil)),
operation: .relabel(title: nil)
) { document in
document.setLabels(next)
}
}
return true
}
/// The labels a card currently carries, as the last reload read them `[]` for a card with no
/// key, an empty list, or a value with no list reading at all.
///
/// The one read every label surface starts from, so the sidebar's chips, the menu's checkmarks and
/// the write path's "what am I toggling against" can never disagree. It looks in **both**
/// containers (`cardBodyTarget`'s walk, through `Self.card`/`boardItem`'s live-only lookup plus the
/// trash), because the card window stays open over a card that was trashed under it and its
/// sidebar must keep showing the truth even though `setLabels` will refuse to write there.
public func labels(ofCard cardID: ItemID) -> [String] {
if let card = Self.card(cardID, in: snapshot) { return card.labels.value ?? [] }
return snapshot.trash.first { $0.id == cardID }?.labels.value ?? []
}
/// One live board-side card off a snapshot, by identity `boardItem`'s sibling for a caller that
/// needs the card's *fields* rather than its position.
nonisolated static func card(_ id: ItemID, in snapshot: BoardModel) -> Card? {