Materialize the trash — store, undo, and the container universe

Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 17:47:56 -04:00
parent 16c10d61c3
commit 53bc71f7fb
53 changed files with 3459 additions and 3655 deletions
+18 -18
View File
@@ -141,14 +141,14 @@ struct OpenCardCommand: View {
return store.isEditingInline || soleSelectedCard != nil
}
/// The sole selected **live card**, or `nil`. A lane, a multi-selection and a tombstoned
/// selection all answer `nil` "everything edit-shaped is disabled on tombstoned selections"
/// (04 The trash), and a card window is tied to one card.
/// 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.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
BoardStore.liveItem(id, in: store.snapshot)?.cardID != nil
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
}
@@ -161,8 +161,8 @@ struct OpenCardCommand: View {
// holds it and re-checked after, because one of those paths is *the lane vanished*.
let lane = placeholder.laneID
let created = store.commitPlaceholder()
if store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
store.select([lane], liveness: .live)
if store.snapshot.lanes.contains(where: { $0.id == lane }) {
store.select([lane], in: .board)
}
if let created { open(created) }
return
@@ -170,7 +170,7 @@ struct OpenCardCommand: View {
if let editor = store.transient.renameEditor {
let target = editor.targetID
let isCard = BoardStore.liveItem(target, in: store.snapshot)?.cardID != nil
let isCard = BoardStore.boardItem(target, in: store.snapshot)?.cardID != nil
store.commitRename()
if isCard { open(target) }
return
@@ -269,8 +269,8 @@ struct MoveLaneCommands: View {
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil }
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first else { return nil }
let lanes = SelectionGrammar.lanes(in: store.snapshot)
// A card id is in no lane order, so this is also the "not a lane" test.
guard let from = lanes.firstIndex(of: id) else { return nil }
let to = from + delta
@@ -382,9 +382,9 @@ struct BoardInfoCommand: View {
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
/// never be renamed at all (04-interactions.md Selection).
///
/// Validation is the sole-selected-live-item rule card or lane, either kind, exactly one. A
/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned
/// selections" (04 The trash), which `ItemReferenceSet`'s liveness side answers directly.
/// Validation is the sole-selected-board-item rule card or lane, either kind, exactly one. A
/// trash selection never enables it: "everything edit-shaped is disabled on trash selections"
/// (04 The trash), which `ItemReferenceSet`'s container answers directly.
struct BoardRenameCommand: View {
@FocusedValue(\.boardStore) private var store
@@ -400,8 +400,8 @@ struct BoardRenameCommand: View {
private var renameTarget: (id: ItemID, title: String?)? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
let item = BoardStore.liveItem(id, in: store.snapshot)
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)
}
@@ -441,7 +441,7 @@ struct BoardStyleCommand: View {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard !selection.isEmpty else { return .board }
guard selection.liveness == .live else { return nil }
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
@@ -498,8 +498,8 @@ struct LaneWidthCommands: View {
private var selectedLanes: [Lane] {
guard let store, store.acceptsBoardMutations else { return [] }
let selection = store.selection
guard selection.liveness == .live, !selection.isEmpty else { return [] }
return store.snapshot.lanes.filter { selection.ids.contains($0.id) && !$0.isDeleted }
guard selection.container == .board, !selection.isEmpty else { return [] }
return store.snapshot.lanes.filter { selection.ids.contains($0.id) }
}
/// `width` is 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads