asterism branch T-2301/bugfix-save-does-not-dismiss-entry commits 2 + working tree files 11 touched lines +402 / -13

Pre-push review: T-2301/bugfix-save-does-not-dismiss-entry

T-2301 — entry detail's Update checkmark saved but never dismissed. Two commits plus the review's fixes in the working tree, against origin/main at ed9fa0e (T-2306).

At a glance

  • EntryDetailModel.update() returns whether the edit landed; the confirmation button pops on true, and only where the screen was pushed.

  • Wide-tree column roots are gated off (leavesAfterUpdate: false from EntryDetailRoute) rather than trusting a no-op, because a windowed dismiss() with nothing to pop closes the window.

  • Refused and failed writes still keep the reader on the screen with the message; the draft-survival contract (Req 2.10) is untouched.

  • Review fixes: host gate, defer for the submission flag, corrected "keeps the draft" wording in four places, tests folded into the scenarios they duplicated, two more UI tests, a Q61 decision row.

Verdict

Ready to push

The fix is small and lands on the model's own answer to "did the write land". The review's one substantive finding — that relying on dismiss() being a no-op for a column root is not what DismissAction documents for a windowed host — is closed by gating the dismissal on the host through a signal the route already carried. Every route shape now has a UI test: the Recent push, the chapter-inside-a-work push, and the iPad column that stays.

Verification: make test-quick built the Mac target and ran 1,198 unit tests; one unrelated timing flake in ComposedTeachingViewModelTests failed in the full run and passed on an isolated rerun, now recorded in the testing notes. The three T-2301 UI tests pass on the iPhone and iPad simulators. No new compiler warnings in the changed files against a raw build log.

Review findings

13 raised · 9 fixed · 4 skipped

Jump to findings →

Tests

Pass rate: n/a

New tests: 4

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What changed

In Asterism, when you open one of your notes (an entry) and tap the checkmark in the top-right corner to save an edit, the app used to save your change and then leave you sitting on the same screen. You had to tap Back yourself. Now the checkmark saves and takes you back to the list you came from, the same way confirming a delete already did.

On an iPad or a Mac, where the note is shown in a wide side-by-side layout rather than on its own screen, nothing closes: the note stays in its column after saving, because there is no separate screen to leave.

Why it matters

Every edit cost one extra tap, and the checkmark behaved differently from every other checkmark in the app. If a save fails or is refused, you stay on the screen and see the message explaining why, exactly as before.

Key concepts

  • Push and pop: on a phone, opening a note slides a new screen over the list (a push). Going back slides it away (a pop). The fix pops after a successful save.
  • Confirmation action: the checkmark in the navigation bar is the platform's standard "I'm done" control. The convention is that it commits and leaves.
  • Column layout: on wide screens the note is a panel beside the list, not a screen on top of it. A panel has nothing to pop, so it stays.

Changes overview

  • EntryDetailModel.update() now returns a discardable Bool saying whether the edit landed: true after the write committed and the entry reloaded, false on a duplicate tap, a write conflict, or a thrown error. The submission flag clears through a defer.
  • EntryDetailView gains a leavesAfterUpdate argument (default true). The confirmation button's action is now if await model.update(), leavesAfterUpdate { dismiss() }.
  • EntryDetailRoute, the one place every host builds this screen, passes leavesAfterUpdate: showsSky: both flags are the single fact that the screen was pushed rather than placed as a column of a window.
  • Tests: return-value assertions added to the three existing update tests plus a new sequential-update test; UI tests for the Recent push, the chapter-inside-a-work push, and the iPad column that stays. A Quick Decision (Q61) in polish-and-export records the behaviour.

Implementation approach

The model owns the answer to "did the write land", and the view acts on that answer rather than re-reading state after the await. This mirrors the screen's two delete arms, which already check the model imperatively after their await and call dismiss(). The host decides whether a dismissal makes sense, and it already had a signal for that: the wide tree passes showsSky: false to every column-root screen.

Trade-offs

  • Six sibling screens dismiss by observing a model flag with .onChange. Here the reload after a landed write overwrites state, so a terminal state case would not survive; the Bool is the honest shape and matches WorkDetailModel's private commit helpers.
  • The first version relied on dismiss() being a no-op for a column root. The review found that DismissAction's documented behaviour on a view with nothing to pop or present is to close its window, which on the Mac is the app's only one. The gate replaces that assumption with a fact the route already holds.
  • The reload after a landed write is kept even though a popped screen discards it: the wide tree needs the reloaded entry, and threading a host flag into the model to save one single-record read is not worth it.

Technical deep dive

The success path in update() is onMutation() (library-wide refresh: diagnoses, Recent presentation, works, site names, two reconciles) then load() then return true. Dismissal therefore lands after Recent has republished, so the pop reveals the updated row rather than a stale one; reordering would show the old rating during the animation and break the mutationCount assertions. The conflict arm returns false without touching the draft (Req 2.10 of duplicate-reconciliation); the catch arm restores the draft from the snapshot unless the record vanished, unchanged from before.

load() routes through .loading, so on the compact tree the pop now animates away from a one-read spinner instead of the card. Pre-existing, made more visible by the pop; flagged as a follow-up, not fixed here.

Architecture impact

EntryDetailView now carries two host-shape flags (showsSky, leavesAfterUpdate) that are always set together by EntryDetailRoute. They are kept separate because they name different consequences of the same fact; if a third such consequence appears, collapsing them into one hostKind enum is the obvious refactor. The screen's delete arms and WorkDetailView's deletion still call dismiss() unconditionally from a column root; that is the same DismissAction contract this fix gates around and is left for a separate ticket.

