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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user