Materialize the trash — faces, menus, and grammar

Phase 3 finishes the pivot at the surface. One card face serves two
containers: CardFaceView extracted with a role — board or trash — so
stripe, tint, chip, selection stroke, cut dim, marquee registration,
and drag are shared by construction, the trash side differing only in
its absences: no Open, no rename, no Style, no file-hover highlight,
and a Delete that goes through the confirmation host. The column
rewrote around the lanes' own single-column masonry so drag reflow
reads as positional slides; chrome stays the hatched header, symbol,
and count — 11 gives Empty Trash to the File menu alone. Two real
grammar bugs die here: plain Backspace on a trash selection purged
without the confirmation the menu raises, and the context menu's
Delete resolved against the standing selection, so right-clicking a
trash card under a board selection silently did nothing — it now
stages the clicked set explicitly. Open, Rename, Style, and Empty
Trash validation became testable store seams; the column is one named
accessibility container of ordinary card elements. The tombstone era
is swept: deleteItem, restoreItem, stripTombstonedChildren — dead
since lane copies stopped nesting trash — the restore verb, the
unreachable put-back banner row, and every quasi-lane doc comment.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 18:18:39 -04:00
parent 53bc71f7fb
commit 797d020d01
34 changed files with 1272 additions and 1422 deletions
+9 -9
View File
@@ -128,7 +128,7 @@ struct BoardDropContext {
// MARK: Re-grounding
/// **Rule 2 of the mid-drag re-grounding trio** (04-interactions.md Drag and drop): a proposal
/// whose target lane was tombstoned or vanished in a reload is invalidated tombstoned lanes are
/// whose target lane vanished in a reload is invalidated deleted lanes are
/// never drop targets the shadow withdraws, and no proposal stands until the pointer reaches a
/// live target.
///
@@ -152,7 +152,7 @@ struct BoardDropContext {
/// this strip's standard width and the cursor is the physical mouse, so neither input is a
/// measured frame (03-board-ui.md § Motion).
///
/// **The terminal slot is clamped before the trash.** The quasi-lane consumes one unit while
/// **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
/// 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.
@@ -264,7 +264,7 @@ struct BoardDropContext {
/// **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
/// and `retargetLanes` clamps the terminal slot in front of the trash column. 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
@@ -390,9 +390,9 @@ struct BoardDropContext {
/// dispatch) and the live handler for the strip's own surfaces and unlike a card session, those
/// surfaces genuinely clear the proposal rather than holding it. A cursor over a gap, the outer
/// margin, or **the trash column** is over no lane at all (`LaneLayoutMath.laneIndex` answers
/// `nil` there, since the quasi-lane is absent from the live lane list by construction), so the
/// `nil` there, since the trash column is absent from the lane list by construction), so the
/// highlight withdraws and a release refuses: "Finder file drops (attachment import) on
/// tombstoned cards are inert" and the trash column is never a target (04-interactions.md The
/// trash cards are inert" and the trash column is never a file-drop target (04-interactions.md The
/// trash).
func retargetFileFromStrip(_ info: DropInfo) {
guard acceptsFileDrop(info), let cursor = stripCursor() else {
@@ -490,7 +490,7 @@ struct BoardDropContext {
/// against whatever the last render believed:
///
/// 1. the geometry was re-derived on every sample and the proposal is what it produced;
/// 2. a proposal naming a vanished or tombstoned lane is invalidated, and **release with no valid
/// 2. a proposal naming a vanished lane is invalidated, and **release with no valid
/// proposal cancels** items return, nothing is written;
/// 3. an emptied drag cancels itself, and a partly emptied one drops the survivors.
///
@@ -531,7 +531,7 @@ struct BoardDropContext {
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
// `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.
guard target.container == .strip else {
@@ -758,7 +758,7 @@ struct StripDropDelegate: DropDelegate {
/// 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
/// 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.
struct TrashDropDelegate: DropDelegate {
@@ -783,7 +783,7 @@ struct TrashDropDelegate: DropDelegate {
context.session.proposeFile(nil)
}
/// A release on the column commits whatever stands the tombstone when the trash is the
/// A release on the column commits whatever stands the delete 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 {