Potential issues

  • The iPad UI test asserts the column stays by waiting for the Update control to re-enable, then checking the entry marker is still laid out inside the column. It cannot distinguish "dismiss was not called" from "dismiss was called and did nothing", but with the gate the first is true by construction.
  • The Mac path is not exercised by any automated test. The gate means no dismiss() call is made from a column root, so the window-close risk is removed by construction rather than by measurement.
  • A survivorDiverged conflict after a redirect is the one refusal reachable with the button enabled; it returns false and stays, which the model test covers.

Important changes — detailed

EntryDetailView: the confirmation leaves a pushed screen

Asterism/Asterism/Views/EntryDetailView.swift

Why it matters. This is the bug. The action ended at the await; now a landed edit pops, and only where there is a push to leave.

What to look at. EntryDetailView.swift — leavesAfterUpdate property and the Button(role: .confirm) action

Takeaway. When a control moves into a system slot with its own semantics (confirmationAction), check what follows it, not only how it looks. The delete arms on the same screen were the template.
Rationale. The model says whether the write landed; the host says whether there is a push to leave. Both are facts the view should not re-derive.

EntryDetailRoute: the host gate reuses the showsSky signal

Asterism/Asterism/Layout/EntryDetailRoute.swift

Why it matters. Without this, a landed update in the Mac's detail column would call dismiss() on a view with nothing to pop, which DismissAction documents as closing the window.

What to look at. EntryDetailRoute.swift — leavesAfterUpdate: showsSky

Takeaway. A NavigationSplitView column root is not a safe place to call dismiss(). Gate on the host rather than trusting a no-op.
Rationale. showsSky is already false exactly when the screen is a column of a window, so the gate is one argument and no Mac measurement is needed to trust it.

EntryDetailModel.update(): a Bool answer, flag cleared by defer

Asterism/Asterism/ViewModels/EntryDetailModel.swift

Why it matters. The return value is the contract the view depends on. It is the write's outcome, not a re-read of state after the reload, which could fail on its own.

What to look at. EntryDetailModel.swift — update()

Takeaway. A post-write reload can overwrite a terminal state; return the outcome from the method instead of asking the model afterwards.
Rationale. Six sibling screens dismiss on an .onChange of a model flag, but here State is overwritten by load(), so a terminal case would not survive. The Bool matches WorkDetailModel's commit helpers.

Three UI tests, one per route shape

Asterism/AsterismUITests/WideLayoutUITests.swift

Why it matters. The ticket names Recent and the Works view; the chapter route is the one push that sits on another push, and the iPad column is the negative case.

What to look at. RecentAndEntryDetailUITests, WorkDetailActionsUITests, WideLayoutUITests — the three T-2301 tests

Takeaway. The iPad test waits for the Update control to re-enable before asserting the column still holds the entry; waiting on the control is waiting for the write to settle.
Rationale. The first UI test failed before the fix and passes after; the other two pin the review's gate from both sides.

Key decisions

Return a Bool rather than observe a model flag.

Six sibling screens dismiss via .onChange of a published property. EntryDetailModel.State is overwritten by the reload that follows a landed write, so a terminal state would not survive; the return value is the write's own outcome. Recorded in the report's Alternatives.

Gate the dismissal on the host instead of trusting a no-op.

DismissAction's documented behaviours are: dismiss a presentation, pop a NavigationStack, or close a window created with Window/WindowGroup. A column root inside the Mac's single Window is the third case. EntryDetailRoute already passes showsSky: false for exactly that host. Recorded as Q61 in polish-and-export.

Keep the reload after a landed write, and keep it before the pop.

The wide tree's column needs the reloaded entry; a popped screen wastes one single-record read, which is not worth a host flag in the model. Popping before onMutation() would reveal Recent with the stale row for the length of the refresh.

A refused write keeps the draft; a failed write restores it.

Both return false and stay on the screen. The first version's comments said "keeps the draft" for both; the catch block restores the draft from the snapshot for an ordinary failure (Req 2.10 forbids discarding it only for a vanished record). Wording corrected in the model, changelog, and report.

In the wide tree's chapter case the column stays on the chapter.

A column stays put; the chapter's ColumnBackButton is its way back. Worth revisiting only if the owner finds the column lingering after a save on iPad.

Review findings

SeverityAreaFindingResolution
majorWide-tree dismissal (spec review)The claim that dismiss() is a no-op for a column root is unmeasured, and DismissAction's contract on a view with nothing to pop is to close its window — the Mac's only one.EntryDetailView gained leavesAfterUpdate; EntryDetailRoute passes showsSky for it; an iPad UI test pins that the column stays.
minorWording in model, view, changelog, report (quality review)"A refused or failed write keeps the draft" is wrong for the failure arm: the catch restores the draft from the snapshot except for a vanished record.Reworded in all four places to distinguish refused (kept) from failed (restored).
minorReport cites Q55 in the wrong spec (spec review)work-detail-reading-redesign's log ends at Q26; the label-less confirmation is Q55 in polish-and-export.Related section corrected; Q61 added to the same log.
minorTest duplication (quality and spec reviews)The three new model tests re-staged scenarios the suite already had, asserting only the return value.Return-value assertions folded into updateCommits, conflictKeepsTheDraft and updateFailureNoOptimistic; the three duplicates removed.
minorMissing sequential-update test (spec review)Nothing pinned that a second update after a landed one still writes, which is what the submission flag's reset amounts to.sequentialUpdatesBothLand added.
minorChapter route not covered (spec review)The ticket names the Works view; the chapter push is the one where pop and pop-to-root would differ.testUpdatingAChapterEntryReturnsToTheWork added.
nitisSubmitting reset at three sites (reuse and quality reviews)House pattern is defer immediately after setting the flag.Replaced with defer { isSubmitting = false }.
nitView comment restated the model's contract (quality review)The middle sentence duplicated update()'s doc comment.Trimmed to the view-specific content.
nitRedundant waitFor in the Recent UI test (efficiency review)openSeededEntry() had already waited for the Update control.Replaced with a direct tap.
minorBool return vs .onChange flag convention (reuse review)Six sibling screens dismiss by observing a model flag; this one acts on a return value.Kept: the reload overwrites State, so a flag would need to survive it; the quality review confirmed the Bool is the right shape. Recorded as a decision.
minorPop animates from a spinner (efficiency review)load() routes through .loading, so the compact pop leaves from a one-read spinner rather than the card. Pre-existing.Out of scope; noted in the report as a follow-up.
minorDelete arms still call dismiss() unconditionally (spec review)The two delete arms here and WorkDetailView's deletion have the same column-root exposure this fix gates around.Out of scope for this bugfix; noted in the report's Related section for a separate ticket.
nitFull-suite flake (verification run)ComposedTeachingViewModelTests.applyingRegeneratesThePreview failed once in the full make test-quick run (a 30 ms sleep before asserting the suggestion applied); untouched by this branch and green in two earlier full runs today.Passed on an isolated rerun of the suite. Recorded in docs/agent-notes/testing.md beside the similar known flake; not a regression.

