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
+64 -44
View File
@@ -138,19 +138,7 @@ struct OpenCardCommand: View {
private var isEnabled: Bool {
guard let store, opener?.open != nil else { return false }
return store.isEditingInline || soleSelectedCard != nil
}
/// The sole selected **board card**, or `nil`. A lane, a multi-selection and a trash
/// selection all answer `nil` "everything edit-shaped is disabled on trash selections Open
/// Card, Rename, Style" (04 The trash), and a card window is tied to one card.
private var soleSelectedCard: ItemID? {
guard let store else { return nil }
let selection = store.selection
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
BoardStore.boardItem(id, in: store.snapshot)?.cardID != nil
else { return nil }
return id
return store.isEditingInline || store.openCardTarget != nil
}
private func open() {
@@ -176,7 +164,61 @@ struct OpenCardCommand: View {
return
}
if let card = soleSelectedCard { open(card) }
if let card = store.openCardTarget { open(card) }
}
}
// MARK: - The three edit-shaped targets, as pure predicates
/// **Everything edit-shaped refuses a trash selection** (04-interactions.md The trash: "Everything
/// edit-shaped is disabled on trash selections Open Card, Rename, Style"), and each of the three
/// answers that with one expression used for both its `disabled` state and its action the
/// `newCardTarget` idiom, for its reason: two derivations of a rule are two chances to disagree.
///
/// They live on the store rather than inside the three menu rows so the grammar can be pinned
/// without a menu (`SelectionGrammarTests`) the same reason `TrashModel`'s validation is a pure
/// function of a snapshot and a selection. A view-private predicate is a rule nobody can test.
extension BoardStore {
/// Board Open Card's target: the sole selected **board card**, or `nil`. A lane, a
/// multi-selection and a trash selection all answer `nil` a card window is tied to one card,
/// and trash cards don't open ("double-click stops at selection; move it out first" 03 §
/// Trash).
///
/// Deliberately free of `acceptsBoardMutations`: opening a window is not a mutation, and the
/// item's own mid-edit branch is the focused-editor rule's one carve-out (`OpenCardCommand`).
var openCardTarget: ItemID? {
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
Self.boardItem(id, in: snapshot)?.cardID != nil
else { return nil }
return id
}
/// Board Rename's target: the sole selected board item, card or lane, with the title to seed
/// the editor with or `nil`. A trash selection never enables it, which `ItemReferenceSet`'s
/// container answers directly.
var renameTarget: (id: ItemID, title: String?)? {
guard acceptsBoardMutations else { return nil }
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
let item = Self.boardItem(id, in: snapshot)
else { return nil }
return (id: id, title: item.title)
}
/// Board Style's target: the selected board items, or **the board itself** when nothing is
/// selected "Board window: selected cards or lane; nothing selected = the board".
///
/// **A trash selection disables it rather than falling through to the board**: quietly restyling
/// the board because the user had a trashed card selected would be the silent retarget 03
/// forbids.
var boardStyleTarget: StyleTarget? {
guard acceptsBoardMutations else { return nil }
guard !selection.isEmpty else { return .board }
guard selection.container == .board else { return nil }
// Re-resolved against the snapshot on the way in, so the session starts out holding only
// items that render the same universe its own reload rule will hold it to.
let live = selection.resolved(against: snapshot).ids
return live.isEmpty ? nil : .items(live)
}
}
@@ -187,7 +229,7 @@ struct OpenCardCommand: View {
/// half alongside the lane-width pair).
///
/// **Validation and action read one answer** (`BoardStore.sortPlan`), the width pair's rule: the
/// items disable on everything the design calls inert a lane selection, a tombstoned selection, a
/// items disable on everything the design calls inert a lane selection, a trash selection, a
/// card selection spanning lanes ("cards never change lanes by -arrow") and additionally on a
/// block already at its lane's end, where the only outcome would be a silent no-op.
///
@@ -227,7 +269,7 @@ struct MoveCardCommands: View {
/// multi-lane move has no single unambiguous meaning ("one slot" for a discontiguous pair is not one
/// answer). So the items validate on exactly one selected live lane.
///
/// **Never into the trash** costs nothing: the quasi-lane is not in the live lane order, so a step
/// **Never into the trash** costs nothing: the column is not in the lane order, so a step
/// past the last real lane is simply off the end which is also the disable rule at the walls,
/// following the width stepper's floor style rather than letting the store no-op silently.
///
@@ -391,19 +433,10 @@ struct BoardRenameCommand: View {
var body: some View {
Button("Rename") {
guard let store, let target = renameTarget else { return }
guard let store, let target = store.renameTarget else { return }
store.transient.beginRename(of: target.id, currentTitle: target.title)
}
.disabled(renameTarget == nil)
}
private var renameTarget: (id: ItemID, title: String?)? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
let item = BoardStore.boardItem(id, in: store.snapshot)
else { return nil }
return (id: id, title: item.title)
.disabled(store?.renameTarget == nil)
}
}
@@ -420,32 +453,19 @@ struct BoardRenameCommand: View {
///
/// Validation is `acceptsBoardMutations` the lock and the focused-editor rule, the latter naming
/// Style in its own list of board-scoped commands (04-interactions.md Grammar) plus one rule of
/// its own: **a tombstoned selection disables it rather than falling through to the board.**
/// Everything edit-shaped is disabled on tombstoned selections (04 The trash), and quietly
/// restyling the board because the user had a trashed card selected would be the silent retarget
/// 03 forbids.
/// its own: **a trash selection disables it rather than falling through to the board**
/// (`BoardStore.boardStyleTarget`, where both halves live).
struct BoardStyleCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Style…") {
guard let store, let target = styleTarget else { return }
guard let store, let target = store.boardStyleTarget else { return }
store.transient.beginStyleEditor(for: target)
}
.keyboardShortcut("s", modifiers: [.option, .command])
.disabled(styleTarget == nil)
}
private var styleTarget: StyleTarget? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard !selection.isEmpty else { return .board }
guard selection.container == .board else { return nil }
// Re-resolved against the snapshot on the way in, so the session starts out holding only
// items that render the same universe its own reload rule will hold it to.
let live = selection.resolved(against: store.snapshot).ids
return live.isEmpty ? nil : .items(live)
.disabled(store?.boardStyleTarget == nil)
}
}