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
+22 -19
View File
@@ -19,14 +19,14 @@ import SwiftUI
///
/// - **Lane resize** the right-edge grab strip (above). Deliberately *not* a drag session
/// (DRAG-REORDER.md § Adjacent interaction).
/// - **Drag & drop** cards, lanes and trash rows travel as **system drag sessions**, which is what
/// - **Drag & drop** cards (in either container) and lanes travel as **system drag sessions**, which is what
/// crosses window boundaries, draws the copy badge and gives the full-size replica
/// (`DragSession`, `BoardDrops.swift`, DRAG-REORDER.md). The strip owns the drop geometry
/// registry and the strip-level drop target; the lanes own theirs.
/// - **The rubber band** a drag from any empty surface sweeps a selection (`MarqueeSession`,
/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
/// - **The board's fixed grammar keys** (11-command-nexus.md Fixed grammar keys) the four
/// arrows and their / modes, Return's create/rename dispatch, 's tombstone, Escape's step
/// arrows and their / modes, Return's create/rename dispatch, 's staged delete, Escape's step
/// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything
/// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md
/// Configurable bindings draws between what remaps and what does not.
@@ -35,7 +35,7 @@ import SwiftUI
/// carries those titles, and titles are the remapping mechanism's key, so a second row sharing one
/// is ruled out (04-interactions.md Configurable bindings).
///
/// - **The trash quasi-lane** trailing, one fixed unit, joining and leaving the width division as
/// - **The trash column** trailing, one fixed unit, joining and leaving the width division as
/// View Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
///
/// ### The live search filter
@@ -91,7 +91,7 @@ struct BoardView: View {
@State private var marquee = MarqueeSession()
/// Where every sweepable item is drawn, in strip coordinates. Owned here because the band is
/// the cards and trash rows only *register* into it (`MarqueeTargetRegistry`).
/// the card faces on either side only *register* into it (`MarqueeTargetRegistry`).
@State private var marqueeTargets = MarqueeTargetRegistry()
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
@@ -252,7 +252,7 @@ struct BoardView: View {
case let .lane(lane):
laneSlot(lane, standard: standard)
// "Appear/disappear is scale + fade lanes ~0.9" (03-board-ui.md § Motion).
// A create, a delete and a Put Back all reach the strip as a lane arriving in
// A create, a delete and an undo all reach the strip as a lane arriving in
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
// the reload that carried it (`Motion.reloadAnimates`) a transition with no
// animated transaction around it is simply an appearance.
@@ -267,7 +267,7 @@ struct BoardView: View {
}
}
if isTrashVisible {
// Trailing, always the quasi-lane has no position of its own to lose, which is
// Trailing, always the column has no position of its own to lose, which is
// also why it never appears in the drop proposal's inputs (those are built from
// `boardLanes`) and why the terminal slot clamps in front of it.
TrashLaneView(
@@ -436,7 +436,7 @@ struct BoardView: View {
// MARK: - Trash
/// Whether the trash quasi-lane is on screen transient, board-scoped, hidden on every open
/// Whether the trash column is on screen transient, board-scoped, hidden on every open
/// (03-board-ui.md § Trash Visibility). Read in two places (the unit total and the slot), so it
/// gets a name rather than being spelled twice.
private var isTrashVisible: Bool {
@@ -609,23 +609,26 @@ struct BoardView: View {
return .handled
}
/// **Plain tombstones the live selection** "a plain-key synonym of File Delete, kept
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md The
/// map).
/// **Plain deletes the selection** "a plain-key synonym of File Delete, kept grammar so no
/// second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md The map).
///
/// **Both stagings**, unlike the tombstone era's live-only reading: "Plain performs the same
/// delete as fixed grammar" (04-interactions.md The map, resettled 2026-07-28), and the delete
/// is staged by place inside the store (`BoardStore.deleteSelection`) rather than by two menu
/// items sharing a chord. Put Back the reason the bare key had to stay off the trash is
/// retired with the tombstone model.
/// delete as fixed grammar" (04-interactions.md The map, resettled 2026-07-28) a board
/// selection moves into `.trash/`, a trash selection deletes permanently.
///
/// **Which means the same confirmation, too.** It goes through `TrashConfirmations.requestDelete`
/// rather than straight to `BoardStore.deleteSelection`, because "the same chord deletes
/// permanently confirmation per 03's recoverability rule" and a bare key that skipped the alert
/// the menu item raises would be the one path in the app where one keystroke destroys a card
/// silently. The staging itself is still the store's the alert is the only thing this adds.
///
/// Inert while an inline editor is open, like every grammar key: the field owns as backspace,
/// and a stray one reaching the board mid-edit would delete the item being renamed.
private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
// **Plain , spelled out.** The modified chords belong to the menu (Delete / Put Back),
// (Delete Immediately), (Empty Trash) and AppKit routes a key equivalent to the
// **Plain , spelled out.** The modified chords belong to the menu (Delete),
// (Delete Immediately), (Empty Trash) and AppKit routes a key equivalent to the
// menu before the view sees it. But and are nobody's key equivalent, and a fall-through
// that tombstoned the selection on a mistyped text-editing chord would be exactly the kind of
// that deleted the selection on a mistyped text-editing chord would be exactly the kind of
// accident 04-interactions.md's fixed grammar is careful to avoid.
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
return .ignored
@@ -633,7 +636,7 @@ struct BoardView: View {
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard !selection.isEmpty else { return .ignored }
store.deleteSelection()
confirmations.requestDelete(in: store)
return .handled
}
@@ -860,7 +863,7 @@ struct BoardView: View {
}
/// **/ jump to the current container's first/last card** the lane's, or the trash
/// quasi-lane's when that is where the cursor is.
/// trash column's when that is where the cursor is.
///
/// ** escalates into the lane domain** (04 Grammar, settled "the keyboard's one entry to
/// lane selection"): with the lane's first card already the sole selection, the next selects