The card menu learns to tag — a labels submenu of twelve checkmarks, and Copy Link moves in beside its mirror

Right-clicking a card now offers Labels: up to twelve rows ranked by how many cards on the
board carry each one, tie-broken by what this user reached for last, each a checkmark that
says whether *this* card already has it. More… opens the whole inventory beside the card, in
lookup order, with a field that mints a name the board has never used.

The rows deliberately do not widen to the selection the way Copy, Cut and Send to Trash do. A
checkmark is a claim about one card, and three cards where two carry `bug` have no honest
checked state — the rows that widen are the ones that say what they will do rather than what
is already true. It is also what keeps the menu cheap: a context menu's contents are rebuilt
on every ordinary pass of every card face, so reading the selection there would resubscribe
the whole board. The board-sized half of the ranking is cached on the store and gated on
equality; what runs per face is bounded by how many distinct labels exist, not by how many
cards do.

Copy Link leaves the first group for a new Copy Special submenu, mirroring Paste Special —
the placement the owner's latest layout asks for, and the one the row's own note has been
waiting on since it shipped as a same-day deviation. It keeps its VoiceOver action even so: it
is an action that changed doors, not a door.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 12:06:01 -04:00
parent da37ed61bf
commit 0609104b2d
6 changed files with 539 additions and 10 deletions
@@ -406,6 +406,73 @@ struct BoardRenderPerformanceTests {
#expect(edit.cards <= 8, "a one-card edit re-rendered \(edit.cards) of \(wide * 15) card faces")
}
/// **The `labels` submenu's load-bearing render claim** (2026-08-09, Pipeline card 28c79ffe;
/// `CardFaceView.labelsMenu`).
///
/// The submenu names up to twelve labels ranked across the **whole board**, and `.contextMenu`'s
/// content closure is not lazy SwiftUI builds every row of every face's menu on every ordinary
/// body pass. Deriving that ranking per face would be a board walk per card per pass, the exact
/// shape this suite exists to rule out. So the walk is cached on the store (`BoardStore.labelIndex`,
/// re-derived once per applied snapshot) and its assignment is **equality-gated**.
///
/// That gate is what this test is about. Ungated, every reload would assign the property, every
/// face that reads it would invalidate *directly* past `CardFaceView.==` entirely, the way the
/// old `store.selection` read did and a one-card edit would cost the whole board again. This
/// asserts the number `aOneCardEditIsNotAWholeBoardRebuild` asserts, on a board where every card
/// carries labels, so a missing gate shows up here as \(laneCount * cardsPerLane) instead of a
/// handful.
///
/// A label change that genuinely moves the board's vocabulary is a different matter and is not
/// pinned here: the universe really did change, every menu really is different, and a full repaint
/// on a deliberate one-per-gesture event is the class `zoomRepaintsTheCardFaces` already blesses.
@Test("An edit to a labelled card costs a handful of faces — the label universe's gate holds")
func labelsDoNotMakeEveryEditABoardRebuild() async throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
// Every card labelled, so the universe is real and every face's menu has twelve rows to build.
for lane in 0..<laneCount {
for card in 0..<cardsPerLane {
try fixture.item("\(laneName(lane))/\(cardName(lane, card))", """
---
schema: 1
kind: card
title: Card \(lane)-\(card)
order: \((card + 1) * 1024)
labels: ["bug", "ui", "lane\(lane)"]
---
Body text for card \(lane)-\(card).
""")
}
}
let board = try host(fixture)
let total = laneCount * cardsPerLane
#expect(board.store.labelIndex.names.count == laneCount + 2, "the universe is real")
// One card's *body* edited its labels untouched, so the universe does not move.
try fixture.item("\(laneName(3))/\(cardName(3, 17))", """
---
schema: 1
kind: card
title: Card 3-17
order: \(18 * 1024)
labels: ["bug", "ui", "lane3"]
---
Body text for card 3-17, now edited.
""")
BoardRenderMetrics.reset()
await board.reload()
let edit = Cost()
print("── reload, ONE labelled card edited — \(edit.summary)")
#expect(board.store.snapshotGeneration > 0, "the edit never landed")
#expect(edit.cards <= 8,
"a one-card edit on a labelled board re-rendered \(edit.cards) of \(total) card faces")
}
@Test("Selecting a card still repaints it — the gate never went too far")
func selectionStillRepaints() throws {
let fixture = try makeFixture()
+132
View File
@@ -585,6 +585,138 @@ struct LabelStoreWriteTests {
}
}
// MARK: - The More dialog's session
@MainActor
@Suite("Labels ▸ the More… dialog's lifecycle")
struct LabelEditorSessionTests {
@Test("Begin aims it at one card; a second Begin re-aims rather than stacking")
func beginAndDiscard() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.transient.labelEditor == nil)
store.transient.beginLabelEditor(forCard: clipboardCard1)
#expect(store.transient.labelEditor?.cardID == clipboardCard1)
store.transient.beginLabelEditor(forCard: clipboardCard2)
#expect(store.transient.labelEditor?.cardID == clipboardCard2, "one dialog, re-aimed")
store.transient.discardLabelEditor()
#expect(store.transient.labelEditor == nil)
}
@Test("The reload rule takes it down when its card is no longer a live board card")
func theReloadRule() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
#expect(LabelEditorSession(cardID: clipboardCard1).resolved(against: snapshot) != nil)
// A trashed card resolves nowhere on the board side, which is exactly right: the write the
// dialog offers (`BoardStore.setLabels`) refuses a trashed card.
#expect(LabelEditorSession(cardID: clipboardCard3).resolved(against: snapshot) == nil)
#expect(LabelEditorSession(cardID: ItemID(rawValue: Ident.indexless))
.resolved(against: snapshot) == nil)
}
@Test("A card that leaves the board while the dialog is open dismisses it")
func aVanishedCardDismisses() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginLabelEditor(forCard: clipboardCard1)
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.transient.labelEditor == nil)
// And the style session's own lifecycle is untouched by the new neighbour.
#expect(store.transient.styleEditor == nil)
}
@Test("The two sessions are independent — neither `begin` clears the other")
func theTwoSessionsAreIndependent() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([clipboardCard1]))
store.transient.beginLabelEditor(forCard: clipboardCard1)
#expect(store.transient.styleEditor != nil)
#expect(store.transient.labelEditor != nil)
store.transient.discardLabelEditor()
#expect(store.transient.styleEditor != nil, "discarding one leaves the other")
}
}
// MARK: - The menu's funnel
@MainActor
@Suite("Labels ▸ the command funnel")
struct LabelCommandTests {
private func settle(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
}
@Test("Toggle adds what is missing and removes what is there")
func toggling() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!)
#expect(LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents))
await settle(store)
#expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c", "spike"])
#expect(LabelCommand.toggle("SPIKE", onCard: clipboardCard1, in: store, recents: recents))
await settle(store)
#expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c"], "case-insensitive, like everywhere")
}
@Test("An apply records the MRU; a removal deliberately does not")
func recording() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!)
LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents)
#expect(recents.labels == ["spike"])
await settle(store)
LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents)
#expect(recents.labels == ["spike"], "taking a label off is not reaching for it")
// And an add onto a card that already carries it still records the user reached for it.
await settle(store)
LabelCommand.add("a", onCard: clipboardCard1, in: store, recents: recents)
#expect(recents.labels == ["a", "spike"])
}
@Test("A typed name lands in the board's own spelling")
func spellingResolution() async throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!)
// The board already spells it `a`; typing `A` must not mint a second variant.
#expect(LabelCommand.resolved("A", in: store) == "a")
#expect(LabelCommand.resolved("brand-new", in: store) == "brand-new", "a new name is the typist's")
#expect(LabelCommand.resolved(" ", in: store) == nil)
LabelCommand.add("A", onCard: clipboardCard2, in: store, recents: recents)
await settle(store)
#expect(store.labelIndex.names.allSatisfy { $0 != "A" }, "no second spelling entered the universe")
#expect(recents.labels == ["a"])
}
}
// MARK: - The sidebar's picker
@Suite("Labels ▸ the sidebar's add field")