From 71664dab029fb2c02f6f697f57a0f3791328cc37 Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 31 Jul 2026 19:39:50 -0400 Subject: [PATCH] Give card windows their own undo stacks and coarsen the close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/AppModel.swift | 9 +- Kanban/App/BoardWindowHost.swift | 2 +- Kanban/App/CardWindowHost.swift | 117 ++++++++++++++++----- Kanban/App/WindowAccessor.swift | 27 +++-- Kanban/Git/GitHistoryProvider.swift | 22 ++-- Kanban/History/BoardStoreHistory.swift | 72 ++++++++++++- Kanban/History/BoardUndoManager.swift | 10 ++ Kanban/History/HistoryPhrase.swift | 22 +++- Kanban/History/HistoryProviding.swift | 78 +++++++++++++- Kanban/History/HistoryStaleness.swift | 25 +++++ Kanban/History/NativeHistoryProvider.swift | 33 +++++- Kanban/LiveStore/BoardStore.swift | 36 ++++++- Kanban/LiveStore/BoardStoreComments.swift | 116 ++++++++++++++++---- Kanban/Storage/AgentGuide.swift | 27 ++++- Kanban/UI/Card/CardComments.swift | 37 +++++-- Kanban/UI/Card/CardSidebarSections.swift | 8 +- Kanban/UI/Card/CardWindowView.swift | 7 +- Kanban/UI/Card/CommentEditSession.swift | 30 +++++- Kanban/UI/StyleEditor.swift | 19 +++- KanbanTests/AgentGuideTests.swift | 16 +++ KanbanTests/CardCommentsTests.swift | 48 ++++++--- KanbanTests/CommentAnnouncementTests.swift | 4 +- KanbanTests/HistoryProviderTests.swift | 27 +++-- 23 files changed, 668 insertions(+), 124 deletions(-) diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 286e682..70a75f1 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -339,9 +339,12 @@ public final class AppModel { /// open-now flag without matching by identity a second time. public let recordID: UUID - /// This board's undo/redo substrate — **one stack per board session, never per window** - /// (13-native-undo.md ▸ Rules). It lives here for the store's reason exactly: the session is - /// what every window over this board shares, and "undo is board-local". + /// This board's undo/redo substrate — **the board half of 13-native-undo.md ▸ Rules' two + /// levels** (re-ruled 2026-07-31): one stack per board session, carrying board-surface + /// gestures and the one coarse step each card window's close registers. A card window's own + /// fine-grained stack is not here and never was the session's (`CardWindowUndo`, held by the + /// window). It lives here for the store's reason exactly: the session is what every window + /// over this board shares, and "undo is board-local". /// /// Which implementation it is, is the tier's answer and nobody else's /// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`. diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 83982b1..8f8db3b 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -276,7 +276,7 @@ struct BoardWindowHost: View { // nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and // the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them // up: `NSWindow` validates and crosses them against exactly this manager. - windowController.boardUndoManager = { appModel.session(for: ref)?.undoManager } + windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager } // The window-title widget (03-board-ui.md § Board popover) — **board windows only**, which // is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index cd38a4b..5c45a40 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -55,6 +55,16 @@ final class CardWindowSession: CardSessionFlushing { /// rather than pretending to have written it. let body: CardBodyEditSession + /// **This window's own undo stack** — 13-native-undo.md ▸ Rules' second level (re-ruled + /// 2026-07-31): "a card window owns its own stack for the session it represents ... and + /// `window.undoManager` answers with it". + /// + /// It lives here for the comments pane's reason exactly: the close owes the board one coarse step + /// folded from this stack, and a stack held only by the view would be gone by the time the fold + /// ran. Every window gesture registers into it through the store's own methods, which take it as + /// a parameter (`CardWindowUndo`). + let undo = CardWindowUndo() + /// The window's comments pane — the thread, the composer's draft buffer, and the one open inline /// edit session (05-card-window.md ▸ The comments column). /// @@ -85,6 +95,16 @@ final class CardWindowSession: CardSessionFlushing { var rawSourceCancel: (@MainActor () -> Void)? var rawSourceIsActive: (@MainActor () -> Bool)? + /// **Where the close registers this session as one board step** — + /// `BoardStore.registerCardSession(_:inCard:retiring:)`, wired by the host for + /// `CardBodyEditSession.save`'s reason: this object is a lifecycle, and it stays testable by + /// having no idea what a board is. + /// + /// It answers whether the deferred `comments/.trash/` purge now has an owner — see `endSession()`. + /// `nil` (a window that never joined its board) means nothing was registered, so the purge is this + /// object's to run, which is also true. + var registerSessionStep: (@MainActor (CardWindowUndo, @escaping @MainActor () -> Void) -> Bool)? + /// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session /// holding keystrokes its file has not got — see `CardSessionFlushing`. /// @@ -164,14 +184,25 @@ final class CardWindowSession: CardSessionFlushing { // session, "never per save tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). The debounced // saves inside the session are ordinary bracketed writes; what makes them one commit is that // the committer's own debounce outlives them and this call is where the session is known to - // be over. + // be over. It is also where the body's *last* fine step joins this window's stack, which is + // why it has to precede the fold below. body.endEditSession() - // **Saves first, purge last** — the inline comment session's flush and the draft's save land - // before `comments/.trash/` is emptied, which is the order that keeps the purge from removing - // a folder a save was about to write into (`CardComments.endSession`). It runs after the - // body's for the same reason it runs at all: this is the one place the window's whole close - // work has a fixed order. + // **The saves, in the order the comments build fixed**: the inline session's flush, then the + // draft's (`CardComments.endSession`). Both may register their own last fine step, so both + // land before the fold. comments.endSession() + // **The coarse close step, and the purge it defers** (13-native-undo.md ▸ Rules ▸ "Window + // close coarsens"; ▸ Interaction with the trash). + // + // This is the one place that knows both halves: the window's stack, which is the session's + // net effect, and the `comments/.trash/` purge, which must not run while a board step's undo + // still restores comments out of it. Registering answers whether the step took the purge on — + // and a board whose substrate keeps no steps has already run it by the time that answer comes + // back, which is how Pro keeps purging at the close flush without a word about tiers here. + let purge: @MainActor () -> Void = { [comments] in comments.purgeTrashNow() } + if registerSessionStep?(undo, purge) != true { + purge() + } } } @@ -415,6 +446,7 @@ struct CardWindowHost: View { attachments: attachments, comments: session.comments, thumbnails: thumbnails, + undo: session.undo, history: cardHistory, fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id), onToggleTask: { offset, checked in @@ -595,12 +627,7 @@ struct CardWindowHost: View { guard let store else { return .vanished } return store.writeCardBody(inCard: cardID, body: text) } - // The session's one undo step, at the Edit→Preview flip (13-native-undo.md ▸ Rules). Weakly, - // `save`'s rule: a session ending after the board window has gone registers nothing rather - // than resurrecting a released store — and the board's stack died with it anyway. - session.body.registerUndo = { [weak store] priorBody, newBody in - store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody) - } + Self.configureUndo(session, store: store, cardID: cardID) bodyPresentation.flushEdits = { [session] in session.body.endEditSession() } @@ -632,7 +659,32 @@ struct CardWindowHost: View { session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() } session.rawSourceCancel = { [rawSource] in rawSource.cancel() } Self.configureAttachments(attachments, store: store, cardID: cardID) - Self.configureComments(session.comments, store: store, cardID: cardID) + Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo) + } + + /// Points this window's session at **its own undo stack** — the three seams the two-level model + /// is made of (13-native-undo.md ▸ Rules, re-ruled 2026-07-31). + /// + /// 1. the body Edit session's one step registers on *this window's* stack, not the board's; + /// 2. the window's Undo/Redo disable under the board's read-only lock, and the stack survives it; + /// 3. the close folds the window's stack into one coarse board step, which then owes the deferred + /// `comments/.trash/` purge. + /// + /// The store is captured **weakly**, `configureSession`'s rule: a session ending after the board + /// window has gone registers nothing rather than resurrecting a released store — and a window with + /// no board keeps the purge itself, which is what the `false` says. + /// + /// `static`, and taking every collaborator as a parameter, for `configureComments`' reason: which + /// stack a gesture lands on is invisible in a running window until it is wrong, and this shape is + /// what lets a test drive the real wiring rather than a re-typed copy of it. + static func configureUndo(_ session: CardWindowSession, store: BoardStore, cardID: ItemID) { + session.body.registerUndo = { [weak store, undo = session.undo] priorBody, newBody in + store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody, on: undo) + } + session.undo.isReadOnly = { [weak store] in store?.isReadOnly ?? false } + session.registerSessionStep = { [weak store] undo, purge in + store?.registerCardSession(undo, inCard: cardID, retiring: purge) ?? false + } } /// Points the comments pane at its card — **the one place every comment gesture learns which card @@ -651,7 +703,17 @@ struct CardWindowHost: View { /// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: the /// target resolution is invisible in a running window until it is wrong, and this shape is what /// lets a test drive the real wiring rather than a re-typed copy of it. - static func configureComments(_ comments: CardComments, store: BoardStore, cardID: ItemID) { + /// + /// - Parameter undo: **this window's stack** — where every comment gesture's fine step lands + /// (13-native-undo.md ▸ Rules ▸ two levels). Not optional and not defaulted: a comments pane + /// only ever exists inside a card window, so a call with no window would be a call with no + /// answer to which stack it meant. + static func configureComments( + _ comments: CardComments, + store: BoardStore, + cardID: ItemID, + on undo: CardWindowUndo + ) { comments.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty } comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) } comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) } @@ -665,11 +727,14 @@ struct CardWindowHost: View { store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters)) } comments.deleteComment = { [weak store] id in - store?.deleteComment(id, inCard: cardID) ?? false + store?.deleteComment(id, inCard: cardID, on: undo) ?? false } comments.editComment = { [weak store] id, body in store?.editComment(id, inCard: cardID, body: body) ?? false } + comments.registerCommentEdit = { [weak store] id, prior, new in + store?.registerCommentEdit(id, inCard: cardID, priorBody: prior, newBody: new, on: undo) + } comments.importAttachments = { [weak store] urls, target in store?.importCommentAttachments(urls, inCard: cardID, target: target) } @@ -680,7 +745,7 @@ struct CardWindowHost: View { store?.saveCommentDraft(inCard: cardID, body: text) } comments.composer.post = { [weak store] in - store?.postComment(inCard: cardID) + store?.postComment(inCard: cardID, on: undo) } // The announcer's gate: which of this thread's changes the app itself wrote, consumed once per // reload (10-accessibility.md — "app-mediated echoes never announce", per comment). A store @@ -788,14 +853,18 @@ struct CardWindowHost: View { CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments) ) - // **The board's stack, not one of this window's own** (13-native-undo.md ▸ Rules: "not - // per-window: every window over a board (board window, its card windows) shares the store - // and shares the stack"). Same closure shape as the board window's, and deliberately the - // same object: ⌘Z with a card window in front crosses the board step the user last made, - // wherever they made it. The card's *text* surfaces are untouched by this — the body editor - // and the raw-source editor each vend their own manager to the responder chain, which is - // what keeps typing undo out of the board's stack (06-history-undo.md ▸ Undo routing). - windowController.boardUndoManager = { appModel.session(for: ref.board)?.undoManager } + // **This window's own stack** (13-native-undo.md ▸ Rules ▸ two levels, re-ruled 2026-07-31 — + // superseding the shared-stack wiring): "a card window owns its own stack for the session it + // represents ... and `window.undoManager` answers with it (standard per-window AppKit + // scoping)". ⌘Z with this window in front walks the gestures made *here*, newest first, and + // when they run out it beeps — "no fall-through: exhausting the window's stack ... never + // reaches board history" (06-history-undo.md ▸ Undo routing). What board history gets is the + // one coarse step this session registers when the window closes. + // + // The card's *text* surfaces are untouched by this — the body editor and the raw-source + // editor each vend their own manager to the responder chain, which is what keeps typing undo + // above either stack (06 ▸ Undo routing, unchanged). + windowController.windowUndoManager = { [session] in session.undo.manager } windowController.onAttach = { window in if let recordID, diff --git a/Kanban/App/WindowAccessor.swift b/Kanban/App/WindowAccessor.swift index 10c0a36..59dbe7e 100644 --- a/Kanban/App/WindowAccessor.swift +++ b/Kanban/App/WindowAccessor.swift @@ -56,19 +56,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate { /// window that has nothing to flush. var onCloseRequested: (() -> Void)? - /// This window's board undo stack, asked for afresh every time AppKit wants it — the board - /// window's and its card windows' shared answer (13-native-undo.md ▸ Rules: "one stack per - /// board, owned by the board session ... `window.undoManager` for board surfaces returns the - /// session's manager"). + /// **The stack this window's ⌘Z crosses**, asked for afresh every time AppKit wants it — + /// 13-native-undo.md ▸ Rules' two levels (re-ruled 2026-07-31): a **board** window answers with + /// its session's stack, and a **card** window with its own, "standard per-window AppKit scoping". /// - /// A closure rather than a stored manager for two reasons: the session does not exist yet when - /// the window attaches, and it stops existing at teardown while the window is still closing — - /// answering `nil` then is what keeps a torn-down board's stack from being reachable through a - /// window that outlived it by a run-loop turn. + /// A closure rather than a stored manager for two reasons: a board window's session does not + /// exist yet when the window attaches, and it stops existing at teardown while the window is + /// still closing — answering `nil` then is what keeps a torn-down board's stack from being + /// reachable through a window that outlived it by a run-loop turn. /// - /// `nil` on every window that is not showing a board (welcome, the bootstrap, the template + /// `nil` on every window that has no stack of its own (welcome, the bootstrap, the template /// chooser), which `BoardUndoRouting` reads as "the platform default". - var boardUndoManager: (() -> UndoManager?)? + var windowUndoManager: (() -> UndoManager?)? /// The text manager this window hands back while a field editor holds the keyboard, and the one /// it hands back when there is no board — 06-history-undo.md ▸ Undo routing, via @@ -215,16 +214,16 @@ final class HostedWindowController: NSObject, NSWindowDelegate { } /// The window-level half of 06-history-undo.md ▸ Undo routing (see `BoardUndoRouting`, which - /// owns the rule and the reasoning): the board's stack when the keyboard is on the board, a + /// owns the rule and the reasoning): this window's stack when the keyboard is on the content, a /// text manager of this window's own while a field editor has it. /// /// **Answered here rather than forwarded**, unlike the proxy's other selectors, on the one - /// condition that this window has a board: `responds(to:)` reports this method whatever the + /// condition that this window has a stack: `responds(to:)` reports this method whatever the /// previous delegate does, so a `nil` return would leave a window with *no* undo manager at all - /// rather than the one AppKit creates for a delegate that stays silent. A window with no board + /// rather than the one AppKit creates for a delegate that stays silent. A window with no stack /// still defers to SwiftUI's delegate if it has an opinion. func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { - let board = boardUndoManager?() + let board = windowUndoManager?() if board == nil, let previousDelegate, previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))), let inherited = previousDelegate.windowWillReturnUndoManager?(window) { diff --git a/Kanban/Git/GitHistoryProvider.swift b/Kanban/Git/GitHistoryProvider.swift index e9718b7..8585879 100644 --- a/Kanban/Git/GitHistoryProvider.swift +++ b/Kanban/Git/GitHistoryProvider.swift @@ -247,12 +247,22 @@ public final class GitHistoryProvider: HistoryProviding { // MARK: - HistoryProviding - /// **Deliberately nothing.** On a git board an undo step is a commit, and the Writer boundary's - /// inverse operations are the *free* tier's substrate (13-native-undo.md). `BoardStore` registers - /// against whatever provider the session bound, and this one has a repository to read instead — - /// so the registrations arrive and are dropped, which is exactly what "the commit trail itself is - /// the substrate" (14 ▸ C1) means in code. - public func register(_ step: HistoryStep) {} + /// **Deliberately nothing — except the one thing a dropped step is owed.** On a git board an undo + /// step is a commit, and the Writer boundary's inverse operations are the *free* tier's substrate + /// (13-native-undo.md). `BoardStore` registers against whatever provider the session bound, and + /// this one has a repository to read instead — so the registrations arrive and are dropped, which + /// is exactly what "the commit trail itself is the substrate" (14 ▸ C1) means in code. + /// + /// Dropping a step means **retiring** it (`HistoryStep.Retirement`), and that is what keeps the + /// tier split in 13's purge rule structural rather than conditional: "on Pro the substrate is + /// history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge + /// rides the close flush there as before" (13 ▸ Interaction with the trash). A card window's close + /// step registered here is retired on arrival, so its deferred `comments/.trash/` purge runs + /// immediately — at the close flush, exactly where it ran before this milestone — with no call + /// site anywhere asking which substrate it is talking to. + public func register(_ step: HistoryStep) { + step.retirement?.run() + } public var canUndo: Bool { guard !isCrossing, isHeld?() != true else { return false } diff --git a/Kanban/History/BoardStoreHistory.swift b/Kanban/History/BoardStoreHistory.swift index 9ee53e8..eb50a9a 100644 --- a/Kanban/History/BoardStoreHistory.swift +++ b/Kanban/History/BoardStoreHistory.swift @@ -74,22 +74,90 @@ extension BoardStore { func registerStep( _ name: String, subject: String? = nil, + on window: CardWindowUndo? = nil, + retiring: (@MainActor () -> Void)? = nil, undoExpects: [HistoryExpectation], redoExpects: [HistoryExpectation], undo: @escaping @MainActor (BoardStore) throws -> Void, redo: @escaping @MainActor (BoardStore) throws -> Void ) { - guard let history else { return } + let retirement = retiring.map(HistoryStep.Retirement.init) + // **The routing decision, and the whole of it** (13 ▸ Rules ▸ two levels): a gesture issued + // in a card window lands on that window's stack, and everything else on the board's. It is a + // parameter rather than ambient state on purpose — the issuing surface is knowledge only the + // call site has, and a store-wide "current window" would be a second answer able to be wrong + // for exactly one gesture (the board styling a card whose window happens to be open). + guard let sink: any HistoryProviding = window?.stack ?? history else { + // No substrate at all — a repo-nested board (06 ▸ Rules), or a store with no session. + // Nothing records the step, so nothing can ever retire it: the work is owed now. + retirement?.run() + return + } let named = subject ?? name - history.register(HistoryStep( + let step = HistoryStep( name: name, + retirement: retirement, undo: { [weak self] direction in BoardStore.cross(self, direction, named, undoExpects, undo) }, redo: { [weak self] direction in BoardStore.cross(self, direction, named, redoExpects, redo) } + ) + // The raw halves, kept beside the window's stack for the close fold — before the register, so + // a fold taken from inside a registration's own side effects can never see a step it has no + // write for (`CardWindowUndo.netEffect`). + window?.record(step.id, CardWindowUndo.Write( + undoExpects: undoExpects, + redoExpects: redoExpects, + undo: undo, + redo: redo )) + sink.register(step) + } + + // MARK: The window close's coarse step + + /// **Registers one card-window session as one board step** — 13-native-undo.md ▸ Rules' window + /// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card + /// ⟨title⟩', values-based, whose undo restores the card subtree to its session-start state — + /// deleted comments included — and whose redo reapplies the net effect"). + /// + /// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about + /// whether there is a board to register it on is this method's: + /// + /// - **A vanished card registers nothing.** 05-card-window.md ▸ Deletion & lifecycle dismisses the + /// window when its card leaves the board — into the trash, with its lane, to another board — and + /// the card's own departure is already a board step of its own (`deleteCard`). A session step + /// naming folders that have moved could only be a step that skips, so the honest answer is not + /// to register one: the window's fine stack dies with the window, as 13's session-only rule has + /// it. (A trashed card keeps its `comments/.trash/` too — "a trashed card carries its + /// `comments/`", 01-storage-format.md — and the residue sweeps at the next open of that card.) + /// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`. + /// + /// - Parameter retiring: the deferred `comments/.trash/` purge (13 ▸ Interaction with the trash). + /// - Returns: whether the purge now has an owner — a live step holding it until the step leaves + /// the board stack, or a substrate that declined to keep the step and therefore ran it already + /// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still + /// owes the purge. + @discardableResult + func registerCardSession( + _ window: CardWindowUndo, + inCard cardID: ItemID, + retiring: @escaping @MainActor () -> Void + ) -> Bool { + guard let item = Self.boardItem(cardID, in: snapshot), item.cardID != nil else { return false } + guard let net = window.netEffect() else { return false } + registerStep( + HistoryPhrase.cardSession, + subject: item.title, + retiring: retiring, + undoExpects: net.undoExpects, + redoExpects: net.redoExpects, + undo: net.undo, + redo: net.redo + ) + return true } /// Validates one side of a step against disk, then runs it as an ordinary bracketed write. diff --git a/Kanban/History/BoardUndoManager.swift b/Kanban/History/BoardUndoManager.swift index 42c3fa2..b88c6d6 100644 --- a/Kanban/History/BoardUndoManager.swift +++ b/Kanban/History/BoardUndoManager.swift @@ -21,6 +21,11 @@ import AppKit /// ⌘Z, the toolbar pair) by binding its provider and changing nothing here, which is what "a user /// subscribing (or lapsing) relearns nothing" (12) has to mean in code. /// +/// **And one per open card window**, over that window's own stack (13 ▸ Rules ▸ two levels, re-ruled +/// 2026-07-31 — `CardWindowUndo.manager`): the second level needs precisely the same translation, so +/// it gets the same object rather than a second one shaped like it. The name is now half-right and +/// kept anyway: renaming it would say the two levels are two mechanisms, and they are not. +/// /// ### It deliberately keeps its inherited stack empty /// /// Every question is overridden, so anything that *did* register into this manager by accident (a @@ -33,6 +38,11 @@ import AppKit /// not empty the board's stack (13-native-undo.md ▸ Rules — the stack belongs to the *session*, and /// its one clearing point is that session's teardown). /// +/// That non-forwarding is what the **close fold** rests on, now that a card window has a stack of its +/// own: AppKit empties a closing window's undo manager, and the session's net effect is read off that +/// window's steps a moment later, from `onDisappear` (`CardWindowSession.endSession`). A forwarded +/// clear would silently make every close a no-net-change close. +/// /// ### The read-only lock disables Undo and Redo here /// /// "Every read-only lock (vanished root, failed reload after wholesale ops, unwritable location) diff --git a/Kanban/History/HistoryPhrase.swift b/Kanban/History/HistoryPhrase.swift index c398bbb..74fa3e2 100644 --- a/Kanban/History/HistoryPhrase.swift +++ b/Kanban/History/HistoryPhrase.swift @@ -64,9 +64,12 @@ public enum HistoryPhrase { case card case lane case board - /// One comment. Only `.delete` reaches it: posting has its own phrase (`comment`, below), and - /// the draft save, the inline edit and the trash purge register no step at all - /// (13-native-undo.md — no byte capture in any tier, and the permanent-delete posture). + /// One comment. `.delete` and `.edit` reach it — the inline edit session joined the + /// vocabulary with the window stack (re-ruled 2026-07-31: "every gesture issued in that + /// window — comment post/delete/**edit**, body Edit sessions ... registers there at fine + /// grain"), registered at its own commit point exactly as the body's session is. Posting has + /// its own phrase (`comment`, below); the draft save and the trash purge still register no + /// step at all (13-native-undo.md — the permanent-delete posture). case comment var singular: String { @@ -99,6 +102,19 @@ public enum HistoryPhrase { /// login'") is dropped exactly as every other phrase drops it — a menu row has to stay short. public static let comment = "Comment" + // MARK: The card-window session + + /// **The coarse close step's phrase** — one card window's whole session, as the board's stack sees + /// it: 13-native-undo.md ▸ Rules names it "Edit card 'Fix login'", so the menu title is the same + /// verb and noun every other card edit uses, dropping the item clause a menu row has no space for. + /// + /// Deliberately *not* a new verb. The session is an edit to a card — the fine-grained things + /// inside it (a comment posted, a colour chosen, a paragraph rewritten) are the window stack's + /// story, and a board-level row that tried to enumerate them would be the "Mixed update" problem + /// in a menu (06-history-undo.md ▸ Commit messages). It reads identically to a body-edit step + /// because on the board's stack it *is* the card's edit. + public static let cardSession = name(.edit, kind: .card) + // MARK: Composition /// The phrase for one gesture: `"Move Card"`, `"Move 3 Cards"`, `"Restyle Board"`. diff --git a/Kanban/History/HistoryProviding.swift b/Kanban/History/HistoryProviding.swift index 76ee993..d2e933b 100644 --- a/Kanban/History/HistoryProviding.swift +++ b/Kanban/History/HistoryProviding.swift @@ -105,9 +105,55 @@ public enum HistoryStepOutcome: Equatable, Sendable { /// and the skip sentence has to name the keystroke rather than the half (see `HistoryDirection`). public struct HistoryStep { + /// **What a step owes the world once it has left history for good** — run exactly once, by + /// whichever provider drops it. + /// + /// It exists for one consumer, and the design names it precisely: the window-close coarse step + /// defers a card's `comments/.trash/` purge, because "the coarse close step's undo restores + /// deleted comments, so their backing lives as long as the step does — the purge runs when the + /// coarse step leaves the board stack (undone-and-superseded, dropped off the end, or gone + /// stale) or the board session ends" (13-native-undo.md ▸ Interaction with the trash, re-ruled + /// 2026-07-31). A step is the only object that knows all three of those moments, and it knows + /// none of them itself — so the *provider* reports them, through this. + /// + /// **A reference type inside a value type, deliberately.** `reversed` copies the step every time + /// it crosses, and the two copies must not each run the work: sharing one latch is what makes + /// "exactly once" a property of the object rather than of the bookkeeping around it. + /// + /// A step with no retirement — every board gesture — carries `nil` and costs nothing. + @MainActor + public final class Retirement { + + private var work: (@MainActor () -> Void)? + + public init(_ work: @escaping @MainActor () -> Void) { + self.work = work + } + + /// Runs the work, once. Later calls do nothing, which is what lets every provider report a + /// drop without first checking whether another one already has. + public func run() { + let work = self.work + self.work = nil + work?() + } + + /// Whether the work is still owed — the assertion a test makes instead of watching disk. + public var isOwed: Bool { work != nil } + } + + /// This step's identity, stable across `reversed` — which is what makes it usable as a key into + /// state held *beside* a stack (`CardWindowUndo`, whose fold has to find the raw write behind a + /// step that may have crossed any number of times). + public let id: UUID + /// The menu phrase, unprefixed — see the type's note. public let name: String + /// What this step owes when it leaves history — see `Retirement`. `nil` for every step that owes + /// nothing, which is all but the card-window close step. + public let retirement: Retirement? + /// Walks the board back across this step. Registered at the Writer boundary as the *inverse* of /// the write that just landed. public let undo: @MainActor (HistoryDirection) -> HistoryStepOutcome @@ -117,11 +163,15 @@ public struct HistoryStep { public let redo: @MainActor (HistoryDirection) -> HistoryStepOutcome public init( + id: UUID = UUID(), name: String, + retirement: Retirement? = nil, undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome, redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome ) { + self.id = id self.name = name + self.retirement = retirement self.undo = undo self.redo = redo } @@ -129,8 +179,12 @@ public struct HistoryStep { /// The same step read backwards — what a provider puts on the opposite stack once this one has /// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move /// Card": the phrase names the *gesture*, not the direction. + /// + /// **The identity and the retirement travel with it**, both for the same reason: a step that has + /// crossed is the same step, so the fold that keyed state on it must still find that state, and + /// the purge it defers must still be owed exactly once. public var reversed: HistoryStep { - HistoryStep(name: name, undo: redo, redo: undo) + HistoryStep(id: id, name: name, retirement: retirement, undo: redo, redo: undo) } } @@ -138,11 +192,16 @@ public struct HistoryStep { /// The undo/redo substrate, behind one protocol boundary (12-editions.md ▸ The provider seam). /// -/// ### One per board session +/// ### One per board session — and one per open card window /// -/// "One stack per board, owned by the board session. Not per-window: every window over a board -/// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md -/// ▸ Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds +/// "Two levels: one stack per board, one per open card window" (13-native-undo.md ▸ Rules, re-ruled +/// 2026-07-31). The **board** stack is the session's, shared by board surfaces, and its +/// implementation is what this protocol is a seam for. A **card window** owns a second stack for its +/// own gestures — always a `NativeHistoryProvider`, in either tier, because a window's fine-grained +/// inverses are values-based whatever the board's substrate is (`CardWindowUndo`); what reaches this +/// seam from a window is the one coarse step its close registers. +/// +/// `AppModel.BoardSession` is where the board half's ownership lives, and the composition root binds /// which implementation it gets — **following the board, not the tier alone** (re-ruled 2026-07-31): /// a gitless board binds `NativeHistoryProvider` (two step stacks over the inverses registered at /// the Writer boundary) in every tier, a Pro git board binds the git provider (undo as forward @@ -160,6 +219,15 @@ public struct HistoryStep { /// relaunch because git does (06). Both are honest implementations of these seven members. /// - **No routing.** Which surface ⌘Z reaches is focus's answer, not the substrate's /// (06 ▸ Undo routing, tier-independent) — `BoardUndoRouting`. +/// +/// ### One obligation every implementation shares: retire what you drop +/// +/// A step may owe work for as long as it is crossable and no longer (`HistoryStep.Retirement` — the +/// deferred `comments/.trash/` purge). Only the substrate knows when a step stops being crossable, so +/// **every implementation must call `retirement?.run()` on every step it lets go**: the redo stack it +/// clears on a `register`, a step it drops as stale, everything in `clear()`, and — for a substrate +/// that keeps no steps at all — the step handed to `register` itself. Nothing else in the app can +/// observe that moment, and a step dropped in silence would defer its work forever. @MainActor public protocol HistoryProviding: AnyObject { diff --git a/Kanban/History/HistoryStaleness.swift b/Kanban/History/HistoryStaleness.swift index 9e6227d..52f8831 100644 --- a/Kanban/History/HistoryStaleness.swift +++ b/Kanban/History/HistoryStaleness.swift @@ -36,6 +36,31 @@ public enum ExpectedField: Sendable, Equatable { /// The body span, **byte for byte** — the Edit session's step, and the one inverse in the app /// whose fidelity is not field-level (13: "body steps compare bytes"). case body(String) + + /// Which field this is, ignoring the value it carries — the key a fold merges on + /// (`CardWindowUndo`): two writes to `background` inside one card-window session are one field + /// with a first and a last value, while a write to `background` and one to `icon` are two. + var kind: Kind { + switch self { + case .title: .title + case .order: .order + case .width: .width + case .background: .background + case .icon: .icon + case .body: .body + } + } + + /// The field names, as a comparable value — deliberately not the `String` keys, which are + /// `FrontmatterKeys`' business and would tie a fold to the file format. + enum Kind: Hashable, Sendable { + case title + case order + case width + case background + case icon + case body + } } // MARK: - HistoryExpectation diff --git a/Kanban/History/NativeHistoryProvider.swift b/Kanban/History/NativeHistoryProvider.swift index 6a2f702..a2f8dd6 100644 --- a/Kanban/History/NativeHistoryProvider.swift +++ b/Kanban/History/NativeHistoryProvider.swift @@ -2,7 +2,11 @@ import Foundation // MARK: - NativeHistoryProvider -/// The free tier's undo substrate: one stack per board session (13-native-undo.md). +/// The free tier's undo substrate: one stack per board session (13-native-undo.md) — **and the +/// substrate of every open card window's stack, in either tier** (re-ruled 2026-07-31, the +/// session-coarsening model): a window's fine-grained gestures are values-based inverses whatever the +/// board's own substrate is, so `CardWindowUndo` holds one of these too. Nothing below knows which of +/// the two it is; both need the same four-line grammar. /// /// ### Two arrays, and why not `NSUndoManager` /// @@ -59,10 +63,25 @@ public final class NativeHistoryProvider: HistoryProviding { public var redoActionName: String? { name(of: redoSteps.last) } + /// **The steps currently crossable by ⌘Z, oldest first** — read-only, and read by exactly one + /// caller: the card window's close, which folds the session's own stack into the one coarse board + /// step (13-native-undo.md ▸ Rules ▸ "Window close coarsens"; `CardWindowUndo`). + /// + /// The undo stack is the honest input to that fold and the redo stack is not: a gesture the user + /// undid inside the window is a gesture whose effect is no longer on disk, so it must contribute + /// nothing to the session's net effect. Nothing here decides what a fold *means* — this is the + /// membership question, and the two arrays already answer it. + public var pendingSteps: [HistoryStep] { undoSteps } + /// Records one undoable step and clears the redo stack — the classic rule, and the one every /// substrate shares. + /// + /// The cleared steps are **retired** on the way out (`HistoryStep.Retirement`): this is the + /// "undone-and-superseded" half of the deferred purge's release condition, and it is the only + /// moment the app can see it. public func register(_ step: HistoryStep) { undoSteps.append(step) + retire(redoSteps) redoSteps.removeAll() } @@ -70,11 +89,20 @@ public final class NativeHistoryProvider: HistoryProviding { public func redo() { cross(.redo) } + /// Session teardown, the add-git substrate swap, a branch reseed — every step goes, so every step + /// retires: "the purge runs when the coarse step leaves the board stack ... or the board session + /// ends" (13 ▸ Interaction with the trash). public func clear() { + retire(undoSteps) + retire(redoSteps) undoSteps.removeAll() redoSteps.removeAll() } + private func retire(_ steps: [HistoryStep]) { + for step in steps { step.retirement?.run() } + } + // MARK: - The crossing /// Crosses one step, and keeps going while the steps it crosses decline as **stale** — 13's @@ -91,6 +119,9 @@ public final class NativeHistoryProvider: HistoryProviding { push(step.reversed, onto: direction.opposite) return case .skipped: + // Dropped for good — the third of the deferred purge's release conditions ("gone + // stale"), and the reason a skip is reported here rather than merely counted. + step.retirement?.run() continue case .failed: push(step, onto: direction) diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 991a2fc..3e079c4 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -423,6 +423,11 @@ public final class BoardStore: HealHost { /// made its own would be a second answer to which stack a board has. /// `AppModel.beginSession` wires it the moment the session's provider exists. /// + /// **It is the board's stack, and not every step's destination** (13 ▸ Rules ▸ two levels, + /// re-ruled 2026-07-31): a gesture issued in a card window registers on *that window's* stack + /// instead, which the write methods below take as a parameter (`CardWindowUndo`). This one carries + /// board-surface gestures and the coarse step a window's close folds its session into. + /// /// **Weak, deliberately.** The session owns both the store and the provider, and the provider's /// steps hold closures over *this* store: a strong reference here would close that loop, leaving a /// board that could only be freed by remembering to empty its undo stack first. `nil` — no session @@ -1490,7 +1495,19 @@ public final class BoardStore: HealHost { /// like every other gesture with no second thing to do. A batch that fails partway leaves the /// targets written before it written — the Writer is "atomic per filesystem operation, not per /// gesture" — and the reload shows the true state, which is the honest one. - public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) { + /// + /// **The step's stack is the anchor's** (13-native-undo.md ▸ Rules ▸ two levels): the card + /// window's sidebar editor passes that window's own (`CardStyleSection`), so a colour chosen there + /// is one of the window's fine-grained gestures and joins board history only inside the coarse + /// close step. The board popover, the Style… popover and the quick-style rows pass nothing, which + /// is the board's stack — where a board-issued gesture belongs even when it names a card whose + /// window is open. + public func applyStyle( + to target: StyleTarget, + background: StyleChange = .keep, + icon: StyleChange = .keep, + on window: CardWindowUndo? = nil + ) { let edits: [( id: ItemID?, folder: URL, @@ -1550,6 +1567,7 @@ public final class BoardStore: HealHost { registerStep( HistoryPhrase.name(.restyle, kind: kind, count: edits.count), subject: subject, + on: window, undoExpects: edits.map { .present($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon)) }, @@ -1961,7 +1979,20 @@ public final class BoardStore: HealHost { /// session that ended because its card was moved to the trash registers against the trash folder /// it actually flushed into, and a later restore moves the card out from under the step, which /// the ordinary existence check then reads as the collision it is. - public func registerBodyEdit(inCard cardID: ItemID, priorBody: String, newBody: String) { + /// + /// ### Which stack it lands on is the caller's to say + /// + /// An Edit session belongs to a *window*, so the card window passes its own + /// (`CardWindowHost.configureSession` → `CardWindowUndo`) and the step never reaches board + /// history until the window closes and folds it into the coarse session step (13 ▸ Rules ▸ two + /// levels). `nil` — the default, and what a test or any non-window caller passes — is the board's + /// stack, exactly as before. + public func registerBodyEdit( + inCard cardID: ItemID, + priorBody: String, + newBody: String, + on window: CardWindowUndo? = nil + ) { guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return } let folder = target.folder(under: rootURL) let title = Self.cardTitle(at: target, in: snapshot) @@ -1969,6 +2000,7 @@ public final class BoardStore: HealHost { registerStep( HistoryPhrase.name(.edit, kind: .card), subject: title, + on: window, undoExpects: [.present(folder, .body(newBody))], redoExpects: [.present(folder, .body(priorBody))] ) { _ in diff --git a/Kanban/LiveStore/BoardStoreComments.swift b/Kanban/LiveStore/BoardStoreComments.swift index ecff475..03afc92 100644 --- a/Kanban/LiveStore/BoardStoreComments.swift +++ b/Kanban/LiveStore/BoardStoreComments.swift @@ -37,21 +37,26 @@ public enum CommentTarget: Sendable, Equatable { /// /// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for /// living on `BoardStore` at all: comments are read by the *card window* and written through the -/// *board's* store, because "one stack per board, owned by the board session. Not per-window: every -/// window over a board (board window, its card windows) shares the store and shares the stack" -/// (13 ▸ Rules). A comment posted in a card window is undone by ⌘Z in the board window, which is only -/// true if the step was registered here. +/// *board's* store — one store per board, whatever the window (02-architecture.md § Components), so a +/// comment posted here takes the same bracket, the same receipts and the same banner surface as every +/// other write in the app. +/// +/// **Which stack the steps land on is not the store's answer** (re-ruled 2026-07-31 — the +/// session-coarsening model, 13 ▸ Rules ▸ two levels): every gesture below takes the issuing window's +/// stack as a parameter, because a comment gesture is a *card window's* gesture and "the board stack +/// never carries a granular comment step" (13 ▸ Interaction with the trash). Board history sees the +/// window's session as one coarse step when the window closes (`registerCardSession`). /// /// ### What registers a step, and what deliberately does not /// -/// Two of the five (13 ▸ Interaction with the trash, the comment clause; 13 ▸ Rules): -/// /// - **`postComment`** — inverse: the rename back to `.draft`. Move-based, no capture. /// - **`deleteComment`** — inverse: the move back out of `comments/.trash/`. Move-based, no capture. -/// - **`saveCommentDraft` and `editComment` register nothing**, and that is the no-capture rule -/// rather than a deferral: undoing a body edit means holding the prior bytes, which 13 forbids in -/// every tier. An inline edit's revert is its *session*'s (Cancel/Escape reverts to session-start -/// bytes — 05-card-window.md), which is a live buffer, not a stack entry. +/// - **`registerCommentEdit`** — one *session*, at its commit point, holding the bytes it opened on +/// (added 2026-07-31 with the window stack, which is where 13 now puts the inline edit). The +/// per-tick `editComment` write registers nothing, exactly as a card body's per-tick save does not. +/// - **`saveCommentDraft` registers nothing**: a draft is a durable file being edited in place, with +/// no session boundary to coalesce at and no meaning for "undo" that emptying it does not already +/// have (05-card-window.md ▸ The comments column). /// - **`purgeCommentTrash` registers nothing** — the permanent-delete posture. It is also what makes /// the interaction with the stack correct for free: leftover comment steps go stale after a purge /// and skip with the ordinary info-tone banner, because the folders their expectations name are @@ -122,8 +127,13 @@ extension BoardStore { /// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo /// that re-minted would post a *different* comment, and any step registered above this one naming /// the posted id would name nothing. + /// + /// **The step lands on the issuing window's stack** (13 ▸ Rules ▸ two levels, re-ruled + /// 2026-07-31): the composer is a card window's surface, so the window passes its own + /// (`CardWindowHost.configureComments`). `nil` is the board's stack — a caller with no window, + /// which in the app is nobody and in a test is the shortest way to drive the write. @discardableResult - public func postComment(inCard id: ItemID) -> ItemID? { + public func postComment(inCard id: ItemID, on window: CardWindowUndo? = nil) -> ItemID? { guard let card = commentSubject(id) else { return nil } guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title) @@ -138,6 +148,7 @@ extension BoardStore { registerStep( HistoryPhrase.comment, subject: title, + on: window, undoExpects: [.present(postedFolder), .absent(draftFolder)], redoExpects: [.present(draftFolder), .absent(postedFolder)] ) { _ in @@ -155,8 +166,8 @@ extension BoardStore { // MARK: Editing - /// An inline edit session's save — one bracket, **no step** (13's no-capture rule; the session - /// owns Cancel). + /// An inline edit session's save — one bracket, **no step**, exactly as a card body's ~700 ms + /// tick writes no step: a session is not a save (`registerCommentEdit`, below). /// /// - Returns: whether bytes were written; `false` also for a card or comment that is gone. @discardableResult @@ -169,6 +180,47 @@ extension BoardStore { return wrote ?? false } + /// **Registers one inline edit session as one step** — `registerBodyEdit`'s shape one level down, + /// and its reasons verbatim. + /// + /// The window stack is what made this possible and what made it necessary. 13-native-undo.md's + /// no-byte-capture rule is about the *board* stack, where a comment's granular history has no + /// business ("the board stack never carries a granular comment step"); the re-ruled two-level + /// model puts every gesture issued in a card window on that window's own stack at fine grain, + /// "comment post/delete/**edit**" named among them. So an inline edit registers where the body's + /// Edit session registers, at the same kind of boundary — Save, ⌘↩, or the close flush — with the + /// bytes the session opened on (`CommentEditSession.sessionStart`). + /// + /// Cancel registers nothing, and needs no rule of its own: it writes the session-start bytes back, + /// so the session's net effect is nothing and there is nothing to undo. + /// + /// Its predicate is the body's: "body steps compare bytes" (13 ▸ Rules), against the comment's own + /// folder — so a foreign edit to that comment skips the step, and a foreign edit to the card or to + /// a sibling comment leaves it alone. + public func registerCommentEdit( + _ commentID: ItemID, + inCard id: ItemID, + priorBody: String, + newBody: String, + on window: CardWindowUndo? = nil + ) { + guard priorBody != newBody, let card = commentSubject(id) else { return } + let folder = CommentThread.commentFolder(commentID, inCard: card.folder) + let title = card.title + + registerStep( + HistoryPhrase.name(.edit, kind: .comment), + subject: title, + on: window, + undoExpects: [.present(folder, .body(newBody))], + redoExpects: [.present(folder, .body(priorBody))] + ) { _ in + _ = try BoardWriter.editComment(at: folder, body: priorBody, cardTitle: title) + } redo: { _ in + _ = try BoardWriter.editComment(at: folder, body: newBody, cardTitle: title) + } + } + // MARK: Delete and its inverse /// **Deletes a comment — a move into `comments/.trash/`, immediate, no confirm, undoable** @@ -179,8 +231,21 @@ extension BoardStore { /// and existence is the whole predicate. The container rides in the path exactly as it does at /// board level — the undo expects the comment in `comments/.trash/`, the redo expects it back in /// the thread — so a foreign restore or a foreign re-delete skips the right half by itself. + /// + /// **Both sides of the move are declared**, `postComment`'s shape (added 2026-07-31 with the + /// window stack): the undo needs the trashed folder there *and the live path free*, because the + /// move back would otherwise land on top of whatever now sits at it — and because the close fold + /// reads these lists as the state the session left. A move that named only its destination would + /// leave the session's fold claiming a comment is still at a path it has left, and the coarse step + /// would be stale the moment it was registered (`CardWindowUndo.netEffect`). + /// + /// **The step lands on the issuing window's stack** — `postComment`'s rule, and the one 13's + /// comments paragraph states outright: "the step lives on the card window's own stack ... so the + /// old stale-after-close skip scenario cannot arise". What reaches the board is the close step + /// that folds it, whose undo restores the comment from a `comments/.trash/` the same step is + /// keeping alive (`registerCardSession`). @discardableResult - public func deleteComment(_ commentID: ItemID, inCard id: ItemID) -> Bool { + public func deleteComment(_ commentID: ItemID, inCard id: ItemID, on window: CardWindowUndo? = nil) -> Bool { guard let card = commentSubject(id) else { return false } let folder = card.folder let title = card.title @@ -195,8 +260,9 @@ extension BoardStore { registerStep( HistoryPhrase.name(.delete, kind: .comment), subject: title, - undoExpects: [.present(trashed)], - redoExpects: [.present(live)] + on: window, + undoExpects: [.present(trashed), .absent(live)], + redoExpects: [.present(live), .absent(trashed)] ) { _ in try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title) } redo: { _ in @@ -207,12 +273,20 @@ extension BoardStore { // MARK: The purge, and the crash residue it leaves - /// **Empties one card's `comments/.trash/`** — the card window's close flush (01-storage-format.md - /// § Enhanced schema: "purged when the card window closes (rides the close flush)"). + /// **Empties one card's `comments/.trash/`** — **when undo no longer needs it** (01-storage-format.md + /// § Enhanced schema, re-ruled 2026-07-31; 13-native-undo.md ▸ Interaction with the trash). /// - /// One bracket, no step. Leftover comment steps on the board's stack are not pruned here and must - /// not be: invalidation is lazy (13 ▸ Rules), so they stay on the stack, look full, and skip with - /// the ordinary info-tone banner the first time one is crossed. + /// The call site moved with that re-ruling and this method did not change: the window's close no + /// longer purges on its own, it hands this work to the coarse close step as that step's + /// **retirement** (`HistoryStep.Retirement`), and the purge runs when the step leaves the board + /// stack — undone-and-superseded, dropped, gone stale — or when the board session ends. On a git + /// board the step is never kept, so the retirement fires at the close flush, which is where the + /// purge always ran there ("purge rides the close flush"). The crash-residue sweep at the next + /// card-window open is unchanged. + /// + /// One bracket, no step. Leftover comment steps on a stack are not pruned here and must not be: + /// invalidation is lazy (13 ▸ Rules), so they stay on the stack, look full, and skip with the + /// ordinary info-tone banner the first time one is crossed. public func purgeCommentTrash(inCard id: ItemID) { guard let card = commentSubject(id) else { return } try? performWrite { () throws(BoardWriteError) -> Void in diff --git a/Kanban/Storage/AgentGuide.swift b/Kanban/Storage/AgentGuide.swift index 2c1aa53..c746408 100644 --- a/Kanban/Storage/AgentGuide.swift +++ b/Kanban/Storage/AgentGuide.swift @@ -68,8 +68,16 @@ enum AgentGuide { /// Trash, re-ruled 2026-07-31; 08-agent-integration.md's own line): the trash sorts by `modified` /// descending, so there is no rank to mint on the way in — the guide's smallest-`order`-minus-1024 /// formula is replaced by "restamp `modified`, leave `order` alone", which is the same stamp - /// discipline v7 already taught, now doing the ordering as well. - static let version = 8 + /// discipline v7 already taught, now doing the ordering as well. **v9 teaches the one-line + /// value**, from a real agent incident (2026-07-31): a card's `title` was a double-quoted + /// scalar wrapped across two lines, and a hand copy of that card onto another board took the + /// first line without its continuation. An unclosed quote does not stop at the key it began + /// on — it runs to the end of the block — so a board failed to load over a file whose only + /// defect was a missing second line, and the parser's complaint pointed at the *last* key it + /// swallowed rather than the title. The guide now teaches long values as one long line, says + /// why the wrapped shape is the dangerous one to copy, and names the unterminated quote beside + /// the unquoted colon in Hard rules. + static let version = 9 // MARK: - The version marker @@ -381,6 +389,16 @@ enum AgentGuide { containing `: ` or starting with `#`, `[`, `{`, or a quote — when in doubt, double-quote. + **Keep every value on one line.** A double-quoted scalar may legally + continue on the following line (`title: "Long title` then ` the rest"`), + and some tools emit that shape for long titles — but it is the most common + way frontmatter gets broken by hand: the continuation reads as a line of + its own, so an edit or a copy that takes only the first one leaves the + quote unclosed, and an unclosed quote runs on to swallow every key below + it. The whole file then fails to parse, not just the title. Titles have no + length limit — write a long one as one long line, and when you copy a card + between boards, copy its frontmatter block whole. + Unknown keys are preserved verbatim by the app and invisible in its UI — custom metadata (`project:`, `tags:`, `claimed-by:` …) is safe to add and survives every app rewrite. Reserved for Lanework's upcoming tracker sync — @@ -491,8 +509,9 @@ enum AgentGuide { ## Hard rules (the app fails loudly on violations) - Frontmatter must parse as YAML; `schema` (plus `order` on lanes and - cards) is required. Keep `schema: 1`. The classic violation is an - unquoted colon in a title (see Frontmatter above). + cards) is required. Keep `schema: 1`. The classic violations are an + unquoted colon in a title and a quoted value left unclosed across a + line break (see Frontmatter above). - Files must be UTF-8 without BOM. - Never create a card folder without an `index.md`. - Never rename UUID folders. diff --git a/Kanban/UI/Card/CardComments.swift b/Kanban/UI/Card/CardComments.swift index fc6240f..4963f0a 100644 --- a/Kanban/UI/Card/CardComments.swift +++ b/Kanban/UI/Card/CardComments.swift @@ -108,7 +108,8 @@ public final class CardComments { @ObservationIgnored public var sweepTrashResidue: (() -> Void)? - /// The close purge — `BoardStore.purgeCommentTrash(inCard:)`. + /// The deferred purge — `BoardStore.purgeCommentTrash(inCard:)`, run through `purgeTrashNow()` + /// by whoever owes it (see that method). @ObservationIgnored public var purgeTrash: (() -> Void)? @@ -127,6 +128,12 @@ public final class CardComments { @ObservationIgnored public var editComment: ((ItemID, String) -> Bool)? + /// One inline edit **session**'s undo step — `BoardStore.registerCommentEdit(...)`, handed to each + /// session beside its save and called once, at the session's commit point (13-native-undo.md + /// ▸ Rules ▸ coalescing; `CommentEditSession.registerUndo`). + @ObservationIgnored + public var registerCommentEdit: ((ItemID, String, String) -> Void)? + /// Imports files into an authoring surface's `attachments/` — /// `BoardStore.importCommentAttachments(_:inCard:target:)`. @ObservationIgnored @@ -319,6 +326,9 @@ public final class CardComments { session.save = { [weak self] text in self?.editComment?(commentID, text) ?? false } + session.registerUndo = { [weak self] prior, new in + self?.registerCommentEdit?(commentID, prior, new) + } editing = session } @@ -409,20 +419,31 @@ public final class CardComments { // MARK: - The close flush - /// **The window's close, in the order the brief fixes: saves first, purge last.** + /// **The pane's half of the window's close: the saves.** /// /// The inline session flushes as the body's does (a flush, never a revert — a close is not an - /// abandon), then the composer's draft lands, and only then is `comments/.trash/` emptied. The - /// purge going last is what makes it safe at all: it removes the folders a delete moved aside, and - /// running it before a session's save could remove a folder that save was about to write into. + /// abandon), and then the composer's draft lands. Ending twice does nothing the second time — the + /// sessions latch — which is what makes the two paths that call this (a window closed on its own, + /// and the board's close flush driving it) safe to both exist. /// - /// Ending twice does nothing the second time — the sessions latch, and a purge over an empty - /// trash is a no-op — which is what makes the two paths that call this (a window closed on its - /// own, and the board's close flush driving it) safe to both exist. + /// **The purge is no longer here** (re-ruled 2026-07-31 — 13-native-undo.md ▸ Interaction with the + /// trash): `comments/.trash/` is what the coarse close step's undo restores deleted comments from, + /// so emptying it belongs *after* that step exists and only when that step does not. The order + /// this method fixed is unchanged and still load-bearing — saves land before anything empties the + /// trash — it is now spelled one level up, where the step is registered (`CardWindowSession`), + /// because that is the only level that knows whether the purge is owed now or owed later. public func endSession() { editing?.endOnClose() editing = nil composer.flush() + } + + /// **Empties `comments/.trash/`** — the deferred purge, run by whoever currently owes it: the + /// window's close when no coarse step took it, or that step's retirement when one did. + /// + /// A purge over an empty trash is a no-op, which is what keeps the two owners from having to agree + /// about anything but who calls first. + public func purgeTrashNow() { purgeTrash?() } diff --git a/Kanban/UI/Card/CardSidebarSections.swift b/Kanban/UI/Card/CardSidebarSections.swift index 8e2072d..46857b8 100644 --- a/Kanban/UI/Card/CardSidebarSections.swift +++ b/Kanban/UI/Card/CardSidebarSections.swift @@ -32,6 +32,11 @@ struct CardStyleSection: View { let store: BoardStore let recents: StyleRecents let cardID: ItemID + /// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels): a colour or symbol + /// chosen here is a gesture *issued in this window*, so its step joins the window's session and + /// reaches board history only inside the coarse close step. The shared editor takes it as an + /// anchor's parameter, exactly as it takes the layout. + let undo: CardWindowUndo /// The live body metric, read here rather than passed in — `CardAttachmentsSection`'s pattern, /// so every section in this sidebar derives its geometry the same way. @@ -58,7 +63,8 @@ struct CardStyleSection: View { layout: .sidebar( contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize), bodyPointSize: pointSize - ) + ), + undo: undo ) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index 9212718..e627723 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -91,6 +91,11 @@ struct CardWindowView: View { let comments: CardComments /// This window's thumbnail memory, held by the host so it outlives a snapshot. let thumbnails: AttachmentThumbnailCache + /// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels). It arrives for exactly + /// one consumer — the Style section, the one sidebar anchor that *writes* — because a gesture + /// issued in this window registers on this window's stack. It lives on the window's session so + /// the close can fold it, which is why it arrives here rather than being made here. + let undo: CardWindowUndo /// **This card's commit trail** (05 ▸ History), or `nil` on every board with no app-managed git — /// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule; /// see `historySlot`. @@ -314,7 +319,7 @@ struct CardWindowView: View { VStack(alignment: .leading, spacing: bodyPointSize * 1.25) { CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails) - CardStyleSection(store: store, recents: recents, cardID: card.id) + CardStyleSection(store: store, recents: recents, cardID: card.id, undo: undo) // The snapshot's own document, not a re-read: the loader parsed this file, unknown // keys and their order included, and `Card` has carried it since (`BoardModel`). diff --git a/Kanban/UI/Card/CommentEditSession.swift b/Kanban/UI/Card/CommentEditSession.swift index 8d9851c..ee33fed 100644 --- a/Kanban/UI/Card/CommentEditSession.swift +++ b/Kanban/UI/Card/CommentEditSession.swift @@ -77,6 +77,16 @@ public final class CommentEditSession { @ObservationIgnored public var save: ((String) -> Bool)? + /// Where this session's **one undo step** goes, called at its commit point with the bytes the + /// session opened on and the bytes it leaves — `CardBodyEditSession.registerUndo`'s seam one level + /// down, and for its reason exactly: a session is one step, never one per debounced tick + /// (13-native-undo.md ▸ Rules ▸ coalescing). + /// + /// `CardComments.beginEdit` points it at `BoardStore.registerCommentEdit`, on the *window's* + /// stack. `nil` — a session with no window behind it — registers nothing. + @ObservationIgnored + public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)? + /// How many saves have actually been attempted through `save`. @ObservationIgnored public private(set) var saveAttempts = 0 @@ -142,7 +152,9 @@ public final class CommentEditSession { public func commit() -> Bool { guard !hasEnded else { return false } hasEnded = true - return flush() + let wrote = flush() + registerStep() + return wrote } /// **Cancel, or Escape** — reverts to session-start bytes and ends (05; 11-command-nexus.md's @@ -179,11 +191,25 @@ public final class CommentEditSession { public func endOnClose() -> Bool { guard !hasEnded else { return false } hasEnded = true - return flush() + let wrote = flush() + registerStep() + return wrote } // MARK: - Private + /// The session's one step, at whichever of its two ends got here first — and **only when the + /// session actually changed the file**: `disk` is what this session knows the file says, so + /// `disk == sessionStart` covers both the session that only read and the one that typed its way + /// back to where it started (`CardBodyEditSession.endEditSession`'s guard, restated). + /// + /// Cancel deliberately never reaches here: it writes the start bytes back, so its net effect is + /// nothing and a step would only offer to undo an undo. + private func registerStep() { + guard disk != sessionStart else { return } + registerUndo?(sessionStart, disk) + } + private func scheduleSave() { cancelPending() let interval = debounceInterval diff --git a/Kanban/UI/StyleEditor.swift b/Kanban/UI/StyleEditor.swift index 9147a52..060bf6c 100644 --- a/Kanban/UI/StyleEditor.swift +++ b/Kanban/UI/StyleEditor.swift @@ -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 { diff --git a/KanbanTests/AgentGuideTests.swift b/KanbanTests/AgentGuideTests.swift index ad7cd34..15a7221 100644 --- a/KanbanTests/AgentGuideTests.swift +++ b/KanbanTests/AgentGuideTests.swift @@ -705,6 +705,22 @@ struct AgentGuideContentTests { #expect(!content.contains("Arrivals go on top")) } + /// **v9: values stay on one line** (2026-07-31 incident): a wrapped double-quoted `title` lost + /// its continuation line in a hand copy between boards, leaving the quote unclosed — and an + /// unclosed quote runs to the end of the block, so the whole file stopped parsing and the board + /// refused to load. The guide has to teach the shape, say why copying it is where it breaks, and + /// name the unterminated quote in Hard rules beside the colon it now shares billing with. + @Test("v9 teaches one-line frontmatter values and names the unterminated quote") + func v9OneLineValueVocabularyIsPresent() { + let content = AgentGuide.content + #expect(content.contains("**Keep every value on one line.**")) + #expect(content.contains("swallow every key below")) + #expect(content.contains("copy its frontmatter block whole")) + // Hard rules names both classic violations, not just the colon. + #expect(content.contains("The classic violations are an")) + #expect(content.contains("quoted value left unclosed across a")) + } + /// The pathfinder's guide taught `media/` and tombstone deletes; both are retired /// (01-storage-format.md ▸ Changes from the pathfinder schema; ▸ Deletion). The one legitimate /// mention of `deleted:` is the warning never to write it. diff --git a/KanbanTests/CardCommentsTests.swift b/KanbanTests/CardCommentsTests.swift index 556de65..394cf60 100644 --- a/KanbanTests/CardCommentsTests.swift +++ b/KanbanTests/CardCommentsTests.swift @@ -425,10 +425,13 @@ struct CardCommentsAttachmentTests { @Suite("Card comments ▸ the close flush") struct CardCommentsCloseTests { - @Test("Saves first, purge last") - func savesPrecedeThePurge() { - // The purge removes the folders a delete moved aside; running it before a session's save - // could remove a folder that save was about to write into. + @Test("The saves land, and the purge is not the pane's to run") + func savesLandAndThePurgeDefers() { + // Realigned 2026-07-31 with the session-coarsening model (13-native-undo.md ▸ Interaction + // with the trash): the ordering this pinned — every save before anything empties + // `comments/.trash/` — still holds and is now the window session's to keep, because only + // that level knows whether a coarse close step took the purge on (`CardWindowSession`). + // What the pane owes the close is its two saves, in order. let spy = CommentsSpy() let comments = makeComments(spy) spy.thread = thread([comment(CommentIdent.one, body: "first\n")]) @@ -441,9 +444,12 @@ struct CardCommentsCloseTests { let edit = try? #require(spy.events.firstIndex(of: .edit(CommentIdent.one, "mid-sentence\n"))) let draft = try? #require(spy.events.firstIndex(of: .saveDraft("a draft\n"))) - let purge = try? #require(spy.events.firstIndex(of: .purge)) - #expect((edit ?? 0) < (purge ?? 0)) - #expect((draft ?? 0) < (purge ?? 0)) + #expect((edit ?? 0) < (draft ?? 1)) + #expect(!spy.events.contains(.purge), "the trash outlives the pane's own close") + + // And it is still one call away, for whoever ends up owing it. + comments.purgeTrashNow() + #expect(spy.events.last == .purge) } @Test("The close flushes the session — it never reverts it") @@ -472,13 +478,15 @@ struct CardCommentsCloseTests { #expect(spy.events.filter { $0 == .saveDraft("a draft\n") }.count == 1) } - @Test("A clean pane still purges — the trash is the app's leftovers, not the user's work") - func aCleanPaneStillPurges() { + @Test("A clean pane's close writes nothing and purges nothing") + func aCleanPaneClosesQuietly() { + // The trash is still the app's leftovers rather than the user's work — but who empties it, + // and when, is the window session's question now (`CardSessionUndoTests`). let spy = CommentsSpy() let comments = makeComments(spy) comments.endSession() - #expect(spy.events == [.purge]) + #expect(spy.events.isEmpty) } } @@ -580,7 +588,7 @@ struct CardCommentsWiringTests { let comments = CardComments() comments.isEditable = true comments.cardFolder = fixture.url(card) - CardWindowHost.configureComments(comments, store: store, cardID: cardID) + CardWindowHost.configureComments(comments, store: store, cardID: cardID, on: CardWindowUndo()) comments.open() comments.composer.edited("A remark.\n") @@ -599,8 +607,12 @@ struct CardCommentsWiringTests { #expect(!fixture.exists("\(card)/comments/.draft")) } - @Test("Delete moves into comments/.trash/, and the close purge empties it") + @Test("Delete moves into comments/.trash/, and the deferred purge empties it") func deleteThenPurge() throws { + // Realigned 2026-07-31 (13-native-undo.md ▸ Interaction with the trash): the pane's own + // close no longer purges — `comments/.trash/` is what the window's coarse close step + // restores from, so emptying it is the purge the window session owns and hands out + // (`CardWindowSession.endSession`; `CardSessionUndoTests` drives the deferral itself). let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try makeCommentBoard(fixture) @@ -610,7 +622,9 @@ struct CardCommentsWiringTests { let comments = CardComments() comments.isEditable = true comments.cardFolder = fixture.url(card) - CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1)) + CardWindowHost.configureComments( + comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo() + ) comments.open() #expect(comments.thread.comments.count == 1) @@ -619,6 +633,10 @@ struct CardCommentsWiringTests { #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) comments.endSession() + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), + "the saves land; the trash is not the pane's to empty any more") + + comments.purgeTrashNow() #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } @@ -633,7 +651,9 @@ struct CardCommentsWiringTests { let comments = CardComments() comments.isEditable = true comments.cardFolder = fixture.url(card) - CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1)) + CardWindowHost.configureComments( + comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo() + ) comments.open() comments.beginEdit(ItemID(rawValue: CommentIdent.one)) diff --git a/KanbanTests/CommentAnnouncementTests.swift b/KanbanTests/CommentAnnouncementTests.swift index 82e1a66..4e05934 100644 --- a/KanbanTests/CommentAnnouncementTests.swift +++ b/KanbanTests/CommentAnnouncementTests.swift @@ -276,7 +276,9 @@ struct CommentPaneAnnouncementTests { comments.isEditable = true comments.cardFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)") comments.cardTitle = "Fix login" - CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1)) + CardWindowHost.configureComments( + comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo() + ) let spoken = Spoken(store: store) comments.announce = { spoken.record($0) } return (comments, spoken) diff --git a/KanbanTests/HistoryProviderTests.swift b/KanbanTests/HistoryProviderTests.swift index 15d7cfa..0b7cbf7 100644 --- a/KanbanTests/HistoryProviderTests.swift +++ b/KanbanTests/HistoryProviderTests.swift @@ -703,7 +703,7 @@ struct UndoCommandSurfaceTests { defer: true ) let controller = HostedWindowController() - controller.boardUndoManager = { manager } + controller.windowUndoManager = { manager } controller.attach(to: window) return (window, controller) } @@ -792,8 +792,11 @@ struct UndoCommandSurfaceTests { #expect(undoRow.title == "Undo") } - @Test("Every window over one board answers with that board's one stack") - func cardWindowsShareTheBoardsStack() throws { + @Test("A board window answers with the board's stack; a card window answers with its own") + func eachWindowAnswersWithItsOwnStack() throws { + // Realigned 2026-07-31 with the two-level model (13-native-undo.md ▸ Rules): this pinned the + // shared-stack wiring, which is exactly what the session-coarsening re-ruling replaced — + // "a card window owns its own stack ... and `window.undoManager` answers with it". let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() @@ -801,10 +804,12 @@ struct UndoCommandSurfaceTests { let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) - // The board window and one of its card windows, wired the way their hosts wire them. + // The board window and one of its card windows, wired the way their hosts wire them + // (`BoardWindowHost.configureWindow`, `CardWindowHost.configureWindow`). + let card = CardWindowUndo() let (boardWindow, boardController) = hostedWindow(session.undoManager) defer { boardController.detach() } - let (cardWindow, cardController) = hostedWindow(session.undoManager) + let (cardWindow, cardController) = hostedWindow(card.manager) defer { cardController.detach() } let boardRow = menuItem("undo:") let cardRow = menuItem("undo:") @@ -812,9 +817,17 @@ struct UndoCommandSurfaceTests { session.history?.register(StepLog().step("Move 3 Cards")) #expect(boardWindow.validateMenuItem(boardRow)) - #expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window") #expect(boardRow.title == "Undo Move 3 Cards") - #expect(cardRow.title == boardRow.title) + + // **No fall-through** (06-history-undo.md ▸ Undo routing): the card window's own stack is + // empty, so its row is disabled and ⌘Z beeps — it never reaches the board's step. + #expect(cardWindow.validateMenuItem(cardRow) == false) + #expect(cardRow.title == "Undo") + + card.stack.register(StepLog().step("Comment")) + #expect(cardWindow.validateMenuItem(cardRow)) + #expect(cardRow.title == "Undo Comment") + #expect(boardRow.title == "Undo Move 3 Cards", "and the board's row is untouched by it") } // MARK: The toolbar twins