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()