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
+16 -13
View File
@@ -21,7 +21,7 @@ public struct CardPlacement: Equatable {
/// A named decision rather than a scattering of `if`s, because 05-card-window.md Deletion &
/// lifecycle and 02-architecture.md § Live-reload resilience state the same rule from two directions
/// and both have to be true of one piece of code. Making it a value also makes it a *pure* function
/// of a snapshot, which is the only way the tombstoned-lane case gets tested without a window.
/// of a snapshot, which is the only way the deleted-lane case gets tested without a window.
public enum CardWindowFate: Equatable {
case shows(CardPlacement)
case dismisses
@@ -38,10 +38,10 @@ public enum CardWindowFate: Equatable {
/// nothing the second time, which is what makes those two paths safe to both exist.
///
/// **What ending means now**: the Edit buffer's debounce is cancelled and its text written the
/// "window close" third of 05's flush rule, and on a dismissal caused by a tombstone the surgical
/// body write 05 Deletion & lifecycle promises ("a dirty Edit buffer flushes into the tombstoned
/// card's folder before the window dismisses ... so the keystrokes survive Put Back").
/// `BoardStore.writeCardBody` resolves tombstoned cards on purpose for exactly this.
/// "window close" third of 05's flush rule, and on a dismissal caused by a delete the surgical body
/// write 05 Deletion & lifecycle promises ("a dirty Edit buffer flushes into the card's folder at
/// its new `.trash/` location before the window dismisses ... so the keystrokes survive a later
/// restore"). `BoardStore.writeCardBody` resolves trash cards on purpose for exactly this.
///
/// A *failing* close flush is not this object's problem to solve: it is `DirtyBufferGuard`'s modal
/// moment, which the host runs earlier, on `windowShouldClose`, while there is still a window to
@@ -100,10 +100,11 @@ final class CardWindowSession: CardSessionFlushing {
/// ### Its whole identity is `(board, card)`
///
/// Which is why this host is mostly a set of dismissal rules. The window follows its card between
/// lanes for free the key names neither and it dismisses in the three cases where the key stops
/// naming anything: the card is tombstoned, its *lane* is tombstoned (effective liveness is
/// ancestor-walked, 02 § Live-reload resilience), or the card is simply not in this board's snapshot
/// any more, which is what a cross-board move looks like from here.
/// lanes for free the key names neither and it dismisses whenever the key stops naming a card
/// **on the board**: the card moved into `.trash/` ("entering the trash counts as deleted"
/// 05-card-window.md Deletion & lifecycle, resettled 2026-07-28), its *lane* was deleted and took
/// it along, or the card is simply not in this board's snapshot any more, which is what a cross-board
/// move looks like from here.
///
/// ### It can never outlive its board window
///
@@ -164,10 +165,12 @@ struct CardWindowHost: View {
/// Whether a card window keyed on `cardID` still has a card, given this board's snapshot.
///
/// The three dismissal cases collapse into two lines: a card that is not in the snapshot is gone
/// (deleted outright, or moved to another board the board half of the key no longer names it),
/// and a card whose **effective** liveness is trashed renders nowhere, whether the tombstone is
/// its own or its lane's. Only a live card in a live lane keeps its window.
/// **One walk over the lanes is the whole rule** (05-card-window.md Deletion & lifecycle,
/// resettled 2026-07-28 the materialized trash): deletion is a *move*, so a trashed card has
/// physically left its lane and answers `.dismisses` by simply not being found "entering the
/// trash counts as deleted", with no liveness flag to read and no ancestor walk to run. A card
/// whose lane was deleted, one purged outright and one moved to another board all fall out of the
/// same absence. Only a card in one of this board's lanes keeps its window.
///
/// Takes the id as the ref stores it a raw folder name and compares it as an `ItemID`, so two
/// case-spellings of one UUID are one card here exactly as they are everywhere else.