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