Lanes delete into the trash — storage, loader, writer, and undo

Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.

TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.

Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 16:16:57 -04:00
parent 785ef5fe14
commit 8014bde7c6
21 changed files with 1357 additions and 910 deletions
+18 -14
View File
@@ -59,12 +59,13 @@ public enum ClickModifier: Sendable, Equatable {
/// (`SelectionGrammarTests`).
///
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
/// homogeneous on **two** axes now cards XOR lanes (§ Selection) and board XOR trash (§ The
/// trash's "single container rule replacing the old liveness law") and the third, kind-inside-the
/// -trash, retired with the lane entries it separated: "Cards only. Lanes are never trashed".
/// Both surviving axes are a property of what a *click* is allowed to produce, so no outcome below
/// is ever mixed: a modifier that would cross an axis degrades to a replace, which is the only
/// answer that keeps the invariant true without silently dropping what the user asked for.
/// homogeneous on **two** axes cards XOR lanes (§ Selection) and board XOR trash (§ The trash's
/// "single container rule replacing the old liveness law") and since lanes rejoined the trash
/// (2026-07-29) the kind axis simply reaches into the second container too: "a trash selection is
/// either cards or lane rows, kind-homogeneous like the live board's own grammar". Both axes are a
/// property of what a *click* is allowed to produce, so no outcome below is ever mixed: a modifier
/// that would cross an axis degrades to a replace, which is the only answer that keeps the invariant
/// true without silently dropping what the user asked for.
///
/// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to
/// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space,
@@ -270,9 +271,10 @@ public enum SelectionGrammar {
/// order, a lane-index lookup, the successor's container) read exactly as they did before the
/// filter existed; the callers that *are* the board's input grammar pass the store's query.
///
/// **The lane list takes no filter**, because a card query hides no lane see `SearchFilter`.
/// **`(.trash, .lane)` is empty by construction**: "Cards only. Lanes are never trashed"
/// (03-board-ui.md § Trash), so there is no such list to walk rather than a rule saying not to.
/// **The lane list takes no filter**, because a card query hides no *live* lane see
/// `SearchFilter`. **`(.trash, .lane)` is the trash's lane rows** (04-interactions.md The
/// trash, lanes rejoined 2026-07-29): the list is empty until the rows are addressable by the
/// grammar, which is where the pointer and keyboard vocabulary for them lands.
public static func order(
of kind: SelectionKind,
in container: ItemContainer,
@@ -337,8 +339,9 @@ public enum SelectionGrammar {
/// somehow was not. Members that name nothing are ignored, and a set of only such members reads
/// as empty: a selection the next reload will drop must not decide what a click does now.
///
/// **The trash answers `.card` or nothing**, because lanes are never trashed which is why the
/// trash's old kind axis (card entries XOR lane entries) has no code left anywhere.
/// **The trash answers `.card` or nothing** while its lane rows are not yet addressable the
/// kind axis reaches both containers (04-interactions.md The trash), and this is where the
/// second container's answer to it lands.
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
guard !selection.isEmpty else { return nil }
switch selection.container {
@@ -453,9 +456,10 @@ public struct MarqueeTarget: Sendable, Equatable {
/// across lanes" across them, not over them). Lanes are simply never registered as targets, and
/// the filter below keeps the rule true even if one were.
///
/// **There is no kind rule any more.** Under the tombstone model the trash interleaved card rows and
/// lane rows in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind;
/// lanes are never trashed now, so both containers hold cards and the rule is one line for both.
/// **The kind filter is the whole of the rule, in both containers**: "the rubber band stays on the
/// side it started on and selects cards only (as the board marquee does); lane rows join by click
/// grammar" (04-interactions.md The trash, re-affirmed 2026-07-29). A band never has to break a
/// tie between a card and a lane row, because it never sweeps a lane row at all.
public enum MarqueeMath {
/// The ids `rect` sweeps.