Tests

Source: local run at 2026-09-05T14:40:00+10:00 · snapshot 127dfca (dirty working tree)

Baseline: none

Execution: passed · JUnit: none · Coverage: none · Baseline: absent

Coverage scope: as the project configures it

No test results

The test runner could not be detected.

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base ed9fa0e.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Per-file diffs

Click to expand.

Asterism/Asterism/Layout/EntryDetailRoute.swift Modified +5 / -1
diff --git a/Asterism/Asterism/Layout/EntryDetailRoute.swift b/Asterism/Asterism/Layout/EntryDetailRoute.swiftindex ff14e58..8bce1ae 100644--- a/Asterism/Asterism/Layout/EntryDetailRoute.swift+++ b/Asterism/Asterism/Layout/EntryDetailRoute.swift@@ -41,7 +41,11 @@ struct EntryDetailRoute: View {                     for: entryID, type: .entry,                     workload: { model.recentPresentation.duplicateWorkload }),                 exportModel: model.markdownExportModel(forEntry: entryID),-                showsSky: showsSky+                showsSky: showsSky,+                // T-2301: the two signals are one fact about the host. A pushed+                // screen paints its own sky and is left after a landed update;+                // a column of a window does neither.+                leavesAfterUpdate: showsSky             )             // See the note on the type: without this the wide tree's detail             // column keeps the first entry's `@State` models forever.
Asterism/Asterism/ViewModels/EntryDetailModel.swift Modified +14 / -5
diff --git a/Asterism/Asterism/ViewModels/EntryDetailModel.swift b/Asterism/Asterism/ViewModels/EntryDetailModel.swiftindex 38b8076..ab5dd97 100644--- a/Asterism/Asterism/ViewModels/EntryDetailModel.swift+++ b/Asterism/Asterism/ViewModels/EntryDetailModel.swift@@ -352,9 +352,18 @@ public final class EntryDetailModel {     }      /// Commits note/rating edit. Suppresses duplicate submissions.-    public func update() async {-        guard !isSubmitting else { return }+    ///+    /// Returns whether the edit landed (T-2301). A pushed screen leaves on a+    /// landed edit and stays on anything else: a refused write keeps the draft+    /// on screen, a failed one restores it (the `catch` below), and either way+    /// the message says why. The answer is the write's outcome, not a re-read+    /// of `state` afterwards: the reload that follows a landed write could fail+    /// on its own, and that is not a reason to keep the reader here.+    @discardableResult+    public func update() async -> Bool {+        guard !isSubmitting else { return false }         isSubmitting = true+        defer { isSubmitting = false }         state = .submitting         errorMessage = nil         do {@@ -373,11 +382,11 @@ public final class EntryDetailModel {                 errorMessage = Self.conflictMessage(conflict)                 state = .error(message: errorMessage ?? "")                 await onConflict(conflict)-                isSubmitting = false-                return+                return false             }             await onMutation()             await load()+            return true         } catch {             errorMessage = error.localizedDescription             // **The draft survives a vanished record.** Restoring from the last@@ -393,8 +402,8 @@ public final class EntryDetailModel {             }             state = .error(message: error.localizedDescription)             Self.logger.error("Entry update failed: \(String(describing: error), privacy: .public)")+            return false         }-        isSubmitting = false     }      /// The Delete button. **It writes nothing** — either way, the reader is
Asterism/Asterism/Views/EntryDetailView.swift Modified +15 / -2
diff --git a/Asterism/Asterism/Views/EntryDetailView.swift b/Asterism/Asterism/Views/EntryDetailView.swiftindex 4fb7aac..2a1b041 100644--- a/Asterism/Asterism/Views/EntryDetailView.swift+++ b/Asterism/Asterism/Views/EntryDetailView.swift@@ -36,16 +36,25 @@ struct EntryDetailView: View {     /// `ipad-and-mac-layouts` Req 3.1: false in the wide tree, where this screen     /// is a column of a window that already paints one sky behind all of them.     let showsSky: Bool+    /// T-2301: whether a landed update leaves the screen. True where this+    /// screen was pushed — a confirmation is the platform's "done, leave"+    /// control, and the pop lands where the reader came from. False where it+    /// is a column root: a column stays, and `dismiss` is not asked, because on+    /// a view with nothing to pop or present `DismissAction`'s contract is to+    /// close the window it is in, which on the Mac is the app's only one.+    let leavesAfterUpdate: Bool      init(         model: EntryDetailModel,         onMoveTo: @escaping () -> Void,         onResolveDuplicate: (() -> Void)? = nil,         exportModel: MarkdownExportModel? = nil,-        showsSky: Bool = true+        showsSky: Bool = true,+        leavesAfterUpdate: Bool = true     ) {         self.onResolveDuplicate = onResolveDuplicate         self.showsSky = showsSky+        self.leavesAfterUpdate = leavesAfterUpdate         _model = State(initialValue: model)         _exportModel = State(initialValue: exportModel)         self.onMoveTo = onMoveTo@@ -208,7 +217,11 @@ struct EntryDetailView: View {                 // — measured at 82 pt wide against the 36 pt of the icon-only                 // Export item beside it. The word goes to VoiceOver instead.                 Button(role: .confirm) {-                    Task { await model.update() }+                    // T-2301: a landed edit closes a pushed screen the way a+                    // confirmed delete does below; `update()` says whether it+                    // landed, and `leavesAfterUpdate` says whether there is a+                    // push to leave.+                    Task { if await model.update(), leavesAfterUpdate { dismiss() } }                 }                 // Req 2.8: a torn record's authored fields are read-only until                 // its resolution. Deleting stays available — it discloses first.
Asterism/AsterismTests/EntryDetailModelTests.swift Modified +30 / -5
diff --git a/Asterism/AsterismTests/EntryDetailModelTests.swift b/Asterism/AsterismTests/EntryDetailModelTests.swiftindex 9e8bedb..516b292 100644--- a/Asterism/AsterismTests/EntryDetailModelTests.swift+++ b/Asterism/AsterismTests/EntryDetailModelTests.swift@@ -84,11 +84,32 @@ struct EntryDetailModelTests {         await model.load()         model.draftNote = "updated note"         model.draftRating = .down-        await model.update()+        let landed = await model.update()         #expect(mock.updateEntryCallCount == 1)         #expect(mock.lastUpdateNote == "updated note")         #expect(mock.lastUpdateRating == .down)         #expect(tracker.mutationCount == 1)+        // T-2301: a landed edit is a pushed screen's cue to leave.+        #expect(landed)+    }++    /// T-2301: the flag that suppresses a double tap must clear once the write+    /// has landed, or the checkmark would stay enabled and every later tap+    /// would silently do nothing. Asserted as behaviour — two edits, two+    /// writes — rather than on the private flag.+    @Test("A second update after a landed one is written too (T-2301)")+    @MainActor func sequentialUpdatesBothLand() async {+        let (model, mock, _) = makeSUT()+        await model.load()++        model.draftNote = "first edit"+        let first = await model.update()+        model.draftNote = "second edit"+        let second = await model.update()++        #expect(first && second, "both edits land")+        #expect(mock.updateEntryCallCount == 2)+        #expect(mock.lastUpdateNote == "second edit")     }      @Test("Update failure does not optimistically mutate entry snapshot")@@ -112,11 +133,13 @@ struct EntryDetailModelTests {         await model.load()         model.draftNote = "modified"         model.draftRating = .down-        await model.update()+        let landed = await model.update()         // Drafts restored to snapshot values         #expect(model.draftNote == "original")         #expect(model.draftRating == .up)         #expect(model.errorMessage != nil)+        // T-2301: a failed write is not a reason to leave the screen.+        #expect(!landed)         guard case .error = model.state else {             Issue.record("Expected error state"); return         }@@ -173,12 +196,14 @@ struct EntryDetailModelTests {         model.draftNote = "the reader's edit"         model.draftRating = .down -        await model.update()+        let landed = await model.update()          #expect(model.draftNote == "the reader's edit")         #expect(model.draftRating == .down)         #expect(model.errorMessage != nil)         #expect(tracker.mutationCount == 0)+        // T-2301: a refused write keeps the reader on the screen with the draft.+        #expect(!landed)         guard case .error = model.state else {             Issue.record("Expected error state"); return         }@@ -565,8 +590,8 @@ struct EntryDetailModelTests {         let (model, mock, _) = makeSUT()         await model.load()         // Simulate rapid double-tap by calling update back-to-back-        async let first: () = model.update()-        async let second: () = model.update()+        async let first = model.update()+        async let second = model.update()         _ = await (first, second)         // Only one should have gone through         #expect(mock.updateEntryCallCount == 1)
Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift Modified +18 / -0
diff --git a/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift b/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swiftindex fe048d8..4d93122 100644--- a/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift+++ b/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift@@ -119,6 +119,24 @@ final class RecentAndEntryDetailUITests: XCTestCase {             app.otherElements["entry-detail-deleted"].exists, "Cancelling deletes nothing")     } +    /// T-2301: the checkmark is the platform's "done, leave" control (Q47), so+    /// a committed edit closes the screen the way a confirmed delete does.+    /// Before the fix the write landed and the screen stayed.+    func testUpdatingAnEntryReturnsToRecent() {+        openSeededEntry()++        // An actual edit, so what is committed is a change and not a no-op.+        waitFor(app.anyElement("entry-detail-rating-down"), "The rating is in the card").tap()+        // `openSeededEntry()` already waited for the control.+        app.buttons["entry-detail-update-button"].tap()++        // A committed update dismisses the detail, so the assertion is on+        // Recent — the list, since an update removes no rows.+        waitUntilGone(+            app.buttons["entry-detail-update-button"], "The detail dismisses after the update")+        waitFor(app.collectionViews["recent-list"], "Recent is back under it")+    }+     func testConfirmingTheDeletionRemovesTheEntry() {         waitFor(app.collectionViews["recent-list"], "The seeded library opens", timeout: 60)         // Counted before the push: the detail covers Recent, so the rows are out
Asterism/AsterismUITests/WorkDetailActionsUITests.swift Modified +22 / -0
diff --git a/Asterism/AsterismUITests/WorkDetailActionsUITests.swift b/Asterism/AsterismUITests/WorkDetailActionsUITests.swiftindex 8d58ff2..79665ef 100644--- a/Asterism/AsterismUITests/WorkDetailActionsUITests.swift+++ b/Asterism/AsterismUITests/WorkDetailActionsUITests.swift@@ -260,6 +260,28 @@ final class WorkDetailActionsUITests: XCTestCase {             "Back from a chapter entry does not skip the work it was opened from")     } +    /// T-2301: a landed update pops the chapter the way Back does — onto the+    /// work it was opened from, not past it. The chapter route is the one push+    /// of entry detail that sits on top of another pushed screen (Q56), so it+    /// is the one where "pop" and "pop to the root" would differ.+    func testUpdatingAChapterEntryReturnsToTheWork() {+        openWorkDetail()+        waitFor(app.anyElement("work-detail-entry"), "The chapter list is on screen").tap()+        waitFor(+            app.anyElement("entry-detail-note-editor"), "The chapter row opened entry detail",+            timeout: 20)++        waitFor(app.anyElement("entry-detail-rating-down"), "The rating is in the card").tap()+        waitFor(app.buttons["entry-detail-update-button"], "Update is offered").tap()++        waitUntilGone(+            app.buttons["entry-detail-update-button"], "The chapter dismisses after the update")+        waitFor(app.anyElement("work-detail-title"), "…and lands on the work detail")+        XCTAssertFalse(+            app.collectionViews["works-list"].exists,+            "A landed update does not skip the work the chapter was opened from")+    }+     // MARK: - The spine's two orders (`work-detail-reading-redesign`)      /// Decision 1 and Q3: both orders are visible at once, the choice is one
Asterism/AsterismUITests/WideLayoutUITests.swift Modified +40 / -0
diff --git a/Asterism/AsterismUITests/WideLayoutUITests.swift b/Asterism/AsterismUITests/WideLayoutUITests.swiftindex bb7d109..d128b1a 100644--- a/Asterism/AsterismUITests/WideLayoutUITests.swift+++ b/Asterism/AsterismUITests/WideLayoutUITests.swift@@ -260,6 +260,46 @@ final class WideLayoutUITests: XCTestCase {         waitFor(app.collectionViews["recent-list"], "The list is still beside it")     } +    // MARK: - T-2301 — a landed update leaves a push, not a column++    /// On the phone a landed update pops entry detail back to the list. Here+    /// the same screen is the detail column's root: there is nothing to pop,+    /// and the column stays on the entry — `EntryDetailRoute` hands the screen+    /// `leavesAfterUpdate: false` for exactly this host. Asserted on the+    /// `entry-detail-<uuid>` marker still being laid out inside the column+    /// once the update has settled, with the list still beside it.+    func testALandedUpdateKeepsTheEntryInTheDetailColumn() {+        launch("seeded-m1", orientation: .landscapeLeft)+        waitForLibrary()++        let row = waitFor(recentRows.element(boundBy: 0), "The seeded library lists an entry")+        let entryID = Self.entryID(fromRowIdentifier: row.identifier)+        XCTAssertFalse(entryID.isEmpty, "The row is identified by the entry it opens")+        row.tap()+        waitFor(app.anyElement("entry-detail-\(entryID)"), "The entry opens in the detail column")++        waitFor(app.anyElement("entry-detail-rating-down"), "The rating is in the card").tap()+        let update = waitFor(app.buttons["entry-detail-update-button"], "Update is offered")+        update.tap()+        // The control disables for the write and re-enables once the reload+        // lands; waiting on that is waiting for the update to have settled.+        let settled = expectation(+            for: NSPredicate(format: "exists == true AND isEnabled == true"), evaluatedWith: update)+        XCTAssertEqual(+            XCTWaiter().wait(for: [settled], timeout: 30), .completed,+            "The update lands and the screen is still there to re-enable its control")++        let detailColumn = waitFor(+            app.anyElement("wide-detail-column"), "The detail column is laid out")+        let marker = waitFor(+            app.anyElement("entry-detail-\(entryID)"), "The column still shows the entry")+        assertInsideColumn(marker, column: detailColumn, what: "The updated entry (T-2301)")+        XCTAssertFalse(+            app.anyElement("wide-detail-placeholder").exists,+            "A landed update does not clear the selection")+        waitFor(app.collectionViews["recent-list"], "The list is still beside it")+    }+     // MARK: - Q57 — a chapter replaces its work in the detail column      /// The user's interim ruling of 2026-09-02: selecting a chapter inside a
CHANGELOG.md Modified +21 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 222db8a..dceb4b5 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +### Fixed++- **Saving an entry now dismisses it (T-2301).** The navigation bar's+  checkmark on entry detail committed the note and rating and then left+  the screen where it was, from Recent, the Works list and a work's+  chapter list alike. The action had no exit: Update began as a content+  row beside Move to, and Q47 of `polish-and-export` moved it into the+  confirmation slot without revisiting what follows a confirmation.+  `EntryDetailModel.update()` now returns whether the edit landed, and+  the button dismisses on true where the screen was pushed; a refused+  or failed write still keeps the reader on the screen with the message+  (a refused one keeps the draft, a failed one restores it, as before).+  Where the screen is a column root in the wide tree the route hands it+  `leavesAfterUpdate: false` and the column stays — `dismiss` is not+  asked there, because on a view with nothing to pop its contract is to+  close the window (Q61 of `polish-and-export`). Report in+  `specs/bugfixes/save-does-not-dismiss-entry/report.md`; regression+  cover is a UI test per route shape — Recent's push, a chapter inside a+  work, and the iPad column that stays — plus return-value assertions+  on the model's existing update tests and a sequential-update test.+ ### Changed  - **Pre-push review fixes for work-and-reading-status (T-2306).** Every
specs/bugfixes/save-does-not-dismiss-entry/report.md Added +225 / -0
diff --git a/specs/bugfixes/save-does-not-dismiss-entry/report.md b/specs/bugfixes/save-does-not-dismiss-entry/report.mdnew file mode 100644index 0000000..8820752--- /dev/null+++ b/specs/bugfixes/save-does-not-dismiss-entry/report.md@@ -0,0 +1,232 @@+# Bugfix Report: Saving an Entry Does Not Dismiss It++**Date:** 2026-09-05+**Status:** Fixed+**Ticket:** T-2301++## Description of the Issue++On entry detail, the navigation bar's checkmark (`entry-detail-update-button`,+VoiceOver "Update") commits the note and rating edit and then leaves the screen+where it is. The write lands — the row in Recent updates behind the screen —+but the reader is still looking at the entry they just finished with, and has+to tap Back themselves.++It happens from every route into the screen: Recent's push, the Works list's+unattached-notes push, a chapter row inside a work, and the Merge sheet's own+stack.++**Reproduction steps:**+1. Open any entry from Recent (or from a work's chapter list).+2. Change the note or toggle the rating.+3. Tap the checkmark in the navigation bar.+4. The edit is saved; the entry screen stays open.++**Impact:** Every note edit costs an extra tap, and the confirmation control+behaves unlike every other confirmation in the app. Work detail's Save leaves+edit mode and New Work's Create dismisses; only entry detail's Update stayed.+No data is affected.++## Investigation Summary++- **Symptoms examined:** the write commits (the row behind the screen updates,+  `onMutation` fires), the screen reloads, and no navigation happens.+- **Code inspected:** `Asterism/Asterism/Views/EntryDetailView.swift` (the+  toolbar's `Button(role: .confirm)` and the two delete arms beside it),+  `Asterism/Asterism/ViewModels/EntryDetailModel.swift` (`update()`),+  `Asterism/Asterism/Layout/EntryDetailRoute.swift`, `CompactRootView.swift`,+  `WideRootView.swift`, `ListDetailPane.swift` and `ContentView.swift` for+  every host of the screen, and the git history of the Update control.+- **Hypotheses tested:**+  - *The dismissal exists and is skipped by a wrong condition.* No — the+    action is `Task { await model.update() }` and nothing follows the await.+    The delete arms, by contrast, end in `if model.entry == nil { dismiss() }`.+  - *A dismissal was removed at some point.* No — from the first commit+    (`7c7c3d7`) Update was a content row beside Move to and never dismissed;+    Q47 of `specs/polish-and-export/` moved it to the navigation bar's+    confirmation slot on 2026-08-04 without revisiting what follows it.+  - *A dismissal would break the wide layouts.* No — in the wide tree+    `EntryDetailRoute` is a `switch` arm at the root of the detail column's+    `NavigationStack`, neither pushed nor presented, where `DismissAction` is+    a no-op by contract. The compact tree pushes the route through+    `navigationDestination(item:)`, and the Merge sheet pushes it on its own+    stack, so a dismissal pops in both.++## Discovered Root Cause++The Update action's success path has no navigation exit.++**Defect type:** Missing control flow (an action with no "what happens after").++**Why it occurred:** Update started as an in-content row, where the Back+button was the way out and staying on the screen let the reader keep editing.+Q47 turned it into the `.confirmationAction` checkmark — the platform's+"done, leave" control — as a styling change applied across four screens. The+control changed; the navigation that should follow a confirmation did not.++**Contributing factors:** Entry detail has no view/edit mode split the way+work detail does, so there was no "exit edit mode" step to hang the dismissal+on. The UI tests assert the Update control exists and is enabled; none tapped+it and asserted what follows.++## Resolution for the Issue++**Changes made:**+- `Asterism/Asterism/ViewModels/EntryDetailModel.swift` — `update()` now+  returns a discardable `Bool`: true once the write committed and the reload+  ran, false on a duplicate submission, a conflict, or a thrown failure. The+  submission flag clears through a `defer`, the house pattern, rather than+  at each exit.+- `Asterism/Asterism/Views/EntryDetailView.swift` — a new `leavesAfterUpdate`+  argument (default true) says whether there is a push to leave. The+  confirmation button's action is `if await model.update(), leavesAfterUpdate+  { dismiss() }`, mirroring the two delete arms' `if model.entry == nil+  { dismiss() }`.+- `Asterism/Asterism/Layout/EntryDetailRoute.swift` — passes+  `leavesAfterUpdate: showsSky`: both signals are the one fact that the+  screen is a push rather than a column of a window.+- `Asterism/AsterismTests/EntryDetailModelTests.swift` — the duplicate-+  submission test's `async let` bindings drop their explicit `()` type so they+  take the new return value.++**Approach rationale:** the model owns the answer to "did the edit land", so+the view acts on that answer rather than re-reading `state` after the await.+Where the screen is pushed (the compact tree's three routes, the Merge sheet's+stack) `dismiss()` pops. Where it is a column root in the wide tree it is not+asked at all: `DismissAction`'s contract on a view with nothing to pop or+present is to close the window it is in, and on the Mac that is the app's+only window. The route already knows which host it is in, so the gate is one+argument and no measurement on the Mac is needed to trust it.++A refused write returns false and keeps the reader on the screen with their+draft, which is the only copy of the edit (Req 2.10 of+`duplicate-reconciliation`). A failed write returns false too; the existing+catch restores the draft from the loaded snapshot for an ordinary failure and+keeps it for a vanished record. Either way the message under the note says+why, and that is unchanged.++**Alternatives considered:**+- *Read `model.state` after the await and dismiss on `.ready`* — rejected:+  it couples the exit to the reload that follows the write rather than to+  the write itself, and a reload failure after a landed write would leave+  the reader on a screen showing an error for an edit that succeeded.+- *A `didUpdate` flag observed with `.onChange`, as six sibling screens do* —+  rejected in favour of the return value: this screen's own delete arms+  already act imperatively after their await, and `EntryDetailModel.State`+  is overwritten by the reload so a terminal state case would not survive.+- *Rely on `dismiss()` being a no-op for a column root* — the first version+  of this fix. Rejected by the pre-push review: the no-op is not what+  `DismissAction` documents for a windowed host, and it had not been+  measured on the iPad or the Mac. The gate replaces an assumption with a+  fact the route already holds.+- *In the wide tree's chapter case, clear `selectedWorkChapterEntryID` after+  a save* — not done: a column stays put, and the chapter's+  `ColumnBackButton` is its way back. Worth revisiting only if the owner+  finds the chapter column lingering after a save on iPad.++## Regression Test++**Test file:** `Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift`+**Test name:** `testUpdatingAnEntryReturnsToRecent`++**What it verifies:** opening a seeded entry from Recent, toggling the rating+and tapping Update dismisses the detail and lands back on the Recent list.+Failed before the fix: "The detail dismisses after the update" timed out+with the Update control still on screen.++**Run command:**+`make test-only TEST=AsterismUITests/RecentAndEntryDetailUITests/testUpdatingAnEntryReturnsToRecent`++**Test file:** `Asterism/AsterismUITests/WorkDetailActionsUITests.swift`+**Test name:** `testUpdatingAChapterEntryReturnsToTheWork`++**What it verifies:** the ticket's "from the works view" route. A chapter+opened from a work is the one push of entry detail that sits on another+pushed screen, and a landed update lands on the work, not past it.++**Test file:** `Asterism/AsterismUITests/WideLayoutUITests.swift`+**Test name:** `testALandedUpdateKeepsTheEntryInTheDetailColumn`++**What it verifies:** the other half of the gate. In the wide tree the screen+is a column root, and a landed update leaves the entry in the column with the+list beside it. Runs on the iPad simulator:+`make test-only TEST=AsterismUITests/WideLayoutUITests/testALandedUpdateKeepsTheEntryInTheDetailColumn DESTINATION="platform=iOS Simulator,name=iPad Pro 11-inch (M5)"`++**Test file:** `Asterism/AsterismTests/EntryDetailModelTests.swift`+**Test names:** `updateCommits`, `conflictKeepsTheDraft`,+`updateFailureNoOptimistic` (each gained an assertion on the return value),+and `sequentialUpdatesBothLand` (new)++**What they verify:** the model's `update()` answers whether the edit landed —+true for a committed write, false for a conflict and for a thrown failure — so+the view can dismiss on the model's word rather than on a re-read of `state`.+The sequential test pins that a second edit after a landed one is written too,+which is what the submission flag's reset amounts to as behaviour. The+investigation checkpoint carried three separate T-2301 tests for the return+value; the pre-push review folded them into the existing scenarios they+duplicated.++**Run command:**+`make test-only TEST=AsterismTests/EntryDetailModelTests`++## Affected Files++| File | Change |+|------|--------|+| `Asterism/Asterism/ViewModels/EntryDetailModel.swift` | `update()` returns whether the edit landed; flag reset via `defer` |+| `Asterism/Asterism/Views/EntryDetailView.swift` | `leavesAfterUpdate`; Update dismisses a pushed screen on a landed edit |+| `Asterism/Asterism/Layout/EntryDetailRoute.swift` | Passes the gate from the host it already knows |+| `Asterism/AsterismTests/EntryDetailModelTests.swift` | Return-value assertions on three existing tests; sequential-update test; duplicate-submission bindings retyped |+| `Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift` | `testUpdatingAnEntryReturnsToRecent` |+| `Asterism/AsterismUITests/WorkDetailActionsUITests.swift` | `testUpdatingAChapterEntryReturnsToTheWork` |+| `Asterism/AsterismUITests/WideLayoutUITests.swift` | `testALandedUpdateKeepsTheEntryInTheDetailColumn` |+| `specs/polish-and-export/decision_log.md` | Q61 records what follows Q47's confirmation on entry detail |+| `CHANGELOG.md` | Fixed entry |++## Verification++**Automated:**+- [x] Regression tests pass — `testUpdatingAnEntryReturnsToRecent` failed+  before the fix (the detail stayed) and passes after it;+  `testUpdatingAChapterEntryReturnsToTheWork` passes on the iPhone+  simulator and `testALandedUpdateKeepsTheEntryInTheDetailColumn` on the+  iPad simulator; the `EntryDetailModel` suite passes.+- [x] `make test-quick` passes: the macOS build succeeds and the unit+  bundle runs 1,198 tests in 87 suites (2026-09-05, after rebasing onto+  main at T-2306 and after the pre-push review's fixes). One unrelated+  timing flake in `ComposedTeachingViewModelTests` failed in that full run+  and passed on an isolated rerun; it is recorded in+  `docs/agent-notes/testing.md`.+- [x] No new compiler warnings: the changed Swift files were touched before+  an unprettified `make test-quick`, the raw log carries pre-existing+  warning lines as a control, and none is in a changed file.++**Manual verification:**+- On the phone, from the `Development` install the owner made from this+  branch: "seems to work" (2026-09-05).+- The Mac was not run. The gate means `dismiss()` is never called from a+  column root, so the window-close contract is avoided by construction+  rather than measured.++## Prevention++- When a control moves into a system slot with its own semantics (a+  confirmation action, a cancellation action), check what the platform+  convention says happens *after* it, not only how it looks.+- A UI test for a control should exercise the control, not only find it.++## Related++- T-2301 in Transit.+- Q47, Q55 and Q61 in `specs/polish-and-export/decision_log.md` (the move+  to the confirmation slot, the label-less confirmation button, and what+  follows the confirmation on entry detail).+- Follow-up worth its own ticket, found by the pre-push review and out of+  this bugfix's scope: the reload after a landed write routes through+  `.loading`, so the pop animates away from a brief spinner rather than the+  card. Pre-existing; the pop only makes it more noticeable.+- Also pre-existing and out of scope: the two delete arms on this screen and+  `WorkDetailView`'s deletion call `dismiss()` unconditionally, including+  from a column root. If a delete from the Mac's detail column ever closes+  the window, that is the same `DismissAction` contract this fix gates+  around, and the same gate applies.
specs/polish-and-export/decision_log.md Modified +1 / -0
diff --git a/specs/polish-and-export/decision_log.md b/specs/polish-and-export/decision_log.mdindex 0ad1c65..ad07502 100644--- a/specs/polish-and-export/decision_log.md+++ b/specs/polish-and-export/decision_log.md@@ -64,6 +64,7 @@ | Q58 | 2026-08-05 | Work detail's title renders twice: as a wrapping serif heading (`work-detail-title`, no `lineLimit`) at the head of the view-mode header card, and as the navigation bar's `.inline` collapsed form | User feedback after device install: long serial names were truncated with nowhere to read them in full. A navigation large title is one line whatever its length — the platform gives no way to wrap it — so the full title moves into content, where it can wrap, and the bar keeps the short form it is good at. `.inline` rather than `.large` because a large title would be a second, *truncated* copy of the heading directly above it; a bar title that truncates is standard iOS. Style-guide §10's "truncate rather than wrap" is scoped to rows and stays true of them | | Q59 | 2026-08-05 | Edit mode hides the system back chevron (`.navigationBarBackButtonHidden(model.isEditing)`); the X is the only dismiss control and it returns to *view mode*, never popping the screen | User feedback after device install: the bar offered two ways out that did different things — one discarding the draft into view mode, the other leaving the work entirely with the draft's fate unstated. Q55 left pushed screens relying on the back chevron for closes, which holds for a screen with one mode; a mode has to be left before the screen is. Hiding the button also disables the interactive back-swipe, so the mode has exactly one exit per direction | | Q60 | 2026-08-05 | The share extension's capture sheets commit from the bar too: `CaptureView`'s Save and `ReShareCaptureView`'s Update become the Q47/Q55 label-less `Button(role: .confirm)` in `.confirmationAction`, beside the X those sheets already carry. `capture.save`, `capture.retry` and `reshare.update` keep their identifiers and accessibility labels; `capture.retry` is now the same checkmark under the failed state's identity rather than a second control in the content | User feedback after device install: the extension was the last place a gradient Save survived, so the one sheet a reader sees most often was the one that did not follow the app's convention. Q47's reasoning applies unchanged. Nothing had to move with it — both sheets already replace their whole content with a "Saving…" / "Updating…" spinner while the write is in flight, so the busy indication was never on the button; the checkmark is offered in exactly the states that carried a bottom button and carries those buttons' disabled conditions verbatim |+| Q61 | 2026-09-05 | What follows Q47's confirmation on entry detail: a landed update leaves a **pushed** screen (`dismiss()` pops to Recent, the Works list, or the work a chapter was opened from), and a refused or failed one stays with the message. Where the screen is a column root in the wide tree, `EntryDetailRoute` passes `leavesAfterUpdate: false` and the column stays; `dismiss()` is not called there at all | T-2301: Q47 moved Update into the confirmation slot as a control swap and never revisited what follows a confirmation, so the edit landed and the screen stayed. The column case is a gate rather than a reliance on `dismiss()` being a no-op: `DismissAction`'s documented contract on a view with nothing to pop or present is to close the window it is in, which on the Mac is the app's only window. Report: `specs/bugfixes/save-does-not-dismiss-entry/report.md` |  ## Decision 1: Amber extends to duplicate-review surfaces as the "actionable attention" accent 
docs/agent-notes/testing.md Modified +11 / -0
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 5cf7078..be5cd09 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -51,6 +51,17 @@ change was the share extension plist; it passed in isolation and on the full rerun. One isolated failure of this test is not a regression signal; rerun before investigating. +## Known flaky unit test: "Applying a suggestion regenerates the preview for the whole hostname"++`ComposedTeachingViewModelTests.applyingRegeneratesThePreview` (AsterismTests,+simulator) sleeps 30 ms after `load()` and then asserts the held suggestion has+been applied to the projection request. Under full-suite load the apply had not+landed yet and the request still carried the `.segment` definition — seen+2026-09-05 on the T-2301 branch, whose changes are entirely in entry detail. It+passed on an isolated rerun of the suite and in two full runs the same day. One+isolated failure of this test is not a regression signal; rerun before+investigating.+ ## Adding recorded state to `MockLibraryProvider` needs a lock  `MockLibraryProvider` is `@unchecked Sendable` and its counters are plain

Things to double-check

Mac save from the detail column.

No automated test drives the Mac. The gate means dismiss() is never called from a column root, so the window-close risk is removed by construction. A Mac run is the owner's to do under the CLAUDE.md rule, if wanted.

The iPad test's settle condition.

It waits for the Update control to exist and be enabled again after the tap. If a future change keeps the control disabled after a landed write, the test would fail for that reason rather than for the column moving; the message says so.