Give transient UI state an explicit home in the store

TransientBoardState, one per store, holds state by how a reload treats
it: item-referencing sets (selection, drag membership, pending cut)
share one shape and one constraint rule — members must exist in the
current universe — applied in two directions by one primitive, so the
search filter's hidden-cards rule and reload survival are one rule
expressed once; derived state is stored as its inputs only (the query,
never its result set); and the new-card placeholder is a lane-anchored
overlay with no UUID until commit, discarded when its lane vanishes or
tombstones, handed off when the created card's UUID appears. Trash
visibility rides along per-open, never persisted. The decision is
written back into DESIGN/02 § Changes from Kanban — the TBD is closed.

Full suite 352 tests in 64 suites green. Two findings filed.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 21:06:26 -04:00
parent 8285e19497
commit 23e761120f
6 changed files with 712 additions and 107 deletions
+45 -104
View File
@@ -68,88 +68,6 @@ public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConv
}
}
/// Which side of the live/tombstoned boundary something sits on.
///
/// A selection is **homogeneous by liveness** (04-interactions.md § The trash): it never mixes live
/// and tombstoned items, so the side is a property of the selection 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: - Selection
/// The board's selection: a set of UUIDs over the snapshot, plus the liveness side it lives on.
///
/// **UUIDs, never indices or copies of items** (02-architecture.md § Live-reload resilience,
/// "Selection survives reloads by UUID"): 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. Re-resolution against the new snapshot is `resolved(against:)`, and it is a *pure
/// function* on purpose the transient-state container (02-architecture.md § Changes from Kanban)
/// will absorb this type and apply the same rule to drag membership and the pending cut, so the
/// rule must be reusable rather than buried in the store's reload path.
public struct Selection: 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 selected, on the live side the state a board opens in and the state
/// `BoardStore.clearSelection()` returns to.
public static let empty = Selection()
public var isEmpty: Bool { ids.isEmpty }
/// This selection 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 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 selection 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) -> Selection {
guard !ids.isEmpty else { return self }
var survivors: Set<ItemID> = []
survivors.reserveCapacity(ids.count)
for lane in snapshot.lanes {
if ids.contains(lane.id), Liveness(isDeleted: lane.isDeleted) == liveness {
survivors.insert(lane.id)
}
for card in lane.cards where ids.contains(card.id) {
if Liveness(isDeleted: lane.isDeleted || card.isDeleted) == liveness {
survivors.insert(card.id)
}
}
}
return Selection(ids: survivors, liveness: liveness)
}
}
// MARK: - BoardStore
/// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the
@@ -168,18 +86,20 @@ public struct Selection: Sendable, Equatable {
/// 2. **The failure rules.** A failed reload never replaces a good snapshot; an ordinary failure
/// raises the banner condition and leaves editing alone; a failure after a bracketed wholesale
/// operation locks the board read-only; the next success clears both.
/// 3. **Selection across reloads.** Re-resolved by UUID and liveness on every applied snapshot.
/// 3. **Transient state across reloads.** `transient.resolve(against:)` runs on every applied
/// snapshot, re-grounding the selection, the drag, the pending cut, and the new-card placeholder.
///
/// ### What it deliberately does not own
///
/// The `FolderWatcher` itself the registry owns one watcher and one store per board and wires
/// them together (`watcherBrackets`, `handleWatcherEvent(_:)`), so this type can be built and tested
/// without a filesystem stream. The transient-state container is also still to come: selection lives
/// here for now, and the **new-card placeholder** will live beside it a pseudo-card with no disk
/// presence and no UUID, overlaid on the snapshot rather than merged into it (02-architecture.md §
/// Layering, the one named exception to the one-way flow). Nothing here precludes that: `snapshot`
/// is a pure value swap with no identity assumptions, so an overlay can simply be rendered on top of
/// whatever the latest reload produced.
/// without a filesystem stream. And the transient state itself, which lives in its own container
/// (`TransientBoardState`) rather than accreting here as fields: this type knows only *when* to
/// re-resolve it, never what the rules are. That includes the **new-card placeholder** a
/// pseudo-card with no disk presence and no UUID, overlaid on the snapshot rather than merged into
/// it (02-architecture.md § Layering, the one named exception to the one-way flow). Nothing here
/// makes that awkward: `snapshot` is a pure value swap with no identity assumptions, so an overlay
/// is simply rendered on top of whatever the latest reload produced.
@MainActor
@Observable
public final class BoardStore {
@@ -208,8 +128,17 @@ public final class BoardStore {
public var isReadOnly: Bool { readOnlyLock != nil }
/// The board's selection, re-resolved against every snapshot this store applies.
public private(set) var selection: Selection
/// Everything shared across this board's windows that is **not on disk** selection, drag
/// membership, the pending cut, the search query, the new-card placeholder, trash visibility
/// (02-architecture.md § Changes from Kanban).
///
/// **Created with the store and dying with it**, which is what makes its per-open values per-open
/// without any reset logic: closing the board is the reset. `let`, because it is one container
/// for the store's whole life the windows observe *it*, not a slot on this class.
///
/// The store's only involvement is `resolve(against:)` on every successful reload; the rules that
/// call answers live over there.
public let transient: TransientBoardState
/// Where the board is **now**. Follows the folder: a rename or a move absorbed through
/// `relocate(to:)` updates it, so every URL derived from it the Writer's paths, card-window
@@ -356,7 +285,7 @@ public final class BoardStore {
self.loadWarnings = result.warnings
self.reloadFailure = nil
self.readOnlyLock = nil
self.selection = .empty
self.transient = TransientBoardState()
}
// MARK: - Inbound signals
@@ -472,11 +401,14 @@ public final class BoardStore {
// this one did not.
reloadFailure = nil
clearLockIfDisproved(by: origin)
selection = selection.resolved(against: result.model)
// The one place transient state is re-grounded. It goes last, after `snapshot` is the
// new one, because a view woken by the snapshot's change must never observe a selection
// still pointing at the old tree.
transient.resolve(against: result.model)
case let .failure(error):
// `snapshot`, `loadWarnings` and `selection` are untouched: a failed reload never
// replaces a good snapshot, and a selection over a snapshot that did not change has
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
// never replaces a good snapshot, and state over a snapshot that did not change has
// nothing to re-resolve against.
reloadFailure = error
// `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished
@@ -673,21 +605,30 @@ public final class BoardStore {
}
}
// MARK: - Selection
// MARK: - Selection (delegated)
/// Replaces the selection.
///
/// Minimal on purpose the transient-state container will absorb this along with drag state and
/// the pending cut, and give the selection its real grammar (extend, range, successor-on-delete).
/// Deliberately *not* filtered against the snapshot: a caller selects what it is rendering, and
/// `Selection.resolved(against:)` on the next reload is what keeps the set honest over time.
// The three thin pass-throughs to `transient`, and the only ones.
//
// **Conveniences, not a second home.** The selection is the transient state every command site
// touches menu validation, , paste anchoring, Select All and `store.selection` reads better
// at each of them than `store.transient.selection` while meaning exactly the same thing. Nothing
// is stored here: `selection` is computed and the two mutators forward, so there is no second
// copy to go stale. Drag membership, the pending cut, the query and the placeholder get no such
// shortcuts they have one or two call sites each, and a delegate per field would be the
// grab-bag reassembling itself on this class.
/// The board's selection, re-resolved against every snapshot this store applies
/// `TransientBoardState.selection` under a shorter name.
public var selection: ItemReferenceSet { transient.selection }
/// Replaces the selection `TransientBoardState.select(_:liveness:)`, which owns the semantics.
public func select(_ ids: Set<ItemID>, liveness: Liveness) {
selection = Selection(ids: ids, liveness: liveness)
transient.select(ids, liveness: liveness)
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar).
public func clearSelection() {
selection = .empty
transient.clearSelection()
}
// MARK: - Quiescence
+369
View File
@@ -0,0 +1,369 @@
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
/// 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, draftTitle: String = "", phase: Phase = .editing) {
self.laneID = laneID
self.draftTitle = draftTitle
self.phase = phase
}
}
// 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 overlay** `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.
///
/// 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.
///
/// `@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 overlay
/// 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?
// MARK: Per-open values
/// 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
}
// MARK: - The placeholder's lifecycle
/// Opens the inline editor for a new card in `laneID`, replacing any placeholder already open.
///
/// Replacing rather than refusing: two placeholders can never be open at once (one inline editor,
/// 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.
public func beginPlaceholder(inLane laneID: ItemID) {
newCardPlaceholder = NewCardPlaceholder(laneID: 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: - 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.
///
/// `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)
}
/// 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
}
}