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
+76 -7
View File
@@ -73,6 +73,78 @@ extension ItemContainer {
}
}
// MARK: - The trash's two kinds, in one order
/// One row of the trash column a card, or a trashed lane's opaque unit (03-board-ui.md § Trash,
/// re-ruled 2026-07-29: "Lane rows and cards interleave in the one trash column purely by trash
/// rank").
///
/// **It exists so the interleave is written once.** The container's two kinds are two arrays on the
/// snapshot, for the reason `BoardModel.trash` states a trashed card is an ordinary card every
/// card-shaped surface already reads, a trashed lane is an opaque row none of them may but *rank
/// order* is a question about the container as a whole, and it is asked by the column that draws the
/// rows, by the grammar that ranges and navigates over them, and by the path resolver that batches
/// them. Three merges would be three chances to disagree about what "the row below this one" is.
///
/// It carries the values rather than only the ids because the column needs them to draw; every other
/// consumer reads the two derived facts, `id` and `kind`.
public enum TrashEntry: Identifiable, Sendable, Equatable {
case card(Card)
case lane(TrashedLane)
public var id: ItemID {
switch self {
case let .card(card): card.id
case let .lane(lane): lane.id
}
}
/// The rank that decides where this row sits among the others the one field both kinds carry
/// for the same purpose.
public var order: Double {
switch self {
case let .card(card): card.order
case let .lane(lane): lane.order
}
}
/// Which of the board's two selectable levels this row is the kind axis, which reaches into
/// the trash exactly as it governs the live board (04-interactions.md The trash: "a trash
/// selection is either cards or lane rows, kind-homogeneous like the live board's own grammar").
public var kind: SelectionKind {
switch self {
case .card: .card
case .lane: .lane
}
}
/// The folder this row names.
public var path: ItemPath {
switch self {
case let .card(card): .trashCard(card.id)
case let .lane(lane): .trashLane(lane.id)
}
}
}
extension BoardModel {
/// The trash's rows, top to bottom **the container's one order**, both kinds interleaved by
/// rank (03-board-ui.md § Trash).
///
/// The tie-break is the folder name's, `Ranks.sortedForDisplay`'s own, which is what the loader
/// already applied within each kind: two rows minted the same rank by two writers order the same
/// way twice.
public var trashEntries: [TrashEntry] {
Ranks.sortedForDisplay(
trash.map(TrashEntry.card) + trashedLanes.map(TrashEntry.lane),
order: \.order,
name: { $0.id.rawValue }
)
}
}
// MARK: - ItemPath
/// Where an item's folder sits under a board root, as identity components rather than as a URL.
@@ -159,9 +231,9 @@ extension ItemPath {
/// for the same reason).
///
/// **The trash's order interleaves its two kinds by rank** (03-board-ui.md § Trash: "lane rows
/// and cards interleave in the one trash column purely by trash rank"), which is why the two
/// arrays are merged here rather than concatenated: the column's order is the batch's order, and
/// a selection is kind-homogeneous anyway, so the merge costs nothing the one time it matters.
/// and cards interleave in the one trash column purely by trash rank"), which is why the walk is
/// `trashEntries` rather than the two arrays concatenated: the column's order is the batch's
/// order, and it is stated in exactly one place.
///
/// Ids the container does not hold are simply absent, which is every caller's standing posture:
/// a selection the next reload will drop writes nothing rather than being refused.
@@ -181,10 +253,7 @@ extension ItemPath {
}
}
case .trash:
let entries = snapshot.trash.map { (order: $0.order, path: ItemPath.trashCard($0.id)) }
+ snapshot.trashedLanes.map { (order: $0.order, path: ItemPath.trashLane($0.id)) }
for entry in Ranks.sortedForDisplay(entries, order: \.order, name: { $0.path.id.rawValue })
where ids.contains(entry.path.id) {
for entry in snapshot.trashEntries where ids.contains(entry.id) {
result.append(entry.path)
}
}