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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user