diff --git a/Kanban/LiveStore/LabelIndex.swift b/Kanban/LiveStore/LabelIndex.swift index ed43a22..6386bb9 100644 --- a/Kanban/LiveStore/LabelIndex.swift +++ b/Kanban/LiveStore/LabelIndex.swift @@ -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 + } +} diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift index 589337b..8a3a76e 100644 --- a/Kanban/LiveStore/TransientBoardState.swift +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -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. diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index e6a6fff..8b1f13a 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -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 { + 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) } diff --git a/Kanban/UI/Board/LabelPickerPopover.swift b/Kanban/UI/Board/LabelPickerPopover.swift new file mode 100644 index 0000000..5a68ec2 --- /dev/null +++ b/Kanban/UI/Board/LabelPickerPopover.swift @@ -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 { + 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 { + 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 + } +} diff --git a/KanbanTests/BoardRenderPerformanceTests.swift b/KanbanTests/BoardRenderPerformanceTests.swift index 21b3579..8645a89 100644 --- a/KanbanTests/BoardRenderPerformanceTests.swift +++ b/KanbanTests/BoardRenderPerformanceTests.swift @@ -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.. 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() diff --git a/KanbanTests/CardLabelsTests.swift b/KanbanTests/CardLabelsTests.swift index 7655fb0..262cda6 100644 --- a/KanbanTests/CardLabelsTests.swift +++ b/KanbanTests/CardLabelsTests.swift @@ -585,6 +585,138 @@ struct LabelStoreWriteTests { } } +// MARK: - The More… dialog's session + +@MainActor +@Suite("Labels ▸ the More… dialog's lifecycle") +struct LabelEditorSessionTests { + + @Test("Begin aims it at one card; a second Begin re-aims rather than stacking") + func beginAndDiscard() throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + #expect(store.transient.labelEditor == nil) + store.transient.beginLabelEditor(forCard: clipboardCard1) + #expect(store.transient.labelEditor?.cardID == clipboardCard1) + store.transient.beginLabelEditor(forCard: clipboardCard2) + #expect(store.transient.labelEditor?.cardID == clipboardCard2, "one dialog, re-aimed") + store.transient.discardLabelEditor() + #expect(store.transient.labelEditor == nil) + } + + @Test("The reload rule takes it down when its card is no longer a live board card") + func theReloadRule() throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + + #expect(LabelEditorSession(cardID: clipboardCard1).resolved(against: snapshot) != nil) + // A trashed card resolves nowhere on the board side, which is exactly right: the write the + // dialog offers (`BoardStore.setLabels`) refuses a trashed card. + #expect(LabelEditorSession(cardID: clipboardCard3).resolved(against: snapshot) == nil) + #expect(LabelEditorSession(cardID: ItemID(rawValue: Ident.indexless)) + .resolved(against: snapshot) == nil) + } + + @Test("A card that leaves the board while the dialog is open dismisses it") + func aVanishedCardDismisses() async throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.transient.beginLabelEditor(forCard: clipboardCard1) + + try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + + #expect(store.transient.labelEditor == nil) + // And the style session's own lifecycle is untouched by the new neighbour. + #expect(store.transient.styleEditor == nil) + } + + @Test("The two sessions are independent — neither `begin` clears the other") + func theTwoSessionsAreIndependent() throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginStyleEditor(for: .items([clipboardCard1])) + store.transient.beginLabelEditor(forCard: clipboardCard1) + #expect(store.transient.styleEditor != nil) + #expect(store.transient.labelEditor != nil) + store.transient.discardLabelEditor() + #expect(store.transient.styleEditor != nil, "discarding one leaves the other") + } +} + +// MARK: - The menu's funnel + +@MainActor +@Suite("Labels ▸ the command funnel") +struct LabelCommandTests { + + private func settle(_ store: BoardStore) async { + store.handleWatcherEvent(.treeChanged(.appMediated)) + await store.awaitQuiescence() + } + + @Test("Toggle adds what is missing and removes what is there") + func toggling() async throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!) + + #expect(LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents)) + await settle(store) + #expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c", "spike"]) + + #expect(LabelCommand.toggle("SPIKE", onCard: clipboardCard1, in: store, recents: recents)) + await settle(store) + #expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c"], "case-insensitive, like everywhere") + } + + @Test("An apply records the MRU; a removal deliberately does not") + func recording() async throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!) + + LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents) + #expect(recents.labels == ["spike"]) + await settle(store) + + LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents) + #expect(recents.labels == ["spike"], "taking a label off is not reaching for it") + + // And an add onto a card that already carries it still records — the user reached for it. + await settle(store) + LabelCommand.add("a", onCard: clipboardCard1, in: store, recents: recents) + #expect(recents.labels == ["a", "spike"]) + } + + @Test("A typed name lands in the board's own spelling") + func spellingResolution() async throws { + let fixture = try makeClipboardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!) + + // The board already spells it `a`; typing `A` must not mint a second variant. + #expect(LabelCommand.resolved("A", in: store) == "a") + #expect(LabelCommand.resolved("brand-new", in: store) == "brand-new", "a new name is the typist's") + #expect(LabelCommand.resolved(" ", in: store) == nil) + + LabelCommand.add("A", onCard: clipboardCard2, in: store, recents: recents) + await settle(store) + #expect(store.labelIndex.names.allSatisfy { $0 != "A" }, "no second spelling entered the universe") + #expect(recents.labels == ["a"]) + } +} + // MARK: - The sidebar's picker @Suite("Labels ▸ the sidebar's add field")