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
+25 -15
View File
@@ -25,8 +25,9 @@ struct DropTarget: Equatable, Sendable {
/// That lane's **masonry**: the index is a position in its logical card order
/// (DRAG-REORDER.md § The card masonry).
case lane(ItemID)
/// The **trash column**, which a board card drag proposes into to delete it
/// (04-interactions.md The trash, settled 2026-07-28). The index is always the topmost row.
/// The **trash column**, which a board card or lane drag proposes into to delete it
/// (04-interactions.md The trash, settled 2026-07-28, lanes extended 2026-07-29). The index
/// is always the topmost row.
case trash
}
@@ -47,9 +48,10 @@ struct DropTarget: Equatable, Sendable {
// MARK: - Dropping on the trash
/// **Drop-on-trash deletes** (04-interactions.md The trash, settled 2026-07-28: "the drag becomes
/// the pointer's delete gesture release moves the dragged card(s) into `.trash/`, exactly the delete"),
/// as the two pure facts the gesture is made of (`TrashDropTests`).
/// **Drop-on-trash deletes** (04-interactions.md The trash, settled 2026-07-28, lanes extended
/// 2026-07-29: "Dropping a live card or lane on the shown trash deletes it a lane drag over the
/// shown trash proposes the delete alongside its strip slots"), as the two pure facts the gesture is
/// made of (`TrashDropTests`).
///
/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the
/// **hover** and the **release** cannot disagree about what the trash takes the hazard being a
@@ -69,16 +71,20 @@ enum TrashDrop {
/// Whether the shown trash takes this session the whole of the gate, and every clause is a
/// refusal 04 states in its own words:
///
/// - **Lanes are not deliverable this way** "a lane drag proposes only lane slots". (The strip's
/// slot list has never contained the trash column, so this is belt over braces; it is written down
/// because a guard that is only true by construction is one refactor from being false.)
/// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out;
/// dropping it back where it came from writes nothing.
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
/// - **Both kinds are deliverable** (lanes extended 2026-07-29, retiring "a lane drag proposes
/// only lane slots"): "a lane drag over the shown trash proposes the delete alongside its strip
/// slots", which is the same sentence the card gesture has always had one level down. A session
/// in flight is a session of one of the two kinds, so the clause is `kind != nil` there is no
/// third kind to admit by accident, and a file session never arms this object at all.
/// - **A trash item is already there.** A `.trash` session's vocabulary is restore and copy-out,
/// at either level; dropping it back where it came from writes nothing.
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign item
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
/// design gives no name and no undo story. The card stays where it is.
/// design gives no name and no undo story. It stays where it is.
/// - ** is refused.** Copying into the trash is not a thing the copy grammar promises the
/// original stays exactly where it was, and there is nothing to delete but the original.
/// original stays exactly where it was, and there is nothing to delete but the original. (A
/// within-board lane drag never resolves to `.copy` anyway: is ignored there,
/// `DragLocality.operation`. The clause is the card gesture's and covers the lane for free.)
/// - **Hidden, the trash is invisible to every gesture.** True by construction too (the column is
/// not rendered, so it has no drop region), and stated here so the claim is testable.
/// - **The mutating-gesture rule**, like every other write the pointer can start.
@@ -91,7 +97,7 @@ enum TrashDrop {
acceptsMutations: Bool
) -> Bool {
guard isTrashShown, acceptsMutations else { return false }
guard kind == .cards, container == .board, isWithinBoard else { return false }
guard kind != nil, container == .board, isWithinBoard else { return false }
return operation == .move
}
}
@@ -400,8 +406,12 @@ final class DragSession {
/// Always `TrashDrop.landingIndex`, and read through this accessor anyway so the column asks the
/// same "is it me?" question every other container asks, and gets the position from the same
/// place.
///
/// **Either kind proposes here** (lanes extended 2026-07-29), so there is no kind clause: what
/// makes the proposal legal is `TrashDrop.accepts`, asked at hover and again at release, and a
/// second copy of its answer written here could only ever disagree with it.
func trashProposal(onBoardRooted root: URL) -> Int? {
guard kind == .cards, let proposal, proposal.container == .trash,
guard isActive, let proposal, proposal.container == .trash,
DragLocality.isSameBoard(proposal.boardRoot, root)
else { return nil }
return proposal.index