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
+43 -9
View File
@@ -52,17 +52,36 @@ final class TrashConfirmations {
/// The staging itself lives on the store (`BoardStore.deleteSelection`), so this is the alert and
/// nothing else the two can never disagree about which write a performs.
func requestDelete(in store: BoardStore) {
guard store.selection.container == .trash, store.purgeIsUnrecoverable else {
guard store.selection.container == .trash else {
store.deleteSelection()
return
}
requestTrashDelete(of: store.selection.ids, in: store)
}
/// The **permanent** half of that staging, aimed at an explicit set the trash card's
/// context-menu Delete (11-command-nexus.md Context menus' Trash cards row).
///
/// Its own entry point because a context menu names its target by where it was invoked, not by
/// what is selected: right-clicking a trash card while a *board* selection stands must purge the
/// clicked card, and a path that re-read `selection` would resolve those ids in the wrong
/// container and silently do nothing.
///
/// Same alert, same rule: it "confirms exactly where the loss is real", so
/// `purgeIsUnrecoverable` decides and where it does not, the purge runs straight through, which
/// is the same shrug Delete Immediately gives on a board that keeps history.
func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteTrashCards(ids)
return
}
guard let prompt = TrashModel.purgePrompt(
for: store.selection.ids,
for: ids,
in: .trash,
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .deleteTrashCards(store.selection.ids))
pending = Pending(prompt: prompt, action: .deleteTrashCards(ids))
}
/// Raises Delete Immediately's alert **or purges outright** where the loss is not real.
@@ -70,6 +89,10 @@ final class TrashConfirmations {
/// The mode check is the one thing that decides between the two, and it lives on the store as a
/// named predicate (`BoardStore.purgeIsUnrecoverable`) so the git milestone changes one
/// expression rather than three call sites.
///
/// Its one caller is File Delete Immediately, which passes the selection's own ids which is
/// what makes reading `store.selection.container` for the prompt correct here and wrong for a
/// context menu (`requestTrashDelete` above exists for exactly that difference).
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids)
@@ -177,15 +200,26 @@ struct TrashCommands: View {
return TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot)
}
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for this row) where
/// "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash: "menu
/// validation's non-empty reads `.trash/`, not the filtered view").
private var canEmptyTrash: Bool {
store?.canEmptyTrash == true
}
}
extension BoardStore {
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for the Empty Trash row)
/// where "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash:
/// "menu validation's non-empty reads `.trash/`, not the filtered view", so a search that hides
/// every trash card leaves the command enabled and its confirmation still names the true count).
///
/// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
/// invisible to every gesture".
private var canEmptyTrash: Bool {
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false }
return !store.snapshot.trash.isEmpty
///
/// On the store rather than private to the menu row so the validation can be pinned without a
/// menu (`TrashModel`'s own reason for being a pure function of a snapshot).
var canEmptyTrash: Bool {
guard acceptsBoardMutations, transient.isTrashVisible else { return false }
return !snapshot.trash.isEmpty
}
}