Files
lanework/Kanban/LiveStore/TransientBoardState.swift
rzen 9588f7b1f0 Comments, phase 3 — search, the thread find, announcements, and a11y
Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 21:30:22 -04:00

730 lines
43 KiB
Swift

import Foundation
import Observation
// MARK: - ItemReferenceSet
/// A set of UUIDs over the snapshot plus the container it lives in — **the one shape every
/// piece of transient state that points at items wears**: the selection, drag membership, and the
/// pending cut are three values of this type, not three hand-rolled near-copies
/// (02-architecture.md § Live-reload resilience, "Selection — and every transient state that
/// references items (drag state, pending cut) — is a set of UUIDs over the snapshot").
///
/// **UUIDs, never indices or copies of items.** A reload swaps the whole snapshot as a value, and
/// anything holding positions or item copies would be silently wrong the moment an agent files a
/// card.
///
/// ### One rule, applied in two directions
///
/// `constrained(to:)` *is* the rule — **state may only reference items in the current universe** —
/// and everything else here is that primitive with a universe supplied. Only the universe differs
/// between the two callers:
///
/// - **Reload survival**: the universe is the new snapshot's ids in this set's container, which
/// is what `resolved(against:)` computes before delegating.
/// - **The live search filter**: the universe is the visible ids the predicate produced, so
/// 04-interactions.md § Search's "hidden cards leave the selection" needs no second rule — it is
/// this one, with a different universe.
///
/// Both stay **pure value functions**. Deciding *when* to apply them belongs to the caller, and
/// storing the result belongs to `TransientBoardState` — a set that filtered itself would need to
/// know about snapshots, and the whole point of the value-type snapshot is that nothing has to.
public struct ItemReferenceSet: Sendable, Equatable {
public var ids: Set<ItemID>
/// Which container the members live in — the board, or `.trash/` (`ItemContainer`).
///
/// A property of the set as a whole rather than of each member, because 04-interactions.md ▸ The
/// trash keeps exactly one boundary: "a selection never mixes trash cards with board cards".
/// That is what makes re-resolution across a reload a matching rule rather than a partition.
public var container: ItemContainer
public init(ids: Set<ItemID> = [], container: ItemContainer = .board) {
self.ids = ids
self.container = container
}
/// Nothing referenced, on the board side — the state a board opens in, the state a drag with
/// nothing in flight is in, and the state `TransientBoardState.clearSelection()` returns to.
public static let empty = ItemReferenceSet()
public var isEmpty: Bool { ids.isEmpty }
/// This set narrowed to `universe`: members that are in it, **container unchanged**.
///
/// The primitive both directions are built from — intersection and nothing else. It is
/// deliberately ignorant of what a universe *is*: a snapshot's ids in one container
/// (`resolved(against:)`) and a search predicate's visible ids are the same argument as far as
/// the rule is concerned, which is what lets one rule be stated once and mean both.
///
/// The container survives even when the membership does not: an emptied set is still a set in a
/// container, and re-populating it (a fresh click, a new drag) is the caller's business.
public func constrained(to universe: Set<ItemID>) -> ItemReferenceSet {
guard !ids.isEmpty else { return self }
return ItemReferenceSet(ids: ids.intersection(universe), container: container)
}
/// This set re-grounded on `snapshot`: the members that are still there, **in the same
/// container**, and nothing else.
///
/// Two rules, both settled in 02-architecture.md § Live-reload resilience:
///
/// - **Vanished members leave silently.** No substitute is invented, no successor is picked —
/// an empty result is a legitimate outcome. (App-mediated deletion is deliberately different:
/// ⌫ selects the successor sibling, because that is an act rather than a surprise —
/// 04-interactions.md ▸ The map. That belongs to the delete command, not here.)
/// - **A container crossing is a vanish** (resettled 2026-07-28, the materialized trash): "a
/// foreign move that trashes a selected board card — or restores a selected trash card —
/// ejects it from the selection (and from the pending cut)", keeping 04's container-boundary
/// invariant true across reloads so menu validation never sees a mixed selection. Drag
/// membership inherits it for free (04 ▸ Drag and drop's emptied-drag rule).
///
/// **Presence is the whole test.** There is no ancestor walk and no effective liveness left to
/// compute — "the old effective-liveness ancestor walk is retired with the tombstone model"
/// (02-architecture.md) — because a deleted card's folder has actually moved, and a folder is
/// either in the container or it is not.
public func resolved(against snapshot: BoardModel) -> ItemReferenceSet {
guard !ids.isEmpty else { return self }
return constrained(to: container.ids(in: snapshot))
}
}
// MARK: - NewCardPlaceholder
/// The card being created: an inline editor rendered as a pseudo-card **overlaid** on the snapshot,
/// with no disk presence and no UUID until the title commits — 02-architecture.md § Layering's *one
/// named exception* to the one-way flow, and the only thing in `TransientBoardState` that is not a
/// set of ids.
///
/// **Anchored to a lane, never to an item set**, and that asymmetry is the point: an unborn card
/// has no identity to re-resolve, so the only question a reload can ask about it is whether the
/// place it is being born into still exists. `TransientBoardState.resolve(against:)` owns the
/// answer; see its doc comment for the two discard rules.
public struct NewCardPlaceholder: Sendable, Equatable {
/// How far along the birth is — and therefore what a reload has to check.
public enum Phase: Sendable, Equatable {
/// The inline editor is open and the draft is in flight. Nothing has been written; abandoning
/// (Escape, empty commit, click-away) discards it and disk was never touched
/// (04-interactions.md ▸ Grammar).
case editing
/// The Writer's create ran, so the real card's id is minted — but the watcher has not
/// round-tripped it yet, and until it does the snapshot has no such card. The overlay stands
/// in for it across that gap ("the placeholder stays visible until the real card arrives,
/// then hands off"), which is what keeps the one-way flow from showing a hole where the user
/// just typed a title.
case awaitingArrival(ItemID)
}
/// The lane the card is being created in — the anchor, and the only item identity a placeholder
/// has before its title commits.
public var laneID: ItemID
/// The card the new one is being born **immediately after**, or `nil` for the lane's bottom.
///
/// It exists because 04-interactions.md's ⌘N target rule is a *position*, not just a lane:
/// "with a card selected, the new card is created in that card's lane, immediately after it
/// (paste-anchor consistency)". Every other entry point — Return on a lane, the header button,
/// a double-click on empty space, ⌘N with nothing selected — appends at the bottom and passes
/// `nil`.
///
/// **A position, deliberately not a rank.** The `order` is computed at *commit* time against
/// the snapshot as it is then (`BoardStore.commitPlaceholder`), never frozen when the editor
/// opened: an agent filing a card into the gap mid-typing must move the new card, not be
/// overwritten by a stale midpoint. An anchor that has vanished by commit time degrades to the
/// lane's bottom rather than failing — the lane is the anchor that matters, and the card being
/// created is the user's, not the vanished neighbour's.
public var anchorCardID: ItemID?
/// What the user has typed so far. Lives here and nowhere else: there is no file to hold it.
public var draftTitle: String
public var phase: Phase
public init(
laneID: ItemID,
anchorCardID: ItemID? = nil,
draftTitle: String = "",
phase: Phase = .editing
) {
self.laneID = laneID
self.anchorCardID = anchorCardID
self.draftTitle = draftTitle
self.phase = phase
}
}
// MARK: - RenameEditor
/// The **third inline editor** (04-interactions.md ▸ Grammar): a card's or a lane's title being
/// edited in place, on the card face or in the lane header.
///
/// It is `NewCardPlaceholder`'s sibling in shape and its opposite in almost every rule, and the
/// contrast is worth stating once:
///
/// | | `NewCardPlaceholder` | `RenameEditor` |
/// |---|---|---|
/// | References | a *lane* (no identity yet) | an **item**, by UUID |
/// | Click-away | discards — nothing is on disk | **commits** — the item is real |
/// | Empty commit | discards the whole draft | removes the `title` key |
/// | A reload can | drop it, or hand it off | only drop it |
///
/// **Tracked by UUID, which makes a foreign move invisible.** "A foreign *move* mid-rename is
/// invisible — the editor follows the UUID and the commit writes the title wherever the card now
/// lives"; the commit re-derives the folder from the current snapshot, so a card an agent filed
/// into another lane mid-typing is still renamed correctly.
///
/// **The filter never touches it** (04-interactions.md § Search, settled): "an open inline rename
/// survives the filter hiding its card — the editor is a surface the filter doesn't reach; it stays
/// open and focused, commits by UUID wherever the card lives, Escape abandons … and the
/// vanish-discard rule stays reserved for true liveness flips". Two halves, in two places: this
/// editor outlives a reload that stops its card matching, because `resolve(against:)` discards only
/// on a *vanish* and `constrainToSearch(in:)` touches the selection and nothing else; and its
/// *field* outlives it too, because `LaneView.rendered` keeps the renaming card's slot in the
/// masonry for as long as the editor is open.
///
/// There is no phase enum. The placeholder needs one because it outlives its own commit (the
/// overlay stands in for a card that has not arrived yet); a rename has nothing to stand in for —
/// the item is already on screen, and the commit's round trip simply updates it.
public struct RenameEditor: Sendable, Equatable {
/// The card or lane being renamed. Deliberately untyped as to *kind*: the commit derives the
/// folder by finding the id in the snapshot, and every rule here — the vanish discard, the
/// UUID tracking, the empty-commit removal — reads identically for both levels.
public var targetID: ItemID
/// What the user has typed. **Seeded from the current title** when the editor opens (an
/// untitled item seeds empty, since "Untitled" is a rendering, not a value —
/// 03-board-ui.md § Card face), and thereafter the only place the draft exists.
public var draftTitle: String
public init(targetID: ItemID, draftTitle: String = "") {
self.targetID = targetID
self.draftTitle = draftTitle
}
}
// MARK: - TransientBoardState
/// Everything a board's windows share that is **not on disk** — one per `BoardStore`, created with
/// it and dying with it (02-architecture.md § Changes from Kanban, settled m3).
///
/// Its reason for existing is that the store's transient state was becoming a grab-bag. Every member
/// below is filed by **how a reload treats it**, and there are exactly three kinds plus a remainder:
///
/// 1. **Item-referencing sets** — `selection`, `dragMembers`, `pendingCut`. One shape
/// (`ItemReferenceSet`) and one constraint rule, *members must exist in the current universe*.
/// `resolve(against:)` applies it to each of them **independently**: a card vanishing from the
/// selection has no business disturbing a drag in flight or a pending cut, and independence is
/// the only way that stays true without three orders of operations to reason about.
/// 2. **Derived state, stored as its inputs only** — `searchQuery` is kept; its *result set* is
/// deliberately absent. The filter is a live predicate re-run against each new snapshot
/// (04-interactions.md § Search), so a card an agent files mid-search appears the moment the
/// reload lands and a card edited to no longer match animates out. A stored result set would be
/// a second, staler answer to a question the snapshot can always answer, and would need a
/// re-resolution rule of its own — which is exactly the accretion this type exists to stop.
/// 3. **The inline editors** — `newCardPlaceholder`, anchored to a lane rather than to items,
/// discarded when the lane it is anchored to goes away and handed off when the real card
/// arrives; and `renameEditor`, anchored to an *item* and discarded when that item vanishes.
/// Two editors, one at a time: they share the app's single keyboard focus, so beginning either
/// ends the other.
///
/// The remainder is plain per-open values: `isTrashVisible` is hidden on every open and **never
/// persisted** — visiting the trash is an errand, not a layout choice. It needs no reset logic
/// because this object is built fresh with its store; closing the board is the reset.
/// `lastActiveLaneID` is per-open in the same sense — "in this window session" is exactly the
/// scope of a container built with its store — but it *does* reference an item, so `resolve` has
/// something to say about it.
///
/// `@MainActor` because it is read by SwiftUI on the main actor and mutated by gestures there;
/// `@Observable` so the board window and its card windows re-render off the same truth.
@MainActor
@Observable
public final class TransientBoardState {
// MARK: Item-referencing sets
/// What the user has selected, re-resolved against every snapshot the store applies.
///
/// `private(set)` with mutators below because the selection is the one set with a *grammar*
/// coming (extend, range, successor-on-delete — 04-interactions.md ▸ Grammar), and every one of
/// those rules will want a single funnel. Its siblings are plain `var`s: a drag and a cut are
/// set wholesale by the gesture that owns them.
public private(set) var selection: ItemReferenceSet = .empty
/// **Where a ⇧-click ranges from** — the item the last plain or ⌘ click named
/// (04-interactions.md § Selection, "⇧-click range-extends").
///
/// A *memory of a gesture*, like `lastActiveLaneID` and for the same reason: it is not
/// derivable from the selection. A range replaces the whole set and deliberately leaves the
/// anchor put, so successive ⇧-clicks sweep out from one origin instead of walking it along —
/// which means nothing in the set marks it.
///
/// **A marquee and every wholesale selection pass no anchor deliberately.** There is no click
/// behind them to range from, so a ⇧-click afterwards behaves as a plain click — the same
/// degrade `SelectionGrammar` gives a vanished anchor, reached honestly rather than by inventing
/// an origin the user never named.
///
/// It lives on the selection's side by construction, so `resolve(against:)` re-grounds it with
/// the same rule every other item reference gets.
public private(set) var selectionAnchor: ItemID?
/// **Where the next arrow steps from** — the navigation cursor, AppKit's "lead": the item the
/// last click or arrow named (04-interactions.md ▸ Grammar's spatial navigation).
///
/// **Distinct from the anchor, and the difference is the whole reason both exist.** A ⇧-gesture
/// leaves the anchor exactly where it was — that is what makes successive extensions sweep out
/// from one origin — while the *head* walks to whatever was just reached, because the next
/// ⇧-arrow has to continue from there rather than from the origin. A plain click or arrow moves
/// both; a ⇧-click or ⇧-arrow moves only this.
///
/// A memory of a gesture like the anchor, on the selection's side by construction, and re-grounded
/// by `resolve(against:)` under the same universe rule.
public private(set) var selectionHead: ItemID?
/// The items a drag is carrying — **empty when no drag is in flight**, which is what "no drag"
/// means here rather than a separate flag.
///
/// Reload-resolved like any set: partial vanishing drops the survivors, and when the last
/// member goes the drag has emptied itself and cancels (04-interactions.md ▸ Drag and drop,
/// "an emptied drag cancels itself"). Deciding what an emptied drag *does* is the drag
/// controller's job (m5); losing the members is this rule's.
public var dragMembers: ItemReferenceSet = .empty
/// The ⌘X staging set: cut items dim in place until a paste moves them (04-interactions.md ▸
/// Clipboard, "Cut is Finder-style deferred").
///
/// The pasteboard and the staged folder snapshots are not here — they are app-wide, outlive this
/// store, and are 04's own story. This is only the *in-board* half: which of this board's items
/// are showing as cut. "Deletion voids per item" is the reload rule above, applied here for
/// free.
public var pendingCut: ItemReferenceSet = .empty
// MARK: Derived state, stored as its inputs
/// The live search field's text (04-interactions.md § Search). Empty means no search is active.
///
/// **The result set is not stored** — see this type's doc comment, kind 2. The predicate runs
/// against whatever snapshot is current, so the filter is never stale, and the selection is kept
/// honest against it by `ItemReferenceSet.constrained(to:)` with the visible ids as the universe
/// — the same rule a reload uses, which is why "hidden cards leave the selection" needs no code
/// of its own (`constrainToSearch(in:)`).
///
/// **Written through `BoardStore.searchQuery`, not here**, on every path that *narrows* it: the
/// store is what has a snapshot, and narrowing without constraining would leave a selection
/// pointing at cards nobody can see. Widening — `beginPlaceholder`'s creation clear, and the
/// clear Escape performs — is safe from anywhere, because a bigger universe invalidates nothing.
public var searchQuery: String = ""
// MARK: The inline editors
/// The new-card placeholder, or `nil` when no card is being created. See `NewCardPlaceholder`
/// for what it is and `resolve(against:)` for what a reload does to it.
///
/// `private(set)`: its four legal transitions are the lifecycle methods below, and an unborn
/// card is exactly the kind of state that rots if anyone may assign it.
public private(set) var newCardPlaceholder: NewCardPlaceholder?
/// The inline rename in flight, or `nil` when no title is being edited. See `RenameEditor`.
///
/// `private(set)` for the placeholder's reason, plus one of its own: the *commit* is a write
/// that only `BoardStore` can perform, so an editor assignable from anywhere could be cleared
/// out from under a commit that was about to read its draft.
public private(set) var renameEditor: RenameEditor?
/// The open Style… popover's target, or `nil` when no popover is open (03-board-ui.md § Styling
/// ▸ Controls). See `StyleEditorSession` for the lifecycle it implements and why it lives here
/// rather than in a view.
///
/// **Not an inline editor**, deliberately: it is not a title field, it does not hold the text
/// domain's keyboard, and `isEditingInline` must stay the answer to "may board commands run" —
/// Style… itself is *disabled* while an inline editor is open, so the two never coexist anyway.
///
/// `private(set)` for `renameEditor`'s reason: its two transitions are the methods below, and a
/// session assignable from anywhere could be re-aimed behind the reload rule's back.
public private(set) var styleEditor: StyleEditorSession?
/// 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
/// validation".
///
/// Every board-mutating menu item validates against this, so the rule is stated once rather
/// than re-derived per item. The one carve-out the design names — Open Card ⌘↩, which stays
/// enabled to commit the edit and open the window — is the item's business, not this flag's.
public var isEditingInline: Bool {
newCardPlaceholder != nil || renameEditor != nil
}
// MARK: Per-open values
/// The lane that most recently held selection or a creation **in this window session** —
/// 04-interactions.md's ⌘N target rule's fallback when nothing (or a trash selection, which
/// never anchors creation) is selected, before the last resort of the first lane.
///
/// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing
/// in the snapshot that could reconstruct which lane the user was last working in, which is
/// precisely why the rule exists — a ⌘N after an Escape should file the card where the user
/// has been, not at the far left of the board.
///
/// `resolve(against:)` clears it when the lane vanishes, because a target that renders nowhere
/// is no target at all; `NewCardTarget` then falls through to the first lane.
public private(set) var lastActiveLaneID: ItemID?
/// Whether the trash column is showing (03-board-ui.md ▸ Trash).
///
/// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout
/// choice, so it does not belong in the board registry beside window frames
/// (02-architecture.md § Per-board app state). Nothing resets it — a fresh store means a fresh
/// container means `false`.
public var isTrashVisible: Bool = false
public init() {}
// MARK: - Selection
/// Replaces the selection, and sets the anchor a subsequent ⇧-click ranges from and the head a
/// subsequent arrow steps from.
///
/// The grammar itself is `SelectionGrammar`'s — pure, testable, and the one funnel every click
/// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its
/// own is the **anchor default**, which the head shares: `nil` with a sole member takes that
/// member, `nil` with any other count takes nothing. That makes the two callers that pass
/// nothing behave exactly as they should — a one-item selection made by any route is a
/// legitimate range origin *and* a legitimate place to arrow from, while a marquee or a Select
/// All names no gesture and so leaves a ⇧-click acting plain and the arrows re-deriving a
/// position from the set's last member.
///
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
public func select(_ ids: Set<ItemID>, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) {
selection = ItemReferenceSet(ids: ids, container: container)
let sole = ids.count == 1 ? ids.first : nil
selectionAnchor = anchor ?? sole
selectionHead = head ?? sole
}
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). The anchor and
/// the head go with it: an empty selection has no origin to range from and no cursor to step
/// from — which is exactly the state the arrows' seed rule answers.
public func clearSelection() {
selection = .empty
selectionAnchor = nil
selectionHead = nil
}
/// Records that `laneID` is where the user is working — a lane selected, or created into.
///
/// **`nil` is a no-op, not a clear.** "Last-active" is a high-water mark: clearing the
/// selection does not un-happen the lane the user was just in, and 04-interactions.md's rule
/// leans on exactly that (⌘N *with nothing selected* is the case the memory serves). The only
/// thing that clears it is the lane going away, which `resolve(against:)` owns.
public func noteActiveLane(_ laneID: ItemID?) {
guard let laneID else { return }
lastActiveLaneID = laneID
}
// MARK: - Creation's carve-out
/// **A user-initiated creation clears the query** — 04-interactions.md § Search's one exception
/// to the pure predicate, and the single place it is stated.
///
/// > creating a card clears the search — creation's carve-out exists because a brand-new card
/// > must not be born invisible, and it is **stated by mechanism, not by gesture** (settled):
/// > *any* user-initiated creation on the board clears the query — ⌘N, Return-creation, the
/// > header button, empty-space double-click, paste, and Finder file drops alike — while
/// > foreign/agent-filed cards keep riding the live filter.
///
/// So this is a *seam*, not a gesture's line: every path that mints an item because the user
/// asked for one calls it, and there are exactly three of them —
///
/// - `beginPlaceholder(inLane:after:)` below, which is itself the funnel for the four inline
/// creation gestures (⌘N, Return on a lane, the header button, a double-click on empty space);
/// - `ClipboardStore.perform` — ⌘V, cards and lanes alike, at the moment the items actually
/// land (a paste the pasteboard went stale under lands nothing and so clears nothing);
/// - `BoardStore.createCards(fromFiles:inLane:at:)` — the Finder file drop's *create* half. Its
/// attach half (`importAttachments`) deliberately does not call this: dropping files on a card
/// creates nothing, so there is no card to be born invisible.
///
/// **What is deliberately not here** is the other half of the same sentence: an item this board
/// receives without the user asking *it* for one keeps riding the filter. A cross-board drag
/// arrival is the near miss — it is a transfer whose destination-side clear 04 does not state,
/// and the enumerated mechanisms above are the ones it does — and an agent filing a card is the
/// far one (02-architecture.md's derived-result rule). **New Lane is not here either**, for the
/// carve-out's own reason rather than in spite of it: "lanes are never filtered out", so a lane
/// cannot be born invisible and has nothing to be rescued from — ⇧⌘N under a query adds a lane
/// showing a `0` badge, which is the filter behaving exactly as designed.
///
/// Widening the universe invalidates nothing, so unlike a *narrowing* write this needs no
/// snapshot and no `constrainToSearch(in:)` behind it — which is why the rule can live on this
/// type at all rather than on the store (see `searchQuery`).
public func noteUserCreation() {
searchQuery = ""
}
// MARK: - The placeholder's lifecycle
/// Opens the inline editor for a new card in `laneID`, replacing any editor already open and
/// marking the lane active.
///
/// Replacing rather than refusing: two inline editors can never be open at once (one focus),
/// so a second begin is the first one being abandoned — 04-interactions.md's click-away
/// discard, arriving as a new creation instead of a click (02-architecture.md § Layering
/// states it outright: "Starting a new creation while a placeholder is open is a click-away
/// for the draft"). A rename in flight is dropped for the same reason; a rename's click-away
/// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the
/// focus is being taken by another editor before the user has said they are done.
///
/// **Creation clears the search, and this is the funnel for the inline gestures**
/// (04-interactions.md § Search): "a brand-new card must not be born invisible". ⌘N, Return on a
/// lane, the lane header's button and a double-click on empty space all arrive here, so the four
/// of them state the carve-out once — by calling the rule's one home, `noteUserCreation()`,
/// which the paste and Finder-file-drop paths call too.
///
/// **Rename deliberately gets no such line** (04, settled): "the filter stays a pure predicate
/// with one exception, not two". A rename committed under an active search re-runs the
/// predicate like any other edit, and a title that stops matching animates its card out and
/// drops it from the selection — which falls out of `BoardStore.commitRename`'s ordinary write
/// and the reload's `constrainToSearch(in:)`, with nothing here to arrange it.
///
/// - Parameter anchorCardID: the card the new one is born immediately after (04's ⌘N target
/// rule), or `nil` for the lane's bottom — which is what Return, the header button, and a
/// double-click on empty space all pass.
public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) {
noteUserCreation()
renameEditor = nil
newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID)
noteActiveLane(laneID)
}
/// Records what the user has typed. A no-op with no placeholder open — the draft has nowhere to
/// live, and inventing a placeholder to hold it would put an editor on screen that nobody asked
/// for.
public func updateDraft(_ title: String) {
newCardPlaceholder?.draftTitle = title
}
/// Marks the placeholder as waiting for the card the Writer just created.
///
/// **The Writer call is not made here.** This type stores no URLs and performs no I/O; the create
/// runs through `BoardStore.performWrite` at the UI's call site (m5), which is also the only
/// place that can decide what a *failed* create should do with the editor. All this records is
/// the id to watch for, so the overlay knows when its job is done.
///
/// A no-op with no placeholder open: nothing is awaiting anything.
public func commitPlaceholder(expecting id: ItemID) {
newCardPlaceholder?.phase = .awaitingArrival(id)
}
/// Abandons the placeholder — Escape, an empty commit, a click-away (04-interactions.md ▸
/// Grammar). Disk was never touched, so there is nothing to undo.
public func discardPlaceholder() {
newCardPlaceholder = nil
}
// MARK: - The rename editor's lifecycle
/// Opens the inline rename of `targetID`, seeded with `currentTitle`.
///
/// The seed is the caller's because this type holds no snapshot: the two entry points (Return
/// on a sole selected card, Board ▸ Rename) both have the item in hand already. `nil` seeds an
/// empty field — an untitled item has no title to edit, and "Untitled" is a rendering that
/// must never be typed into the file (03-board-ui.md § Card face).
///
/// Replaces whatever editor was open, for `beginPlaceholder`'s reason: one focus, one editor.
public func beginRename(of targetID: ItemID, currentTitle: String?) {
newCardPlaceholder = nil
renameEditor = RenameEditor(targetID: targetID, draftTitle: currentTitle ?? "")
}
/// Records what the user has typed. A no-op with no editor open, like `updateDraft`.
public func updateRenameDraft(_ title: String) {
renameEditor?.draftTitle = title
}
/// Closes the rename editor without writing — **Escape's abandon**, and also how
/// `BoardStore.commitRename` retires the editor once its write has been issued (or refused).
///
/// There is no `commitRename` here for the same reason there is no create here: this type
/// stores no URLs and performs no I/O. The keystrokes are simply dropped; on the abandon path
/// disk was never touched, and on the commit path disk has already been touched by the time
/// this runs.
public func discardRename() {
renameEditor = nil
}
// MARK: - The style editor's lifecycle
/// Opens the Style… popover on `target` — the menu item's call and both context menus'
/// (03-board-ui.md § Styling ▸ Controls).
///
/// Replacing any session already open, for the inline editors' reason turned inside out: there is
/// one popover, so a second Style… is the first one being re-aimed by a fresh gesture. Unlike
/// `beginRename`/`beginPlaceholder` it does **not** clear the inline editors — it cannot coexist
/// with one (Style… disables while an editor is focused), so clearing them here would be a rule
/// about a state that menu validation already rules out.
public func beginStyleEditor(for target: StyleTarget) {
styleEditor = StyleEditorSession(target: target)
}
/// Closes the popover — the user dismissing it, and the anchor's response to a session the
/// reload rule emptied.
public func discardStyleEditor() {
styleEditor = nil
}
// MARK: - Reload
/// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot.
///
/// Called by `BoardStore` on each *successful* reload and nowhere else — a failed reload leaves
/// the snapshot alone, and state over a snapshot that did not change has nothing to re-resolve
/// against.
///
/// **Every item-referencing set is resolved independently.** They are re-grounded against the
/// same snapshot but never against each other: a card leaving the selection must not disturb a
/// drag in flight or a pending cut that also held it, and each set carries its own container.
/// Independence is what makes that a property of the code rather than of the order the lines
/// happen to be in.
///
/// **The placeholder has its own two rules**, because it references a lane rather than items:
///
/// - **Discarded when its anchor lane is gone** — absent from the snapshot. "If the
/// placeholder's lane vanished in the reload, it is discarded" (02-architecture.md); a lane
/// delete is physical now, so gone is the only way a lane goes.
/// - **Discarded as a hand-off** when it is `.awaitingArrival(id)` and `id`'s card is in the
/// snapshot. The real card arrived; the overlay's whole job was covering the gap between the
/// Writer's create and the watcher's round trip, and holding it a moment longer would draw the
/// card twice. The card is looked for anywhere in the snapshot rather than only under the
/// anchor lane — it arrived, and where it landed is the snapshot's business.
///
/// Otherwise the placeholder survives untouched: reloads swap the snapshot *underneath* the
/// overlay, exactly as they do underneath the selection.
///
/// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md ▸
/// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target
/// that is trashed, deleted, or gone discards the editor and its keystrokes silently —
/// "entering the trash is a vanish from the board; nothing is ever written into a vanished
/// folder". A foreign *move between lanes* is deliberately not a vanish — the editor follows
/// the UUID and the commit writes wherever the card now lives — which falls out for free from
/// matching on identity within the board container.
///
/// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md
/// gives every item-referencing piece of transient state: nothing may reference an item the
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
/// optional rather than a set on a side — the rule it obeys is the same one.
///
/// **`selectionAnchor` obeys it too**, in the *selection's* container: a range origin that
/// vanished or crossed containers is gone, and the next ⇧-click acts as a plain click rather
/// than ranging from somewhere that renders nowhere. It deliberately does **not** have to stay
/// *in* the selection — a ⌘-click that toggles the anchor's neighbour out leaves the anchor
/// selected and a range from it is still exactly what the user asked for.
///
/// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls,
/// settled): a member that vanishes or crosses containers leaves the set — so the editor's
/// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload
/// clears the session, which is how "the popover dismisses when it empties" reaches the screen.
/// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns
/// both halves.
///
/// **`searchQuery` is re-applied rather than re-resolved.** It references no item, so no
/// snapshot can invalidate it — but its *results* change with every snapshot, and a reload
/// landing under an active query can hide a selected card as surely as a query change can (an
/// agent editing a title out of the match is the case). So `constrainToSearch(in:)` runs last,
/// on the freshly resolved sets, and the vanish rule and the filter rule compose in the one
/// order that makes sense: gone first, then hidden. `isTrashVisible` is the only member with
/// nothing to say here at all.
///
/// - Parameter commentMatches: the transient comment index' current answer
/// (`CommentSearchIndex.matchingCards`), threaded straight through to the constraint below.
/// Defaulted, because it is the store's fact rather than this container's: everything else here
/// is a rule about *this* state, and a caller with no index — every test of the resolution rules
/// — means "no comment matches", which is what an empty set says.
public func resolve(against snapshot: BoardModel, commentMatches: Set<ItemID> = []) {
selection = selection.resolved(against: snapshot)
dragMembers = dragMembers.resolved(against: snapshot)
pendingCut = pendingCut.resolved(against: snapshot)
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
styleEditor = styleEditor?.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.
let board = ItemContainer.board.ids(in: snapshot)
if let editor = renameEditor, !board.contains(editor.targetID) {
renameEditor = nil
}
if let lane = lastActiveLaneID, !board.contains(lane) {
lastActiveLaneID = nil
}
if selectionAnchor != nil || selectionHead != nil {
// The selection's container, because that is where both cursors live by construction —
// every route that sets either sets the selection to the same container in the same
// call. A vanished or container-crossed cursor is gone, which is the rule every item
// reference here gets. The head then re-derives from the selection's last member on the
// next arrow, which is the same fallback an anchorless ⇧-arrow already uses.
let universe = selection.container == .board ? board : selection.container.ids(in: snapshot)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
}
constrainToSearch(in: snapshot, commentMatches: commentMatches)
}
/// **Hidden cards leave the selection** (04-interactions.md § Search) — the constraint rule with
/// the *filter's* universe supplied, which is the second of the two directions
/// `ItemReferenceSet.constrained(to:)`'s doc comment names.
///
/// Called on exactly two occasions, and they are the two ways the visible universe can narrow:
/// when the **query changes** (`BoardStore.searchQuery`'s setter) and when a **reload lands
/// under an active query** (`resolve(against:)` above, whose last line this is). Both hand it
/// the current snapshot, because the predicate has nothing else to run against.
///
/// **A no-op with no search running**, deliberately: with the filter off the visible universe is
/// the whole board, so constraining to it could only ever be the identity — and stating that as
/// an early return rather than letting it fall out keeps the reload path free of a board-sized
/// set computation nobody needs.
///
/// The anchor and the head obey the same universe rule the reload gives them, for the same
/// reason: a range origin or a navigation cursor sitting on a card the filter hid would range or
/// step from somewhere the user cannot see. Neither has to stay *in* the selection — that
/// asymmetry is `resolve`'s and survives here untouched.
///
/// **The drag, the pending cut and the rename editor are deliberately left alone.** 04 hides
/// cards and says one thing about the consequence — that they leave the *selection*. A cut is
/// staged content waiting for a paste that may well happen after the search clears, and a drag
/// under a live filter is a gesture in flight, not a set the filter has any claim on. The
/// editor's absence is the settled ruling in person: "an open inline rename survives the filter
/// hiding its card … the vanish-discard rule stays reserved for true liveness flips"
/// (`RenameEditor`) — read for the materialized trash, true container crossings — so a foreign
/// edit that stops the renaming card matching drops it from the selection here and leaves the
/// keystrokes exactly where the user left them.
/// **A third occasion joined the two** with the comment index (04 ▸ Search, re-ruled 2026-07-29):
/// a landed sweep can *widen* the visible universe (a card matching only through its comments
/// appears) and, on a re-sweep, narrow it again — so `CommentSearchIndex.onRefine` calls this too.
/// Widening constrains nothing, which is why the seam is worth having anyway: the narrowing half is
/// the one that would otherwise leave a selection on a card nobody can see.
public func constrainToSearch(in snapshot: BoardModel, commentMatches: Set<ItemID> = []) {
let filter = SearchFilter(query: searchQuery, commentMatches: commentMatches)
guard filter.isActive else { return }
let universe = filter.visibleIDs(in: snapshot, container: selection.container)
selection = selection.constrained(to: universe)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
}
/// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.
private func resolvedPlaceholder(against snapshot: BoardModel) -> NewCardPlaceholder? {
guard let placeholder = newCardPlaceholder else { return nil }
guard snapshot.lanes.contains(where: { $0.id == placeholder.laneID }) else { return nil }
if case let .awaitingArrival(id) = placeholder.phase,
snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) {
return nil
}
return placeholder
}
}