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
+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")