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
+135 -10
View File
@@ -373,6 +373,14 @@ struct CardFaceView: View, Equatable {
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
// Labels More the second session-backed popover this face can host, mounted
// exactly like the first (2026-08-09, card 28c79ffe). The two can never be open at
// once: each is opened by a row of the same menu, and opening either replaces nothing
// of the other's but a user cannot press two menu rows in one gesture, and each
// session's own `begin` replaces only its own kind.
.popover(isPresented: labelEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
LabelPickerPopover(store: store, recents: appModel.labelRecents)
}
case let .trash(confirmations):
face
.contextMenu { trashMenu(confirmations: confirmations) }
@@ -751,11 +759,14 @@ struct CardFaceView: View, Equatable {
/// **The card context menu, redesigned** (2026-08-09 "redesign context menu for cards", card
/// fe66c461): four groups, a divider between each, the owner's shape verbatim
///
/// 1. Open, Copy Link, Rename, Style (Symbol, Color)
/// 2. Copy, Cut, Paste, Paste Special (Paste Image into Card, more tbd)
/// 1. Open, Rename, Style (Symbol, Color), Labels (up to 12 ranked toggles, More)
/// 2. Copy, Cut, Paste, Copy Special (Copy Link), Paste Special (Paste Image into Card, more tbd)
/// 3. Navigation (Move Left, Move Right)
/// 4. Send to Trash
///
/// **Group 1 gained Labels and group 2 gained Copy Special on 2026-08-09** (card 28c79ffe) see
/// `labelsMenu` for the submenu, and "Copy Link moves" below for the relocation.
///
/// Every row below routes through an *existing* write primitive it twins nothing here invents a
/// new way to touch disk, only new arrangements and, for one group, a new targeting rule over ones
/// the app already has (`OpenCardCommand`, `BoardRenameCommand`, `StyleCommand`/`StyleEditorSession`,
@@ -767,10 +778,21 @@ struct CardFaceView: View, Equatable {
///
/// ### Two deviations from the card's literal list, both kept and both journaled
///
/// **Copy Link stays.** The owner's list does not mention it, but it shipped the same day this
/// card was filed (design ruling 2026-08-09, card 737a949f) and nothing here was asked to retire
/// it dropping a same-day feature would be a behavior change, not a structure one. It rides in
/// the first group, immediately after Open (its original neighbor), ahead of Rename.
/// **Copy Link stays** and, since 2026-08-09, has a home the owner named. It shipped the same
/// day the menu redesign was filed (design ruling, card 737a949f), so that card's list did not
/// mention it and it was parked in group 1 after Open as a deviation kept-and-journaled. The
/// owner's next layout resolves it: **Copy Link now sits in a new `Copy Special` submenu**, beside
/// the `Paste Special` it mirrors, and their own comment on card 28c79ffe says so in as many words
/// ("note 'copy link' has been moved to 'copy special'"). Nothing about the row itself changed
/// same action, same `selectedCount == 1` enablement, same "a link is singular" rule.
///
/// **It stays in `boardActions` even so** (the VoiceOver custom-action list), which is a deliberate
/// exception to that list's own "submenus are absent" rule. The rule is about *containers* Style,
/// Navigation and Paste Special are doors onto other surfaces, and a flat action list has nothing
/// to say about a door. Copy Link is an action that merely changed which door draws it, and
/// dropping it from the list would cost VoiceOver a real capability in exchange for a symmetry the
/// list does not owe. (Paste Image into Card is absent for the older reason: it never was in the
/// list.)
///
/// **The row reads "Send to Trash", not "Delete".** The owner's own word for group 4, and the more
/// accurate one for what this button does on the board side it is the staged move into
@@ -897,8 +919,6 @@ struct CardFaceView: View, Equatable {
Button("Open") {
openCard(card.id)
}
Button("Copy Link") { copyLink() }
.disabled(!copyLinkEnabled)
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
Menu("Style") {
@@ -911,16 +931,24 @@ struct CardFaceView: View, Equatable {
// Both rows share this exact condition, so the submenu itself greys out with them rather than
// opening to reveal two disabled rows.
.disabled(!store.acceptsBoardMutations)
labelsMenu
Divider()
// Group 2 Copy, Cut, Paste, Paste Special Paste Image into Card.
// Group 2 Copy, Cut, Paste, Copy Special Copy Link, Paste Special Paste Image into Card.
Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) }
.disabled(!copyEnabled)
Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
.disabled(!store.acceptsBoardMutations)
Button("Paste") { appModel.clipboard.paste(into: store) }
.disabled(!pasteEnabled)
Menu("Copy Special") {
Button("Copy Link") { copyLink() }
.disabled(!copyLinkEnabled)
}
// Its one row's condition, so the submenu greys out with it rather than opening onto a
// disabled row `Style`'s rule, one group down.
.disabled(!copyLinkEnabled)
Menu("Paste Special") {
Button("Paste Image into Card") {
appModel.clipboard.pasteImage(intoCard: card.id, in: store)
@@ -952,12 +980,109 @@ struct CardFaceView: View, Equatable {
.disabled(!store.acceptsBoardMutations)
}
/// **Group 1's fourth row: `Labels`** (2026-08-09, Pipeline card 28c79ffe; `FrontmatterKeys.labels`,
/// whose reservation the owner retired the same day) the owner's spec verbatim: "a list of up to
/// 12 most frequently and recently used labels", a separator, then "More (show a dialog with a
/// list of all used labels + ability to create new)".
///
/// ### The twelve, and where they come from
///
/// `LabelRanking.ranked` frequency across the board's live and trashed cards, tie-broken by an
/// app-side MRU, then alphabetically for a total order. That function's own doc comment states the
/// arithmetic and the one consequence worth flagging (a brand-new label does not jump a full menu).
///
/// **Toggles, not buttons**, so the row says what the card already is: a `Toggle` renders as a
/// checked row in an AppKit menu, which is exactly the "toggling membership for the clicked card"
/// the spec asks for made visible. The read is free `card` is a compared parameter this view
/// already has, so a checkmark costs no lookup at all.
///
/// ### One card, and the checkmark is why
///
/// Every other item-shaped row in this menu widens to the selection when the clicked card is a
/// member of it (`targetIDs` Copy, Cut, Style, Send to Trash). **These rows deliberately do
/// not**, for two reasons that point the same way:
///
/// - **A checkmark is a claim about one card.** A three-card selection where two carry `bug` has no
/// honest checked state, and an AppKit menu item has no mixed one to draw. The widening rows all
/// carry no state they say what they will *do*, never what is already true which is exactly
/// why widening costs them nothing and would cost this everything.
/// - **It keeps the menu render-safe.** `targetIDs` reads `store.selection`, and `.contextMenu`'s
/// builder is not lazy (this struct's top-of-file note; `copyLinkEnabled`'s doc comment): the
/// checkmark is computed *while the menu is built*, not inside an action, so a widened one would
/// subscribe every card face's body to board-wide selection state the O(board) regression
/// `BoardRenderPerformanceTests.selectionStillRepaints` exists to catch.
///
/// Flagged for owner review: if tagging a multi-card selection at once is wanted, it is a
/// *different* control a row that reads "Add label to 3 Cards", not a checkmark.
///
/// ### What this builder is allowed to read, and what it costs
///
/// Two Observation reads, both narrow and both rarely-changing the class this menu's `.disabled`
/// modifiers already draw from:
///
/// - `store.labelIndex`, the board's used-labels universe. The O(board) walk behind it happens
/// **once per applied snapshot**, on the store, and the assignment is equality-gated so the
/// property changes only when the board's labels genuinely change (its own doc comment says why
/// that gate is load-bearing rather than an optimisation).
/// - `appModel.labelRecents.labels`, the MRU. `appModel.styleRecents` is read in this very body
/// already (the `==` gate's own note lists it), and this list moves on exactly the same cadence:
/// once per label the user applies, which is a deliberate gesture and not a marquee sample.
///
/// What is left in the builder is `LabelRanking.ranked`'s sort, whose size is the board's **label
/// vocabulary** tens of entries and not its card count. That is the whole of "no O(board) work
/// in the menu builder": the board-sized part is cached, and the part that runs here is bounded by
/// how many distinct labels exist.
///
/// ### More
///
/// Opens `LabelPickerPopover` through a session in `TransientBoardState`, `Style`'s exact
/// mechanism and for its reasons see that view for why a popover rather than a sheet.
///
/// **The submenu never greys out whole.** Even a read-only board can open More to *look* at the
/// board's vocabulary (Reveal in Finder's posture: inspection is not a mutation), so the lock
/// disables the twelve toggles and the dialog's own controls rather than the door to them.
@ViewBuilder
private var labelsMenu: some View {
Menu("Labels") {
ForEach(rankedLabels, id: \.self) { name in
Toggle(name, isOn: labelBinding(name))
.disabled(!store.acceptsBoardMutations)
}
if !rankedLabels.isEmpty {
Divider()
}
Button("More…") { store.transient.beginLabelEditor(forCard: card.id) }
}
}
/// The twelve, ranked see `labelsMenu` for the cost argument behind these two reads.
private var rankedLabels: [String] {
LabelRanking.ranked(store.labelIndex, recents: appModel.labelRecents.labels)
}
/// One toggle row's state: whether **this** card carries the label, and the write that flips it.
///
/// The read is off `card.labels` the value this face was handed so it costs no store lookup and
/// no selection read. The write goes through the one funnel every label surface shares, so the
/// menu, the More dialog and the card window's sidebar cannot come to mean three different things
/// (`LabelCommand`).
private func labelBinding(_ name: String) -> Binding<Bool> {
Binding(
get: { CardLabels.contains(name, in: card.labels.value ?? []) },
set: { _ in
LabelCommand.toggle(name, onCard: card.id, in: store, recents: appModel.labelRecents)
}
)
}
/// `boardMenu`'s plain (non-submenu) rows as VoiceOver custom actions every one calling the
/// *same* private method its menu row does, so the two surfaces cannot come to mean different
/// things. Style, Paste Special and Navigation are absent for `LaneView`'s own reason (its Style
/// note): each opens its own accessible surface a popover, or the submenu itself, which the
/// context menu already exposes reachably (VO--M) and is "not an action" in the flat sense this
/// list carries.
/// list carries. **Copy Link is the one exception**, and it moved into a submenu on 2026-08-09
/// without leaving here see `boardMenu`'s "Copy Link stays" note for why. Labels is absent like
/// its fellow submenus: it is a container, and its own rows are checkmarks rather than actions.
@ViewBuilder
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
Button("Open") { openCard(card.id) }