Files
lanework/Kanban/LiveStore/TransientBoardState.swift
T
rzen b35566e0fe Build lane chrome — title bar, badge, inline rename
The lane title bar becomes real: leading SF Symbol (hand-written names
render leniently, unknown ones fall back to the level default), title
or secondary untitled placeholder, a quiet count badge that counts
exactly the cards the body renders (so the m5 search filter is
followed by construction), and a new-card button. The whole bar is
the reorder drag surface — no grip — with click-vs-movement splitting
select from drag; a pure proposal function maps the drag to an
insertion index and release commits through the Writer's same-parent
degenerate reorder, compacting and retrying when midpoint precision
runs out. Clicking never edits: inline rename is Return on the sole
selected card or Board > Rename for either kind, a third transient
editor beside the placeholder that tracks its target by UUID, commits
on focus loss, discards silently when the target vanishes, and
removes the title key on an empty commit. The new-card placeholder
renders at last — the settled Cmd-N target rule (pure, tested) files
it after the anchor card, at a selected lane's bottom, or into the
last-active lane; Return commits and re-selects the lane, Cmd-Return
also opens the card window, and a failed create discards the overlay.
New Card / New Lane / Rename land in the menus with focused-editor
and read-only validation; rename gets its own WriteOperation case in
the banner vocabulary. 59 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 08:52:24 -04:00

547 lines
30 KiB
Swift

import Foundation
import Observation
// MARK: - Liveness
/// Which side of the live/tombstoned boundary something sits on.
///
/// An item-referencing set is **homogeneous by liveness** (04-interactions.md § The trash): it never
/// mixes live and tombstoned items, so the side is a property of the set as a whole rather than of
/// each member — which is exactly what makes re-resolution across a reload a matching rule rather
/// than a partition.
public enum Liveness: Sendable, Equatable {
case live
case trashed
/// The side an item's own tombstone flag puts it on. `Lane.isDeleted`/`Card.isDeleted` are
/// presence-of-the-key, not validity, so a malformed `deleted:` still reads as trashed — see
/// their doc comments in `BoardModel.swift`.
init(isDeleted: Bool) {
self = isDeleted ? .trashed : .live
}
}
// MARK: - ItemReferenceSet
/// A set of UUIDs over the snapshot plus the liveness side it lives on — **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 on this set's liveness side, 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 (m5 wires that caller).
///
/// 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>
public var liveness: Liveness
public init(ids: Set<ItemID> = [], liveness: Liveness = .live) {
self.ids = ids
self.liveness = liveness
}
/// Nothing referenced, on the live 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, **side 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 on one liveness side
/// (`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 liveness side survives even when the membership does not: an emptied set is still a set
/// on a side, 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), liveness: liveness)
}
/// This set re-grounded on `snapshot`: the members that are still there, **on the same liveness
/// side**, 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 liveness flip is a vanish.** A foreign edit that tombstones a selected live card — or
/// restores a selected tombstoned one — ejects it, keeping 04-interactions.md's
/// homogeneous-by-liveness invariant true across reloads so menu validation never sees a
/// mixed selection. The pending cut inherits the same rule for free (04 ▸ Clipboard: "a cut
/// item that is tombstoned or vanishes externally before paste drops out of the pending
/// cut"), and so does drag membership (04 ▸ Drag and drop's emptied-drag rule).
///
/// The liveness that is matched is **effective — ancestor-walked** (settled): a card counts as
/// trashed if its own flag *or its lane's* says so. Tombstoning a lane therefore ejects its
/// cards from a live set even though their own flags never changed — the card renders nowhere
/// once 03-board-ui.md collapses the lane to a single trash entry, and nothing invisible may
/// stay selected, drag-included, or pending-cut.
public func resolved(against snapshot: BoardModel) -> ItemReferenceSet {
guard !ids.isEmpty else { return self }
return constrained(to: Self.idUniverse(of: snapshot, on: liveness))
}
/// Every id in `snapshot` whose **effective** liveness is `side` — the reload direction's
/// universe, and the only place the ancestor walk lives.
///
/// A lane contributes itself on the side its own flag names, and each of its cards on the side
/// `lane.isDeleted || card.isDeleted` names — the walk being one level deep is the whole of it,
/// because the tree is (01-storage-format.md § Fractal layout: board → lane → card).
static func idUniverse(of snapshot: BoardModel, on side: Liveness) -> Set<ItemID> {
var universe: Set<ItemID> = []
for lane in snapshot.lanes {
if Liveness(isDeleted: lane.isDeleted) == side {
universe.insert(lane.id)
}
for card in lane.cards where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side {
universe.insert(card.id)
}
}
return universe
}
}
// 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.
///
/// 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
/// 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.
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?
/// 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 tombstoned something) 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 quasi-lane 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.
///
/// Minimal on purpose — the selection's real grammar (extend, range, successor-on-delete) lands
/// with the board UI. 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>, liveness: Liveness) {
selection = ItemReferenceSet(ids: ids, liveness: liveness)
}
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar).
public func clearSelection() {
selection = .empty
}
/// 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: - 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.
///
/// - 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) {
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: - 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 liveness
/// side. 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, or effectively
/// tombstoned. A tombstoned lane renders nowhere (03-board-ui.md collapses it to a single
/// trash entry), so its lane "vanished" in every sense 02-architecture.md means: "if the
/// placeholder's lane vanished in the reload, it is discarded".
/// - **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 tombstoned, deleted, or gone discards the editor and its keystrokes silently.
/// A foreign *move* is deliberately not a vanish — the editor follows the UUID and the commit
/// writes wherever the item now lives — which falls out for free from matching on identity
/// rather than on position. Liveness is **effective**, so a card under a lane an agent just
/// tombstoned vanishes with it.
///
/// **`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.
///
/// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references
/// an item, so no snapshot can invalidate either — the query's *results* change with every
/// snapshot, which is precisely why the results are not stored here.
public func resolve(against snapshot: BoardModel) {
selection = selection.resolved(against: snapshot)
dragMembers = dragMembers.resolved(against: snapshot)
pendingCut = pendingCut.resolved(against: snapshot)
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
// One universe computed once and asked three questions — the rename target's liveness, the
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
let live = ItemReferenceSet.idUniverse(of: snapshot, on: .live)
if let editor = renameEditor, !live.contains(editor.targetID) {
renameEditor = nil
}
if let lane = lastActiveLaneID, !live.contains(lane) {
lastActiveLaneID = 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 let anchor = snapshot.lanes.first(where: { $0.id == placeholder.laneID }),
!anchor.isDeleted
else { return nil }
if case let .awaitingArrival(id) = placeholder.phase,
snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) {
return nil
}
return placeholder
}
}