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
+37
View File
@@ -203,3 +203,40 @@ public enum LabelRanking {
return ordered.prefix(limit).map(\.name)
}
}
// MARK: - The More dialog's session
/// **The open More dialog's card** the `labels` submenu's escape hatch, which lists every used
/// label with a toggle plus a field to create a new one (the owner's card menu spec).
///
/// `StyleEditorSession`'s shape and its whole lifecycle argument, narrowed to one card: it lives in
/// `TransientBoardState` rather than in a view because a card face is inside a lazy stack that may be
/// recycled out from under an open surface, and because the reload rule has to be able to take the
/// dialog down when its subject leaves the board.
///
/// **One card, not a target set**, which is the whole difference from the style session and the same
/// decision the submenu itself makes: every row in this dialog is a *checkmark*, and a checkmark has
/// to state a fact about one card. See `CardFaceView.labelsMenu` for the argument in full.
public struct LabelEditorSession: Sendable, Equatable {
/// Whose labels are being edited. `let`, for `StyleEditorSession.target`'s reason: a session whose
/// card has vanished is *gone*, never re-aimed at another one.
public let cardID: ItemID
public init(cardID: ItemID) {
self.cardID = cardID
}
/// This session re-grounded on a freshly applied snapshot, or `nil` when its card is no longer a
/// live board card which the presenting anchor reads as "dismiss".
///
/// Liveness is **effective, ancestor-walked**, for free and for `StyleEditorSession`'s reason: the
/// resolution runs through `ItemReferenceSet`, so a card under a lane somebody just trashed leaves
/// the set exactly as it leaves the selection. A card moved into the trash dismisses too, which is
/// right `BoardStore.setLabels` refuses a trashed card, so a dialog left open over one would be
/// a surface offering writes it cannot make.
public func resolved(against snapshot: BoardModel) -> LabelEditorSession? {
let live = ItemReferenceSet(ids: [cardID], container: .board).resolved(against: snapshot).ids
return live.contains(cardID) ? self : nil
}
}
@@ -367,6 +367,17 @@ public final class TransientBoardState {
/// session assignable from anywhere could be re-aimed behind the reload rule's back.
public private(set) var styleEditor: StyleEditorSession?
/// The open **Labels More** dialog's card, or `nil` when none is open (the card context menu's
/// `labels` submenu; `FrontmatterKeys.labels`, activated 2026-08-09). `styleEditor`'s neighbour in
/// every respect same reason it lives here rather than in a view, same `private(set)`, same
/// re-grounding below narrowed to one card because every row in that dialog is a checkmark about
/// one card (`LabelEditorSession`).
///
/// **Not an inline editor either**, `styleEditor`'s own note: the dialog's create field holds text,
/// but `isEditingInline` is the answer to "may board commands run" and this surface is opened *by*
/// one of them.
public private(set) var labelEditor: LabelEditorSession?
/// Whether a title editor holds focus 04-interactions.md's **focused-editor rule** as one
/// boolean: "while an inline title editor rename or the new-card placeholder is focused,
/// board-scoped menu commands (Delete, New Card, Paste, Move, Style, ) disable via menu
@@ -618,6 +629,22 @@ public final class TransientBoardState {
styleEditor = nil
}
// MARK: - The labels dialog's lifecycle
/// Opens **Labels More** on one card the card context menu's row, and its only caller.
///
/// Replacing any session already open, `beginStyleEditor`'s rule and its reason: there is one
/// dialog, so a second More is the first one being re-aimed by a fresh gesture.
public func beginLabelEditor(forCard cardID: ItemID) {
labelEditor = LabelEditorSession(cardID: cardID)
}
/// Closes it the user dismissing it, and the anchor's response to a session the reload rule
/// emptied.
public func discardLabelEditor() {
labelEditor = nil
}
// MARK: - Reload
/// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot.
@@ -691,6 +718,7 @@ public final class TransientBoardState {
pendingCut = pendingCut.resolved(against: snapshot)
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
styleEditor = styleEditor?.resolved(against: snapshot)
labelEditor = labelEditor?.resolved(against: snapshot)
// One universe computed once and asked three questions the rename target's container, the
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.