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).
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.
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.
4ad0f2a T-2301: Add investigation report and failing regression tests for save-does-not-dismiss-entry 127dfca T-2301: Dismiss entry detail after a landed update working-tree Fixes applied in this review 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.
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.
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.polish-and-export records the behaviour.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.
.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.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 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.
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.
dismiss() call is made from a column root, so the window-close risk is removed by construction rather than by measurement.survivorDiverged conflict after a redirect is the one refusal reachable with the button enabled; it returns false and stays, which the model test covers.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
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
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()
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
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.
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.
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.
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.
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.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | Wide-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. |
| minor | Wording 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). |
| minor | Report 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. |
| minor | Test 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. |
| minor | Missing 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. |
| minor | Chapter 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. |
| nit | isSubmitting reset at three sites (reuse and quality reviews) | House pattern is defer immediately after setting the flag. | Replaced with defer { isSubmitting = false }. |
| nit | View comment restated the model's contract (quality review) | The middle sentence duplicated update()'s doc comment. | Trimmed to the view-specific content. |
| nit | Redundant waitFor in the Recent UI test (efficiency review) | openSeededEntry() had already waited for the Update control. | Replaced with a direct tap. |
| minor | Bool 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. |
| minor | Pop 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. |
| minor | Delete 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. |
| nit | Full-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. |
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
The test runner could not be detected.
Derived by declaration name, from the diff (no baseline run).
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.
Click to expand.
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.
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
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.
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)
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
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
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
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
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.
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
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
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.
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.