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
+54 -16
View File
@@ -163,9 +163,11 @@ struct BoardDropContext {
/// measured frame (03-board-ui.md § Motion).
///
/// **The terminal slot is clamped before the trash.** The column consumes one unit while
/// shown and is never a landing spot for anything (04-interactions.md The trash: "no move or
/// shown and is never a *position* on the strip (04-interactions.md The trash: "no move or
/// paste ever targets the trash"), so it is absent from the slot list by construction and the end
/// slot's uncapped reach past the last real lane lands *before* it.
/// slot's uncapped reach past the last real lane lands *before* it. The delete a lane drag can
/// propose over the column is not a slot at all it is the column's own target answering
/// (`retargetTrash`), and it names `.trash` rather than an index in this list.
func retargetLanes() {
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
@@ -508,9 +510,10 @@ struct BoardDropContext {
/// set's size (DRAG-REORDER.md § The drop commits).
///
/// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
/// a delete the same write performs, through the same `BoardWriter.deleteCardToTrash` in the same
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
/// from one deleted by keystroke (04-interactions.md The trash, settled 2026-07-28).
/// a delete the same write performs, through the same `BoardWriter.deleteCardToTrash` /
/// `deleteLaneToTrash` in the same bracket (`BoardStore.deleteByDrag`, `deleteLanesByDrag`), so an
/// item deleted by drop is indistinguishable on disk from one deleted by keystroke
/// (04-interactions.md The trash, settled 2026-07-28; lanes extended 2026-07-29).
///
/// The write is the first half; the second is the **committed-overlay hold**
/// (`DragSession.commit`). The write is still in flight when this returns, so the session flips
@@ -539,18 +542,53 @@ struct BoardDropContext {
switch kind {
case .lanes:
// **A lane drag never targets the trash**, and never a masonry either a lane session
// proposes only lane slots (04-interactions.md The trash). True by construction, since
// `retargetLanes` is the only thing that proposes for one and the trash column is absent
// from its slot list; written down because a commit that trusted the container implicitly
// would be the one place the invariant could break silently.
if target.isTrash {
// **The pointer's delete gesture, at the lane level** (04-interactions.md The
// trash, lanes extended 2026-07-29: "a lane drag over the shown trash proposes the
// delete alongside its strip slots"): release moves the lane's folder subtree
// intact into `.trash/`, exactly the delete (`BoardStore.deleteLanesByDrag`).
//
// The gate is re-asked here rather than trusted from the hover, the card branch's
// rule for its reason: the modifiers can change after the proposal stood, and no
// callback reports it. A refusal cancels the lane returns, nothing is written.
guard TrashDrop.accepts(
kind: kind,
container: session.container,
isWithinBoard: within,
operation: operation,
isTrashShown: store.transient.isTrashVisible,
acceptsMutations: store.acceptsBoardMutations
) else {
cancelDrop()
return false
}
store.deleteLanesByDrag(laneIDs: ids)
break
}
// **A lane drag never targets a masonry** a lane session proposes lane slots and the
// trash column, and nothing else (04-interactions.md The trash). True by construction,
// since `retargetLanes` and `retargetTrash` are the only things that propose for one;
// written down because a commit that trusted the container implicitly would be the one
// place the invariant could break silently.
guard target.container == .strip else {
cancelDrop()
return false
}
if within {
store.moveLanes(Set(ids), toIndex: target.index)
// **The trash side is the restore, and it is a different write** (04 The trash:
// "a trashed lane row [dropped] onto its own board's strip is an ordinary move to
// the drop position"): the folder comes *out* of `.trash/`, where `moveLanes`'
// rank arithmetic a permutation of the strip's own lanes has nothing to say
// about it. `restoreLanes` is that move, with the same one-bracket, one-step shape.
if session.container == .trash {
store.restoreLanes(Set(ids), toIndex: target.index)
} else {
store.moveLanes(Set(ids), toIndex: target.index)
}
} else {
// Cross-board, from either container: the ordinary arrival, copy by default and
// move under "Dropped on *another* board it follows the copy default -drag
// forces the true cross-board restore-move" (04 The trash).
store.receiveLanes(folders, operation: operation, at: target.index)
}
@@ -762,11 +800,11 @@ struct StripDropDelegate: DropDelegate {
/// gives the deepest region the session whether it wants it or not (see the note above), and it
/// routes them three ways:
///
/// - **card sessions** through `retargetTrash`, which proposes the topmost row for the ones the trash
/// takes and falls through to the strip's own answer for the rest;
/// - **lane sessions** the same way, and `TrashDrop.accepts` refuses them there, so what actually
/// runs is the strip's `retargetLanes` lane reordering keeps working across the column exactly as
/// it did when the column was a hole in the strip's target;
/// - **card and lane sessions alike** through `retargetTrash`, which proposes the topmost row for the
/// ones the trash takes a live item from this board, unmodified, either kind (lanes extended
/// 2026-07-29) and falls through to the strip's own answer for the rest, so a *trashed* row being
/// dragged out keeps reordering against the strip across the column exactly as it did when the
/// column was a hole in the strip's target;
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
/// import) on trash cards are inert" ( The trash), and the column has nothing else to offer
/// them no lane, no card, nothing to attach to.