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
+140
View File
@@ -0,0 +1,140 @@
import SwiftUI
// MARK: - Presentation
/// Whether *this* card face is the one showing the open **Labels More** dialog.
///
/// `styleEditorPresentation`'s shape and its reasoning, one session over and simpler for it: a label
/// session names exactly one card, so the anchor test is an identity comparison rather than a walk for
/// the first live member of a set. The setter is narrowed to this anchor's own dismissal for that
/// function's reason exactly a session that has moved on must not be discarded by a surface it is no
/// longer about.
@MainActor
func labelEditorPresentation(_ store: BoardStore, anchor: ItemID) -> Binding<Bool> {
Binding(
get: { store.transient.labelEditor?.cardID == anchor },
set: { presented in
guard !presented, store.transient.labelEditor?.cardID == anchor else { return }
store.transient.discardLabelEditor()
}
)
}
// MARK: - The dialog
/// **Labels More** every label the board uses, each with a toggle, plus a field that creates a
/// new one (the owner's card-menu spec: "More (show a dialog with a list of all used labels +
/// ability to create new)").
///
/// ### Why it exists beside the twelve
///
/// The submenu's rows are a *shortcut* the labels this user is most likely to want, ranked
/// (`LabelRanking`). This is the **inventory**: every label in the board's universe, however rarely
/// used, in an order built for looking one up rather than for reaching the common ones fast
/// (`LabelIndex.alphabetical`). It is also the only surface on the board side that can mint a label
/// the board has never used the twelve can only ever offer what already exists.
///
/// ### A popover, not a sheet
///
/// "Dialog" is the owner's word for the shape, not necessarily for the presentation. A sheet would
/// block the board window for a gesture whose whole character is quick tagging, and it would have
/// nothing to anchor to the user right-clicked a specific card and the answer belongs beside it.
/// Style is the precedent in this exact position: same anchor, same session-backed lifecycle, same
/// dismissal on click-away. Flagged for owner review if a real modal was meant.
///
/// ### One card
///
/// Every row is a checkmark, and a checkmark states a fact about one card see
/// `CardFaceView.labelsMenu` for the argument and why it is also what keeps the menu render-safe.
struct LabelPickerPopover: View {
let store: BoardStore
let recents: LabelRecents
/// What is typed in the create field.
@State private var draft = ""
@FocusState private var fieldFocused: Bool
var body: some View {
// Empty for the frame between a session ending and the popover's own dismissal landing
// `StyleEditorPopover`'s own formality, and for its reason.
if let session = store.transient.labelEditor {
content(for: session.cardID)
}
}
private func content(for cardID: ItemID) -> some View {
let current = store.labels(ofCard: cardID)
let universe = store.labelIndex.alphabetical
return VStack(alignment: .leading, spacing: 8) {
Text("Labels")
.font(.caption.weight(.semibold))
.textCase(.uppercase)
.foregroundStyle(.secondary)
if universe.isEmpty {
Text("This board has no labels yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
// A scroll view rather than a bare stack: the universe has no bound, and a popover
// that grows past the screen is a popover that cannot be dismissed by its own bottom
// edge. The height is a cap, not a size a board with three labels draws three rows.
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 2) {
ForEach(universe, id: \.self) { name in
Toggle(name, isOn: binding(for: name, onCard: cardID, current: current))
.toggleStyle(.checkbox)
.lineLimit(1)
.truncationMode(.middle)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 220)
}
Divider()
HStack(spacing: 6) {
TextField("New label", text: $draft)
.textFieldStyle(.roundedBorder)
.focused($fieldFocused)
.onSubmit { create(onCard: cardID) }
.accessibilityLabel("New Label")
Button("Add") { create(onCard: cardID) }
.disabled(CardLabels.normalized(draft) == nil)
}
.disabled(!store.acceptsBoardMutations)
}
.padding(12)
.frame(width: 240)
.onAppear { fieldFocused = true }
}
/// One row's checkmark: reads the card's current list, writes through the one funnel.
///
/// `current` is passed in rather than re-read per row one snapshot lookup for the whole dialog,
/// so twenty rows cost one walk instead of twenty.
private func binding(for name: String, onCard cardID: ItemID, current: [String]) -> Binding<Bool> {
Binding(
get: { CardLabels.contains(name, in: current) },
set: { _ in LabelCommand.toggle(name, onCard: cardID, in: store, recents: recents) }
)
}
/// The create field's landing. It **applies** rather than toggles the user typed a name into a
/// box labelled "New label", which is a request to put it on the card, never to take it off
/// (`LabelCommand.add`, which also resolves the board's own spelling for a name that merely looks
/// new).
///
/// The field clears and keeps focus: adding two labels in a row is the common case, and the
/// popover's own dismissal is click-away or Escape.
private func create(onCard cardID: ItemID) {
guard store.acceptsBoardMutations, CardLabels.normalized(draft) != nil else { return }
LabelCommand.add(draft, onCard: cardID, in: store, recents: recents)
draft = ""
fieldFocused = true
}
}