Lanes delete into the trash — rendering, grammar, drag, clipboard, a11y

Phase 2 completes the lanes-in-trash card. TrashEntry merges the
trash's two kinds by rank in exactly ONE place (ItemPath.resolve's
own merge deleted in favor of it — the three-merge-points finding
shrinks instead of growing). TrashLaneRowView renders the opaque
row — tertiary plate, level-default lane glyph never the lane's own
icon, title + card count, no accents, no expansion; the column badge
counts rendered rows. Selection grammar: kind-homogeneous trash
selections — ranges skip the other kind, ⇧-extension stops at the
kind boundary, plain arrows walk the merged order, marquee stays
card-only (now load-bearing: rows register frames for arrows),
Select All card-scoped; successor-on-purge crosses kinds like
navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop
accepts lane sessions (drop on shown trash deletes), restoreLanes
routes a trash-sourced strip drop as an arrival-ranked within-board
move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque
lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as
the root — a same-board restore looked like an import and would
have reminted the lane it was restoring (pinned by test). A11y:
row = one flattened "title, deleted lane, N cards" element with
Delete/Reveal actions; BoardDiff crossings read lanes as
deleted/restored, shown-trash churn digested at row level. Agent
guide stays v7 — the literal already teaches lanes-trash-by-move
and kind stamping; drift-guard pins those lines. README trash
paragraph notes lanes.

Both schemes 1893 tests / 322 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 17:04:30 -04:00
parent 8014bde7c6
commit f7c8088783
26 changed files with 1825 additions and 176 deletions
+53 -14
View File
@@ -272,9 +272,14 @@ public enum SelectionGrammar {
/// 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 *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.
/// `SearchFilter`. **The trash's lane list is filtered like its cards**, and that asymmetry is
/// the opaque unit's own (03-board-ui.md § Trash: "The row matches the search filter by lane
/// title only") a trashed lane is a row in a column, not a container a query can empty.
///
/// **Both trash lists are kind-narrowed slices of one order** (`BoardModel.trashEntries`,
/// 04-interactions.md The trash: "-click ranges skip rows of the other kind"), which is what
/// makes a range skip the other kind rather than needing a rule that says so: a list is exactly
/// one (container, kind) pair, and the rows in between simply are not in it.
public static func order(
of kind: SelectionKind,
in container: ItemContainer,
@@ -285,7 +290,7 @@ public enum SelectionGrammar {
case (.board, .card): boardCards(in: snapshot, filter: filter)
case (.board, .lane): lanes(in: snapshot)
case (.trash, .card): trashCards(in: snapshot, filter: filter)
case (.trash, .lane): []
case (.trash, .lane): trashLanes(in: snapshot, filter: filter)
}
}
@@ -330,6 +335,27 @@ public enum SelectionGrammar {
snapshot.trash.filter { filter.matches($0) }.map(\.id)
}
/// The trash's **lane rows**, top to bottom the opaque units (03-board-ui.md § Trash, lanes
/// rejoined 2026-07-29), filtered by title alone (`SearchFilter.matches(_ lane:)`).
///
/// Its own list rather than a kind flag on `trashCards` because that is what an order list *is*
/// here: one (container, kind) pair, and the pair is what a -range walks. The rows' positions
/// among the cards are `trashRows`' business.
public static func trashLanes(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
snapshot.trashedLanes.filter { filter.matches($0) }.map(\.id)
}
/// **Every row the trash column shows, both kinds, in rank order** what *navigation* walks
/// (04-interactions.md The trash: "inside, plain arrows walk every row, card and lane row
/// alike (navigation crosses kinds)").
///
/// The deliberate counterpart to the two lists above: extension and ranging are per-kind, so
/// they stop at a kind boundary, while navigation is over the column as drawn and crosses it.
/// One merge for both `BoardModel.trashEntries`.
public static func trashRows(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
snapshot.trashEntries.filter { filter.matches($0) }.map(\.id)
}
// MARK: - The current selection's kind
/// Which level the selection holds, or `nil` when it holds nothing its container renders.
@@ -339,14 +365,15 @@ 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** 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.
/// **The trash answers for both kinds** (04-interactions.md The trash, lanes rejoined
/// 2026-07-29: "a trash selection is either cards or lane rows, kind-homogeneous like the live
/// board's own grammar"), walked in the column's own rank order so a set that somehow held both
/// answers by what is topmost rather than by array iteration order.
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
guard !selection.isEmpty else { return nil }
switch selection.container {
case .trash:
return snapshot.trash.contains { selection.ids.contains($0.id) } ? .card : nil
return snapshot.trashEntries.first { selection.ids.contains($0.id) }?.kind
case .board:
for lane in snapshot.lanes {
if selection.ids.contains(lane.id) { return .lane }
@@ -378,8 +405,9 @@ public enum SelectionGrammar {
///
/// **Both stagings of Delete get one** (04, resettled 2026-07-28 "one Delete vocabulary,
/// staged by place"): `container` says which side the gesture ran on, and the trash walks its own
/// ordered cards exactly as a lane walks its own. The permanent delete is as deliberate an act as
/// the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for.
/// ordered rows exactly as a lane walks its own cards. The permanent delete is as deliberate an
/// act as the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for.
/// **The trash's own successor crosses kinds** an interim, and the branch below says why.
///
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule: "the selection just shrinks"), which is why this is called by
@@ -402,7 +430,16 @@ public enum SelectionGrammar {
let siblings: [ItemID]
switch (container, kind) {
case (.trash, _):
siblings = trashCards(in: snapshot, filter: filter)
// **The siblings are every row, both kinds** the interim answer to an open gap. 04
// The trash settles navigation (plain arrows cross kinds) and extension (-arrows stop
// at the kind boundary) for the trash's two kinds, but says nothing about which row the
// *successor* lands on after a lane row is purged. Until that is ruled, this follows
// navigation rather than extension: the successor is the next row down the column
// whatever its kind, so a purge never strands the selection with nothing selected while
// rows the user can see sit right below it. The conservative direction the alternative
// (kind-scoped siblings) clears the selection whenever the purged row was its kind's
// last, which is a deselect wearing a successor's clothes.
siblings = trashRows(in: snapshot, filter: filter)
case (.board, .lane):
siblings = lanes(in: snapshot)
case (.board, .card):
@@ -453,13 +490,15 @@ public struct MarqueeTarget: Sendable, Equatable {
/// `container` is a parameter rather than something derived from what the rect happens to touch: a
/// band begun on the board and dragged over the trash column selects board cards and nothing else.
/// - **The band never selects lanes** (§ Selection gives it to cards: "click-drag rubber-bands
/// 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.
/// across lanes" across them, not over them). A live lane is never registered as a target at
/// all; a **trashed lane row is**, because the arrows navigate by the same frames
/// (`NavigationMath`) and the band's begin guard reads them too so the kind filter below is
/// load-bearing rather than belt over braces.
///
/// **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.
/// tie between a card and a lane row, because a lane row is not in the answer whatever it sweeps.
public enum MarqueeMath {
/// The ids `rect` sweeps.