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
+30 -15
View File
@@ -496,15 +496,22 @@ struct BoardView: View {
store.transient.isTrashVisible
}
/// The trash's cards as the column is showing them the shown trash's cards "participate in
/// the filter exactly like any other card" (03-board-ui.md § Trash), and the arrows walk what is
/// on screen (`TrashLaneView` applies the identical predicate to the identical cards).
/// The trash's **rows** as the column is showing them, both kinds in rank order the shown
/// trash's contents "participate in the filter exactly like any other card" (03-board-ui.md §
/// Trash), and the arrows walk what is on screen (`TrashLaneView` applies the identical predicate
/// to the identical rows).
///
/// **Rows and not cards, because this is navigation** (04-interactions.md The trash: "inside,
/// plain arrows walk every row, card and lane row alike (navigation crosses kinds)", and
/// Grammar gives "the shown non-empty trash its first *entry*"). The kind-scoped lists are
/// the *ranging* grammar's (`SelectionGrammar.order`), which is what stops a -arrow at the kind
/// boundary while a plain one crosses it.
///
/// Read by the three keyboard destinations that reach into the column the arrow origin's
/// order list, /'s container, and 's jump so none of them can walk onto a card the
/// order list, /'s container, and 's jump so none of them can walk onto a row the
/// filter took away.
private var trashCards: [ItemID] {
SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter)
private var trashRows: [ItemID] {
SelectionGrammar.trashRows(in: store.snapshot, filter: store.searchFilter)
}
// MARK: - The drag
@@ -795,11 +802,15 @@ struct BoardView: View {
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
/// having to name a cursor.
///
/// The **trash's list is its cards**, top to bottom there are no lane entries to interleave
/// any more (03-board-ui.md § Trash: "Cards only").
/// The **trash's list is its rows**, top to bottom, both kinds interleaved by rank (lanes
/// rejoined 2026-07-29) and the trash side is **never the lane domain**, whatever the
/// selection's kind: the lane domain is the strip's grammar (/ walk lanes, descends into
/// cards, / move one), and a trashed lane row is a row in a column, not a lane on the board
/// ("The trash lane itself is never selectable *as a lane*" 04 The trash). Its arrows are the
/// spatial ones every row gets.
///
/// Both lists are the **filtered** board (04 § Search: "arrow nav read[s] it"), so the
/// fallback lands on the last *visible* member rather than on a card the query hid.
/// fallback lands on the last *visible* member rather than on a row the query hid.
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
let selection = store.selection
guard !selection.isEmpty else { return nil }
@@ -813,7 +824,7 @@ struct BoardView: View {
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
case .trash:
isLaneDomain = false
list = trashCards
list = trashRows
}
if let head = store.transient.selectionHead, list.contains(head) {
@@ -915,13 +926,16 @@ struct BoardView: View {
return .handled
}
/// **/ jump to the current container's first/last card** the lane's, or the trash
/// trash column's when that is where the cursor is.
/// **/ jump to the current container's first/last row** the lane's cards, or the trash
/// column's rows when that is where the cursor is.
///
/// ** escalates into the lane domain** (04 Grammar, settled "the keyboard's one entry to
/// lane selection"): with the lane's first card already the sole selection, the next selects
/// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane",
/// so a second there is simply inert.
///
/// **In the trash the jump crosses kinds**, like every other navigation there: the ends it names
/// are the column's, so from a card can land on a lane row sitting above it.
private func jumpWithinContainer(
_ direction: NavigationMath.Direction,
from head: ItemID,
@@ -931,7 +945,7 @@ struct BoardView: View {
var lane: ItemID?
switch container {
case .trash:
siblings = trashCards
siblings = trashRows
case .board:
guard let home = store.snapshot.lanes.first(where: { lane in
lane.cards.contains { $0.id == head }
@@ -956,11 +970,12 @@ struct BoardView: View {
/// first card, since is the one keyboard entry to lane selection.
///
/// ** reaches the shown trash** first (04 The trash: "the shown trash is the last container
/// for card navigation, and jumps to it"); an empty or hidden column is not a destination, so
/// for card navigation, and jumps to it" Grammar names the landing as "its first
/// **entry**", which is a row of either kind); an empty or hidden column is not a destination, so
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
if direction == .right, isTrashVisible, let first = trashCards.first {
if direction == .right, isTrashVisible, let first = trashRows.first {
replaceSelection(with: first, in: .trash)
return .handled
}