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
+74
View File
@@ -416,6 +416,27 @@ public final class BannerCenter {
postLoss(message)
}
/// **The legacy tombstone migration** (01-storage-format.md § Deletion, resettled 2026-07-28:
/// "Legacy `deleted:` keys migrate on load-and-write, never destroy a graceful warning-tone
/// notice"): a board written by an older version carried `deleted:` keys, the app moved the
/// cards those keys named into `.trash/` and returned the lanes live, and this is the row that
/// says so.
///
/// **A loss row for `postRelocatedLooseFiles`' exact reason**, and it is the same shape of event:
/// the app moved the user's folders on its own initiative, on a board it opened rather than on a
/// gesture they made. That must be said out loud, must not evaporate unread, and must not rank
/// as an error, because no action failed. The one nuance worth naming: the *lane* half is a
/// resurrection rather than a removal cards nobody asked to see again may reappear on the
/// board which is exactly the kind of surprise this class exists to announce.
///
/// `cards` and `lanes` are the migrated items' titles, in the order they were written, `nil` for
/// an untitled one "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so
/// the phrasing layer decides what to call it. A migration that migrated nothing posts nothing.
public func postMigratedTombstones(cards: [String?], lanes: [String?]) {
guard let message = Self.migratedTombstonesMessage(cards: cards, lanes: lanes) else { return }
postLoss(message)
}
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
/// folders (04-interactions.md Selection, drag & drop, "Folders are refused at hover"): "a
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
@@ -822,6 +843,59 @@ public final class BannerCenter {
return "Moved '\(name)' into attachments — \(subject)"
}
/// The legacy tombstone migration's line **one folded sentence for both halves**, written in
/// `relocatedLooseFilesMessage`'s voice because it is the same kind of notice: the act first,
/// the subject after an em dash, plurals folded, a sole item named.
///
/// The two clauses are joined rather than posted as two rows, because it is **one migration**:
/// a board opened, its old deletion markers were resolved, and that is one thing that happened
/// to the user's files. Two rows would also mean two dismissals for one event, and would rank a
/// resurrection and a relocation against each other for no reason.
///
/// The shapes, in the relocation's own idiom:
///
/// - **One card**, no lanes: "Moved 'Fix login' to the trash it carried an old deleted marker".
/// - **Several cards**: "Moved 3 cards to the trash they carried old deleted markers".
/// - **One lane**, no cards: "Restored 'Doing' it carried an old deleted marker".
/// - **Both**: "Moved 3 cards to the trash and restored 2 lanes they carried old deleted markers".
///
/// **The tail names the cause once**, and it is the whole explanation the row owes: the user did
/// not delete anything just now, and without the clause the sentence would read as an action
/// they had somehow just taken. The singular/plural of the tail follows the *total*, so the
/// mixed case never has to spell a singular (two clauses carry at least two items).
///
/// `nil` when nothing migrated a migration that migrated nothing is not news.
public nonisolated static func migratedTombstonesMessage(cards: [String?], lanes: [String?]) -> String? {
let total = cards.count + lanes.count
guard total > 0 else { return nil }
var clauses: [String] = []
if !cards.isEmpty {
let subject = cards.count == 1
? sole(cards[0])
: "\(cards.count) cards"
clauses.append("Moved \(subject) to the trash")
}
if !lanes.isEmpty {
let subject = lanes.count == 1
? sole(lanes[0])
: "\(lanes.count) lanes"
clauses.append(clauses.isEmpty ? "Restored \(subject)" : "restored \(subject)")
}
let tail = total == 1
? "it carried an old deleted marker"
: "they carried old deleted markers"
return "\(clauses.joined(separator: " and "))\(tail)"
}
/// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
/// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
/// clause it sits in already says which level it is).
private nonisolated static func sole(_ title: String?) -> String {
guard let title else { return "an untitled item" }
return "'\(title)'"
}
/// The skipped-step line 13-native-undo.md Rules' own example sentence, "Undo skipped 'Fix
/// login' changed outside Lanework", with Z's mirror ("Redo skipped ").
///