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
+121 -10
View File
@@ -12,9 +12,10 @@ import Testing
/// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift
/// from what the loader actually produces.
///
/// **Two homogeneity axes, not three** (resettled 2026-07-28): cards XOR lanes, and board XOR trash.
/// The third card entries XOR lane entries *inside* the trash retired with the lane entries it
/// separated, because lanes are never trashed.
/// **Two homogeneity axes, and the kind one reaches into the trash** (resettled 2026-07-28; lanes
/// rejoined 2026-07-29): cards XOR lanes, and board XOR trash. The trash's rows are cards *and*
/// opaque lane units, so "a trash selection is either cards or lane rows" is the board's own kind
/// rule in a second container rather than a third axis.
///
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
@@ -58,6 +59,23 @@ private func makeTrashBoard() throws -> WriterFixture {
return fixture
}
/// The same trash with **two lane rows interleaved among its cards** (03-board-ui.md § Trash, lanes
/// rejoined 2026-07-29): the rank order is `[card1, lane2, card2, lane3, card3]`, so every
/// kind-boundary claim below has a row of the other kind sitting inside the span it asks about.
@MainActor
private func makeMixedTrashBoard() throws -> WriterFixture {
let fixture = try makeTrashBoard()
try fixture.item(
".trash/\(Ident.lane2)",
"---\nschema: 1\ntitle: Doing\norder: 384\nkind: lane\n---\n"
)
try fixture.item(
".trash/\(Ident.lane3)",
"---\nschema: 1\ntitle: Done\norder: 768\nkind: lane\n---\n"
)
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
@@ -117,7 +135,7 @@ struct SelectionOrderTests {
#expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
}
@Test("The trash's list is its cards, in `order`; its lane list is empty by construction")
@Test("The trash's list is its cards, in `order`; a trash with no lane rows has no lane list")
func trashOrder() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
@@ -125,11 +143,39 @@ struct SelectionOrderTests {
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
// "Cards only. Lanes are never trashed" so there is no list to walk rather than a rule
// saying not to (03-board-ui.md § Trash).
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty)
}
/// **The column's three lists** (03-board-ui.md § Trash; 04-interactions.md The trash): one
/// merged rank order for *navigation*, and two kind-scoped slices of it for *ranging*. The
/// slices are what make a -range skip the other kind without a rule that says so.
@Test("The trash's rows interleave by rank, and each kind's list is a slice of that order")
func trashRowsInterleave() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.trashRows(in: snapshot) == [card1, lane2, card2, lane3, card3])
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == [lane2, lane3])
// One merge, one order: the path resolver batches in exactly the order the column draws.
#expect(ItemPath.resolve([card2, lane2, lane3], in: .trash, snapshot: snapshot)
== [.trashLane(lane2), .trashCard(card2), .trashLane(lane3)])
}
@Test("The trash's kind is derived from the snapshot for both kinds")
func trashKindDerivation() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.kind(of: set([card1, card2], .trash), in: snapshot) == .card)
#expect(SelectionGrammar.kind(of: set([lane2, lane3], .trash), in: snapshot) == .lane)
// A board id claimed on the trash side names nothing there the container is the question.
#expect(SelectionGrammar.kind(of: set([card6], .trash), in: snapshot) == nil)
#expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == nil, "and the reverse")
}
@Test("A selection's kind is derived from the snapshot, and a ghost selection has none")
func kindDerivation() throws {
let fixture = try makeLiveBoard()
@@ -241,6 +287,26 @@ struct CommandClickTests {
#expect(ontoCard.anchor == card1)
}
/// The kind axis inside the trash: "a trash selection is either cards or lane rows,
/// kind-homogeneous like the live board's own grammar" (04-interactions.md The trash).
@Test("⌘-click across the kind boundary inside the trash replaces")
func acrossKindInTheTrashReplaces() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let ontoRow = click(target(lane2, .lane, .trash), .command, selection: set([card1], .trash), anchor: card1, in: snapshot)
#expect(ontoRow.selection == set([lane2], .trash))
let ontoCard = click(target(card1, .card, .trash), .command, selection: set([lane2, lane3], .trash), anchor: lane3, in: snapshot)
#expect(ontoCard.selection == set([card1], .trash))
// Within one kind it still toggles, which is what makes the branch above a rule rather than
// a refusal of in the trash.
let added = click(target(lane3, .lane, .trash), .command, selection: set([lane2], .trash), anchor: lane2, in: snapshot)
#expect(added.selection == set([lane2, lane3], .trash))
}
@Test("⌘-click across the container boundary replaces too")
func acrossContainerReplaces() throws {
let fixture = try makeTrashBoard()
@@ -355,6 +421,30 @@ struct ShiftClickTests {
#expect(outcome.anchor == card1)
}
/// "-click ranges skip rows of the other kind (resurrecting the 2026-07-28 skip-by-kind ruling,
/// mooted when lanes left the trash and back with them)" 04-interactions.md The trash.
@Test("A trash range skips rows of the other kind")
func trashRangeSkipsTheOtherKind() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// The column reads [card1, lane2, card2, lane3, card3]: a card range from the top to the
// bottom takes the three cards and steps over both lane rows.
let cards = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
#expect(cards.selection == set([card1, card2, card3], .trash))
// And a lane-row range takes the rows, skipping the card sitting between them.
let rows = click(target(lane3, .lane, .trash), .shift, selection: set([lane2], .trash), anchor: lane2, in: snapshot)
#expect(rows.selection == set([lane2, lane3], .trash))
// A range aimed across the kinds has no list holding both endpoints, so it degrades to a
// plain click never a mixed selection.
let crossed = click(target(lane3, .lane, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
#expect(crossed.selection == set([lane3], .trash))
#expect(crossed.anchor == lane3)
}
@Test("A range never crosses the container boundary")
func trashRangeStopsAtTheBoundary() throws {
let fixture = try makeTrashBoard()
@@ -393,13 +483,16 @@ struct MarqueeMathTests {
#expect(ids == [card1, card2])
}
/// **There is no kind rule any more.** The tombstone model 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 one line serves both.
@Test("On the trash side the band takes the trash's cards, and stays on its own side")
/// **The kind filter is the whole rule, and in the trash it is load-bearing.** A trashed lane row
/// registers its frame like a card does the arrows navigate by those frames so the band
/// genuinely sweeps over one and must still leave it out: "the rubber band selects cards only
/// (as the board marquee does); lane rows join by click grammar" (04-interactions.md The trash).
@Test("On the trash side the band takes cards only, and stays on its own side")
func trashSideTakesItsOwnCards() {
let targets = [
Self.card(card1, 0, container: .trash),
MarqueeTarget(id: lane2, kind: .lane, container: .trash,
frame: CGRect(x: 0, y: 50, width: 100, height: 30)),
Self.card(card2, 100, container: .trash),
Self.card(card3, 50)
]
@@ -550,6 +643,24 @@ struct SelectAllTests {
#expect(store.selection == set([card1, card2, card3], .trash))
}
/// "Select All is card-scoped everywhere, never lane rows" (04-interactions.md The trash,
/// re-affirmed 2026-07-29): a lane-row selection is a *trash* selection, so the command reads the
/// column and what it selects there is its cards.
@Test("A lane-row selection still selects the trash's cards, never the rows")
func trashBranchIsCardScopedWithLaneRows() throws {
let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
store.select([lane2], in: .trash)
store.selectAll()
#expect(store.selection == set([card1, card2, card3], .trash))
#expect(!store.selection.ids.contains(lane2))
#expect(!store.selection.ids.contains(lane3))
}
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
func trashBranchFallsThrough() throws {
let fixture = try makeTrashBoard()