Drop a card on the shown trash to delete it

04's ruling makes the drag the pointer's delete gesture: the shown
trash column accepts live same-board card drags, the shadow pinned
topmost — honest, since the trash sorts by deleted newest-first — and
release tombstones through the same write path as Backspace, extracted
so the two gestures cannot drift. DropTarget grew a container case for
the quasi-lane (it has no lane id by construction); lane drags,
cross-board arrivals, option-copies (re-checked at release, the one
input that can flip without a callback), trashed-side payloads, hidden
trash, and the read-only lock all refuse — and a refusal falls through
to the strip retarget, never cancelling the drag. The settle draws the
tombstoned rows in the trash under the cards' own GUIDs, so the echo is
an invisible content swap and nothing winks out for a round trip.
Selection needs no surgery: the reload's resolve rule ejects tombstoned
members as the vanish it is, pinned by a test contrasting both gestures.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 08:12:09 -04:00
parent 1020d9fca4
commit 33bf425f25
6 changed files with 819 additions and 72 deletions
+146 -5
View File
@@ -159,7 +159,7 @@ struct BoardDropContext {
)
guard let slot else { return } // a dead region: hold the current proposal
let index = min(max(0, slot), restingUnits.count)
session.propose(DropTarget(boardRoot: store.rootURL, laneID: nil, index: index))
session.propose(DropTarget(boardRoot: store.rootURL, container: .strip, index: index))
}
/// Where a **card** session would land in `laneID`'s masonry.
@@ -195,7 +195,7 @@ struct BoardDropContext {
current: session.laneProposal(onBoardRooted: store.rootURL, laneID: laneID)
)
guard let slot else { return } // a dead region: hold
session.propose(DropTarget(boardRoot: store.rootURL, laneID: laneID, index: slot))
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(laneID), index: slot))
}
/// The strip's fall-through for card sessions: which lane is under the cursor, analytically.
@@ -227,6 +227,49 @@ struct BoardDropContext {
}
}
// MARK: Retargeting the trash column
/// Whether the trash column would take the session in flight right now `TrashDrop.accepts`
/// with this board's state read in (04-interactions.md The trash, settled 2026-07-28).
///
/// A method rather than a property because it is not free of consequence: the operation is
/// re-resolved against the modifiers *at this instant*, which is also what keeps the badge honest
/// while the cursor sits over the column (`dropProposal`).
func acceptsTrashDrop() -> Bool {
guard let sourceRoot = session.sourceRoot else { return false }
return TrashDrop.accepts(
kind: session.kind,
side: session.side,
isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL),
operation: session.resolveOperation(destinationRoot: store.rootURL),
isTrashShown: store.transient.isTrashVisible,
acceptsMutations: store.acceptsBoardMutations
)
}
/// Where a session over the **trash column** would land: the topmost row, or nowhere.
///
/// **A refusal falls through to the strip's own answer rather than withdrawing the proposal**,
/// which is precisely what this column did before it had a drop target of its own: a cursor over
/// it resolves to no lane, so `retargetCardsFromStrip` holds whatever the shadows already show
/// and `retargetLanes` clamps the terminal slot in front of the quasi-lane. That is the
/// hysteresis contract (DRAG-REORDER.md § Hysteresis) and it is also the honest reading of "the
/// trash proposes nothing for you": the column declines to be a target, it does not cancel the
/// drag the user is still holding. So a lane drag reorders across the column exactly as it always
/// did, and an -copy released over it still lands where its shadows are.
func retargetTrash() {
revalidateProposal()
guard acceptsTrashDrop() else {
retargetFromStrip()
return
}
session.propose(DropTarget(
boardRoot: store.rootURL,
container: .trash,
index: TrashDrop.landingIndex
))
}
// MARK: Retargeting external Finder file sessions
/// Whether `info` is an **external Finder file** session rather than one of ours.
@@ -457,6 +500,11 @@ struct BoardDropContext {
/// The commit is the **destination** store's, one `performWrite` bracket per gesture whatever the
/// 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 tombstone the same write performs, through the same `BoardWriter.deleteItem` 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).
///
/// The write is the first half; the second is the **settle** (`DragSession.commit`). The write is
/// still in flight when this returns, so the session flips from proposing to committed and the
/// slot the shadows were holding starts drawing the dropped cards themselves "at release the
@@ -487,6 +535,15 @@ 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 quasi-lane 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.
guard target.container == .strip else {
cancelDrop()
return false
}
if within {
store.moveLanes(Set(ids), toIndex: target.index)
} else {
@@ -494,6 +551,28 @@ struct BoardDropContext {
}
case .cards:
if target.isTrash {
// **The pointer's delete gesture** (04-interactions.md The trash, settled
// 2026-07-28): "release tombstones the dragged card(s), exactly the tombstone".
//
// The gate is re-asked here rather than trusted from the hover, because the one input
// that can change between them arrives through no callback at all: pressed after
// the proposal stood would otherwise tombstone an original the copy grammar had just
// promised to leave alone. A refusal cancels items return, nothing is written.
guard TrashDrop.accepts(
kind: kind,
side: session.side,
isWithinBoard: within,
operation: operation,
isTrashShown: store.transient.isTrashVisible,
acceptsMutations: store.acceptsBoardMutations
) else {
cancelDrop()
return false
}
store.deleteByDrag(cardIDs: ids)
break
}
guard let laneID = target.laneID else {
cancelDrop()
return false
@@ -773,9 +852,12 @@ struct LaneDropDelegate: DropDelegate {
}
}
/// The strip's drop target the backdrop, the gaps, the outer margin, and the trash column's
/// footprint, which is never a landing spot of its own (04-interactions.md The trash) and so
/// simply falls through to here.
/// The strip's drop target the backdrop, the gaps and the outer margin.
///
/// The trash column used to fall through to here too, being no landing spot of its own; it has its
/// own target now that a live card drag can *delete* into it (`TrashDropDelegate`), and that target
/// hands every session it declines straight back to this logic, so the behaviour over the column is
/// unchanged for everything but the one gesture that is new.
///
/// Lane sessions retarget against the strip's analytic zones; card sessions retarget through the
/// same shared function the lane delegates use, resolving the lane under the cursor analytically
@@ -818,6 +900,65 @@ struct StripDropDelegate: DropDelegate {
}
}
/// The **trash column's** drop target the pointer's delete gesture (04-interactions.md The
/// trash, settled 2026-07-28: "dropping a live card on the shown trash deletes it").
///
/// It exists only while the column does. Hidden, the trash renders nothing, so there is no region
/// here to enter and "the trash stays undroppable-into while hidden, like every gesture" needs no
/// code `TrashDrop.accepts` restates it anyway, because an invariant that is only true by
/// construction is worth being able to point at.
///
/// Like every other delegate it accepts **all three** session types, because single-target dispatch
/// 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;
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
/// import) on tombstoned cards are inert" ( The trash), and the column has nothing else to offer
/// them no lane, no card, nothing to attach to.
struct TrashDropDelegate: DropDelegate {
let context: BoardDropContext
func validateDrop(info: DropInfo) -> Bool {
if context.isFileSession(info) { return context.acceptsFileDrop(info) }
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropEntered(info: DropInfo) { retarget(info) }
func dropUpdated(info: DropInfo) -> DropProposal? {
retarget(info)
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
}
/// See `LaneDropDelegate.dropExited` the file mode's clear, and nothing for our own sessions,
/// whose proposals are meant to hold across ambiguous territory.
func dropExited(info: DropInfo) {
guard context.isFileSession(info) else { return }
context.session.proposeFile(nil)
}
/// A release on the column commits whatever stands the tombstone when the trash is the
/// proposal, and otherwise the proposal the column declined to displace, which is the same
/// "the drop lands where the shadows show" promise as anywhere else.
func performDrop(info: DropInfo) -> Bool {
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
}
private func retarget(_ info: DropInfo) {
if context.isFileSession(info) {
context.session.proposeFile(nil)
return
}
context.retargetTrash()
}
}
/// The window-level fallback, behind every specific target: a release over any in-window region they
/// do not cover (the banner strip, the window's edges) commits the current proposal rather than
/// leaking the session into a cancel-snapback. It retargets nothing the drop lands where the