Give card windows their own undo stacks and coarsen the close

Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.

2432 tests in 418 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 19:39:50 -04:00
parent c0c741fe62
commit 71664dab02
23 changed files with 668 additions and 124 deletions
+15 -4
View File
@@ -30,14 +30,18 @@ import SwiftUI
/// colour to remember; only `.set` reaches `StyleRecents.record`.
@MainActor
enum StyleCommand {
/// - Parameter undo: the issuing window's own stack, for the one anchor that has one the card
/// window's sidebar (13-native-undo.md Rules two levels). `nil`, which every board-side
/// anchor passes, is the board's stack.
static func apply(
background: StyleChange = .keep,
icon: StyleChange = .keep,
to target: StyleTarget,
in store: BoardStore,
recents: StyleRecents
recents: StyleRecents,
on undo: CardWindowUndo? = nil
) {
store.applyStyle(to: target, background: background, icon: icon)
store.applyStyle(to: target, background: background, icon: icon, on: undo)
if case let .set(value) = background {
recents.record(value)
}
@@ -239,6 +243,13 @@ struct StyleEditorView: View {
/// size and a default argument cannot read one.
var layout: StyleEditorLayout?
/// **Which stack this anchor's writes register on** the card window's own when the editor is
/// mounted in one (`CardStyleSection`), and `nil`, the board's, everywhere else
/// (13-native-undo.md Rules two levels). It sits beside `layout` and arrives the same way
/// the anchor telling the shared component about itself but unlike `layout` it is not geometry:
/// a colour chosen in a card window is one of that window's session gestures.
var undo: CardWindowUndo?
/// The live body metric, read here rather than passed in `CardStyleSection`'s pattern, so
/// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
/// rule).
@@ -293,7 +304,7 @@ struct StyleEditorView: View {
columns: layout.backgroundColumns,
layout: layout,
apply: { change in
StyleCommand.apply(background: change, to: target, in: store, recents: recents)
StyleCommand.apply(background: change, to: target, in: store, recents: recents, on: undo)
}
)
}
@@ -346,7 +357,7 @@ struct StyleEditorView: View {
columns: layout.symbolColumns,
layout: layout,
apply: { change in
StyleCommand.apply(icon: change, to: target, in: store, recents: recents)
StyleCommand.apply(icon: change, to: target, in: store, recents: recents, on: undo)
}
)
if let maximumHeight = layout.symbolGridMaximumHeight {