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
+216
View File
@@ -829,3 +829,219 @@ struct TrashConfirmationsTests {
#expect(confirmations.pending == nil)
}
}
// MARK: - The menu-validation seams
/// The trash's three File-menu rows, validated as predicates rather than as menu items 11-command
/// -nexus.md's inventory, and 03-board-ui.md § Trash's rulings about scope.
///
/// The rows themselves are `TrashCommands`, whose whole body is one `disabled()` per row over these
/// answers; what is worth pinning is the answers. `TrashModel.canDelete`/`canDeleteImmediately` are
/// pinned as pure functions in `TrashModelTests`; this suite covers the two seams that need a live
/// store Empty Trash's scope, and the staging a actually performs.
@MainActor
@Suite("The trash's menu validation")
struct TrashMenuValidationTests {
/// 11-command-nexus.md: "Board window, trash shown and non-empty (whole-trash scope,
/// search-independent)"; 03 § Trash: "menu validation's 'non-empty' reads `.trash/`, not the
/// filtered view".
@Test("Empty Trash needs the column shown and the container non-empty — never the filtered view")
func emptyTrashValidation() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Hidden, the trash "is invisible to every gesture" the command included.
#expect(!store.canEmptyTrash)
store.transient.isTrashVisible = true
#expect(store.canEmptyTrash)
// A query that hides every trash card leaves it enabled: the scope is the container, not
// what is on screen.
store.searchQuery = "zzzz-nothing-matches"
#expect(store.searchFilter.visibleIDs(in: store.snapshot, container: .trash).isEmpty)
#expect(store.canEmptyTrash)
// And the confirmation still names the true count, for the same reason.
let prompt = try #require(TrashModel.emptyTrashPrompt(in: store.snapshot, unrecoverable: true))
#expect(prompt.title == "Permanently delete 2 cards?")
}
@Test("An empty container disables it however visible the column is")
func emptyTrashNeedsCards() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
#expect(!store.canEmptyTrash)
}
/// **One Delete, staged by place** 04-interactions.md The map: "File Delete is the chord's
/// only owner no twin menu items, no shared-equivalent routing". Put Back's twin is retired,
/// so exactly one predicate enables the row and the selection's *container* decides which write
/// it performs.
@Test("Delete is one enabled row on both sides, and the container picks the write")
func deleteIsOneRowStagedByPlace() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([card1], in: .board)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
confirmations.requestDelete(in: store)
// The board staging: a move, no alert, the card now in the trash.
#expect(confirmations.pending == nil)
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
store.select([trashed], in: .trash)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
confirmations.requestDelete(in: store)
// The trash staging: permanent, and behind the alert.
let pending = try #require(confirmations.pending)
#expect(pending.action == .deleteTrashCards([trashed]))
}
/// A context menu names its target by where it was invoked, so the trash row's Delete must purge
/// the clicked card even while a *board* selection stands the case a selection-reading path
/// would silently no-op on (`TrashConfirmations.requestTrashDelete`).
@Test("The trash card's context-menu Delete acts on its own target, not on the selection")
func contextMenuDeleteIgnoresTheSelection() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([card1], in: .board)
confirmations.requestTrashDelete(of: [trashed], in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
#expect(pending.action == .deleteTrashCards([trashed]))
confirmations.confirm(in: store)
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
// The board selection was never the subject and is untouched on disk.
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
}
}
// MARK: - Everything edit-shaped refuses a trash selection
/// 04-interactions.md The trash: "Everything edit-shaped is disabled on trash selections Open
/// Card, Rename, Style". Each of the three answers with one expression used for both its `disabled`
/// state and its action, which is what this suite drives (`BoardStore.openCardTarget`,
/// `.renameTarget`, `.boardStyleTarget`).
@MainActor
@Suite("Edit-shaped commands on a trash selection")
struct TrashGrammarExclusionTests {
@Test("Open Card takes a sole board card and nothing else")
func openRefusesTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], in: .board)
#expect(store.openCardTarget == card1)
// "Trash cards don't open double-click stops at selection; move it out first" (03 § Trash).
store.select([trashed], in: .trash)
#expect(store.openCardTarget == nil)
store.select([trashed, newer], in: .trash)
#expect(store.openCardTarget == nil)
// A lane and a multi-selection refuse too a card window is tied to one card.
store.select([lane1], in: .board)
#expect(store.openCardTarget == nil)
store.select([card1, card2], in: .board)
#expect(store.openCardTarget == nil)
}
@Test("Rename takes a sole board item, card or lane, and never a trash card")
func renameRefusesTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], in: .board)
#expect(store.renameTarget?.id == card1)
#expect(store.renameTarget?.title == "First")
store.select([lane1], in: .board)
#expect(store.renameTarget?.id == lane1)
store.select([trashed], in: .trash)
#expect(store.renameTarget == nil)
}
/// The one with a fall-through worth guarding: an empty selection styles *the board*, so a trash
/// selection has to disable rather than land there "quietly restyling the board because the
/// user had a trashed card selected would be the silent retarget 03 forbids".
@Test("Style… falls through to the board on an empty selection, but a trash selection disables it")
func styleRefusesTheTrashWithoutFallingThrough() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.clearSelection()
#expect(store.boardStyleTarget == .board)
store.select([card1], in: .board)
#expect(store.boardStyleTarget == .items([card1]))
store.select([trashed], in: .trash)
#expect(store.boardStyleTarget == nil)
}
/// The trash's Delete is not edit-shaped and neither is Reveal, so both stay available the two
/// rows 11-command-nexus.md gives a trash card, and no others.
@Test("Delete and Reveal are what a trash selection keeps")
func whatTheTrashKeeps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([trashed], in: .trash)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
#expect(TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot))
#expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL).lastPathComponent } == [Ident.indexless])
}
}
// MARK: - Put Back is gone
/// 03-board-ui.md § Trash: "**No Put Back** (settled) Restoring is an ordinary move out". The
/// retirement is mostly a compile-time fact there is no `putBack` on the store, no restore write on
/// the Writer, and no second -titled menu row so what is left to state at runtime is that a
/// restore registers, phrases and writes as the ordinary move it now is.
@MainActor
@Suite("Put Back is retired")
struct PutBackRetirementTests {
@Test("The undo vocabulary has no restore verb — a restore is a Move")
func noRestoreVerb() {
#expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Restore"))
#expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Put Back"))
// What a drag-out or a X/V restore actually reads as in the Edit menu.
#expect(HistoryPhrase.name(.move, kind: .card) == "Move Card")
}
/// One predicate for both stagings, because there is only one item: the mirror-image pair that
/// existed to make two twins enable exactly one of themselves retired with Put Back.
@Test("One Delete predicate covers both containers")
func oneDeletePredicate() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try loaded(fixture)
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot))
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [trashed], container: .trash), in: snapshot))
}
}