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
+44
View File
@@ -53,6 +53,10 @@ let clipboardCard2 = ItemID(rawValue: Ident.card2)
let clipboardCard3 = ItemID(rawValue: Ident.card3)
let clipboardCard4 = ItemID(rawValue: Ident.card4)
/// The trash's **lane row** in the harness below the opaque unit X restores (03-board-ui.md §
/// Trash, lanes rejoined 2026-07-29).
let clipboardTrashedLane = ItemID(rawValue: Ident.lane3)
/// An ordinary card body, for the trash's resident.
func trashResidentItem(order: String, title: String) -> String {
"""
@@ -126,6 +130,21 @@ func makeClipboardHarness() throws -> ClipboardHarness {
try ClipboardHarness(fixture: try makeClipboardBoard())
}
/// The same board with a **lane row in its trash**, carrying one card the clipboard's other trash
/// subject (04-interactions.md The trash: "X works a trashed lane pastes after the anchor
/// lane"). Its own fixture rather than a line in `makeClipboardBoard`, so every suite that counts the
/// trash's cards keeps counting exactly what it did.
@MainActor
func makeTrashedLaneHarness() throws -> ClipboardHarness {
let fixture = try makeClipboardBoard()
try fixture.item(
".trash/\(Ident.lane3)",
"---\nschema: 1\ntitle: Done\norder: 512\nkind: lane\nproject: lanework\n---\nDone body.\n"
)
try fixture.item("\(".trash/\(Ident.lane3)")/\(Ident.indexless)", Item.rich(order: "1024", title: "Freight"))
return try ClipboardHarness(fixture: fixture)
}
// MARK: - The manifest
@Suite("ClipboardManifest")
@@ -627,6 +646,31 @@ struct ClipboardAvailabilityTests {
#expect(harness.clipboard.canCut(from: harness.store))
}
/// The row is a lane on the clipboard's own axis: "the payload kinds never mix because the
/// selection never does" (04-interactions.md The trash), so a cut row writes a **lane** payload
/// recorded in the **trash** container.
@Test("A trashed lane row copies and cuts, and its payload is a lane in the trash")
func trashedLaneRowTakesCopyAndCut() throws {
let harness = try makeTrashedLaneHarness()
defer { harness.tearDown() }
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardTrashedLane], in: .trash)
#expect(harness.clipboard.canCopy(from: harness.store))
#expect(harness.clipboard.canCut(from: harness.store))
harness.clipboard.cut(from: harness.store)
let manifest = try #require(harness.clipboard.payload)
#expect(manifest.kind == .lane)
#expect(manifest.container == .trash)
#expect(manifest.entries.map(\.title) == ["Done"])
// The opaque unit describes itself and not its subtree: the *content* travels through the
// staged folder, which is copied whole.
#expect(manifest.entries.first?.cards.isEmpty == true)
#expect(harness.store.transient.pendingCut
== ItemReferenceSet(ids: [clipboardTrashedLane], container: .trash))
}
@Test("The read-only lock blocks cut but never copy")
func lockBlocksCutOnly() throws {
let harness = try makeClipboardHarness()