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
+1 -1
View File
@@ -316,7 +316,7 @@ private final class DuplicateCancellation {
/// to a command the thing 04's contract forbids.
///
/// **The board scope reveals either side of the trash boundary and ignores every lock.** Reveal "is
/// not edit-shaped and stays enabled on tombstoned selections" (04 The trash), and inspection is a
/// not edit-shaped and stays enabled on trash selections" (04 The trash), and inspection is a
/// read, so neither the read-only lock nor the focused-editor rule applies the same posture the
/// trash row's own Reveal takes. A selection whose ids resolve to no folders (one the next reload
/// will drop) disables rather than falling back to the root: revealing the wrong thing is worse
+5 -9
View File
@@ -626,19 +626,15 @@ public final class AppModel {
/// The lane and card counts stamped into the registry at close **live items only** (02
/// § Per-board app state, settled).
///
/// > tombstoned lanes and cards and cards hidden beneath a tombstoned lane don't count; the
/// > row advertises the board's working size, and the trash is an errand, not inventory.
///
/// The nesting is the ancestor walk: a tombstoned lane is skipped whole, so its cards are never
/// reached whatever their own flags say. `Lane.isDeleted`/`Card.isDeleted` are presence-of-key,
/// not validity, so a malformed `deleted:` counts as deleted here exactly as it does everywhere
/// else.
/// > deleted lanes and cards don't count; the row advertises the board's working size, and the
/// > trash is an errand, not inventory.
///
/// **`.trash/` is excluded by construction** (02-architecture.md § Per-board app state,
/// re-grounded 2026-07-28 for the materialized trash): this walks `snapshot.lanes`, and the
/// trash is `snapshot.trash` a sibling container, never a lane so no filter is needed and
/// none could be forgotten. The flag walk above is the retiring half of the same rule, kept
/// while boards written by older versions still carry `deleted:` keys.
/// none could be forgotten. The tombstone era's ancestor walk over `deleted:` flags is gone with
/// the flag; a board an older version wrote counts its unmigrated cards until the migration moves
/// them, which is the safe direction and lasts exactly one write.
///
/// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to
/// discover a counting bug.
+4 -4
View File
@@ -9,10 +9,10 @@ import Foundation
/// board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's
/// fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it
/// describes)".
/// - **Tombstoned items are carried too** (03, settled): "Duplicate is a full fork, trash included
/// dropping them would leave the copy's working tree disagreeing with its own copied HEAD". This
/// file does nothing to achieve that: a tombstone is a `deleted:` key inside a file, so a copy
/// carries it by declining to be clever.
/// - **`.trash/` is carried too** (03, settled, re-grounded 2026-07-28 for the materialized trash):
/// "Duplicate is a full fork, `.trash/` included dropping it would leave the copy's working tree
/// disagreeing with its own copied HEAD". This file does nothing to achieve that: the trash is an
/// ordinary folder under the root, so the tree walk carries it by declining to be clever.
/// - **`.git` comes along** a duplicate of a git board is a fork of its history with only its
/// remote configuration stripped, which is m7's.
/// - Timestamps, unknown keys, strays, `CLAUDE.user.md`, attachments: verbatim, for the same reason.
+1 -1
View File
@@ -16,7 +16,7 @@ import Foundation
// Application Support, each folder loaded through `BoardLoader` `name`/`blurb`/`icon` off the
// template board's own `index.md`, order off its `template.order`, an unloadable user template still
// listed (by folder name, marked unloadable, carrying the loader's specifics) but not instantiable.
// `laneTitles` stops existing at that point: instantiation becomes a tree copy that skips tombstones,
// `laneTitles` stops existing at that point: instantiation becomes a tree copy that skips `.trash/`,
// mints fresh GUIDs, and stamps `created`/`modified` fresh (`BoardWriter.CopyStamps.born`), never
// copying `.git`. The chooser's mini preview renders from the loaded `BoardModel` rather than from
// these strings.
+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.
+6 -5
View File
@@ -29,7 +29,7 @@ extension UTType {
///
/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
/// live-XOR-tombstoned invariants are exactly the ones those two types already carry. Their raw
/// board-XOR-trash invariants are exactly the ones those two types already carry. Their raw
/// spellings are pasteboard API a manifest written before a quit is decoded after the relaunch.
///
/// `entries` are in the order the copy read them flatten order on the live side ("lane `order`,
@@ -83,10 +83,11 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
/// A **lane** entry's cards, index text and all "a lane entry embeds its cards' too,
/// attachment-less". Empty for a card entry.
///
/// **Live cards only**, which is not a shortcut: a lane *copy* strips tombstoned cards
/// (04-interactions.md Clipboard, The trash), and the fallback only ever materializes a
/// copy a cut's move carries the real folder whole and never comes near this array. So the
/// embedded set is exactly what a fallback paste should produce.
/// **Exactly the lane's cards**, which needs no filter: "a lane carries exactly its cards
/// the trash is board-level, so there is nothing lane-nested to strip or carry"
/// (04-interactions.md Drag and drop, resettled 2026-07-28), and the fallback only ever
/// materializes a copy a cut's move carries the real folder whole and never comes near this
/// array. So the embedded set is exactly what a fallback paste should produce.
public var cards: [Card]
/// One card inside a copied lane.
+1 -1
View File
@@ -38,7 +38,7 @@ import os
/// X stages, writes the pasteboard, and arms the source board's `transient.pendingCut` the items
/// dim in place. The cut is **armed** while the pasteboard still holds its `copyID`, the source store
/// is still open, and the pending cut still names something; "deletion voids per item" needs no code
/// here at all, because `TransientBoardState.resolve` already ejects a tombstoned or vanished member
/// here at all, because `TransientBoardState.resolve` already ejects a member that moved to the trash or vanished
/// on every reload, so "paste moves only the survivors" is the reload rule read at paste time.
/// Voiding undims and downgrades the paste to a copy from staging.
///
+1 -1
View File
@@ -42,7 +42,7 @@ struct FutureCommand: View {
///
// m9-templates: copies the open board into the user templates store, close-flushed first exactly as
// Duplicate is (09 Save as Template: "The copy is preceded by the close flush"), `.git` stripped,
// tombstones dropped, a `template:` key stamped. Validation will be `acceptsBoardMutations` plus 09's
// `.trash/` excluded, a `template:` key stamped. Validation will be `acceptsBoardMutations` plus 09's
// one carve-out from the read-only lock live under the unwritable-location state unless an open
// Edit/raw-source session holds unsaved content so it cannot simply borrow
// `DuplicateBoardCommand`'s predicate outright.