Carves paywall presentation state out of the app-wide StoreManager onto a per-scene PaywallPresenter, so each window (and the macOS Settings scene) owns its own sheet and its own post-purchase retry. Reviewed against origin/main across three prior review rounds; this pass adds a wiring pin, one requirement-backed hardening, and a documentation sweep.
showPaywall / pendingExportAction / didCompletePurchase describe one window, but lived on an app-wide object — so every window's sheet bound to one flag. Nothing in the type system objected. Moving them onto a per-scene PaywallPresenter makes the defect unreintroducible by adding another call site.ExportNotesFlow, deliberate and covered by a non-vacuous teardown test..paywallPresentation(...) from either scene compiled, passed the entire suite, and shipped a paywall that could never appear. Now pinned by source-structural tests, following the FootnotePresentationHostTests precedent (T-1893) and the T-1943 lesson.present() (the Settings unlock row) now clears any stranded retry, so a Settings-initiated purchase cannot run an export the user never asked for on that screen.docs/agent-notes/inapp-purchase.md still described StoreManager as owning the paywall flags and documented a gating helper that had moved — the first thing a future session reads.didCompletePurchase narrowed from app-wide to per-scene along with everything else, so a purchase completed in a different scene no longer marks the blocked scene. On macOS the user can buy from the Settings window while window A's paywall is up, and A's queued export is then silently discarded. Documented in Decision 13 with the trade-off spelled out; left unchanged because the obvious fix drops the "purchased then dismissed" versus "swiped away" distinction.Ready to push
One judgement call is flagged for the author below — it is documented rather than fixed, and does not block the push.
The split is the right one and it is done well. StoreManager keeps account-scoped truth (entitlement, products, counter, checkExport()); presentation — which sheet is up, and which export to re-run — moves to a per-scene object, which is exactly the scope those facts actually have. The T-1868 observation clock and every entitlement writer are untouched. Memory behaviour is better than before, not merely no worse: a pending retry used to sit on an immortal singleton for the life of the app and now dies with its window.
No blockers and no correctness defects were found. The two capture rules the earlier rounds established — weak on the presenter, strong on the models — were independently verified by tracing the retain graph, including the chain through ExportNotesFlow to DocumentLayoutCoordinator, which terminates correctly because onBanner is itself [weak self].
One behavioural narrowing surfaced and is documented rather than changed: didCompletePurchase became per-scene along with the rest, but entitlement stayed app-wide, so a purchase made in a different scene no longer marks the blocked one. Fixing it means dropping the "purchased then dismissed" versus "swiped away" distinction — a semantics call that predates this split and belongs to the author, not to a pre-push edit.
This pass fixed the one structural gap worth fixing before push: nothing pinned that either scene actually attaches the paywall host. That is the precise failure shape this repo already has a name for — T-1943, where a whole recovery path was dead in production while its unit tests stayed green — and the repo already has the answer for it in FootnotePresentationHostTests. It is now pinned both ways. One requirement-backed hardening (present() now clears a stranded retry, per req 6.8) and a documentation sweep of the stale agent note and spec docs round it out.
Remaining suggestions are follow-ups, not gates: the retry-capture prologue could be made structural rather than review-enforced by passing the presenter into the closure, and the File > Export flow could move onto a coordinator-owned object the way the share flow already has. Both change signatures at three call sites; neither belongs in this PR.
b70dacc Fix T-1779: scope paywall presentation to the initiating scene f393912 Fix PR #366 review: don't let a pending retry retain its window's presenter 98c7d2e Fix PR #366 review: let a paid-for share retry survive the document screen working-tree Fixes applied in this review Prism gives you 20 free annotation exports. After that, an unlock screen (a "paywall") appears. Before this change, the app kept track of that unlock screen in one single place shared by the whole app — but you can have several document windows open at once on a Mac or iPad.
Because there was only one place to record "the unlock screen is showing", every window read the same note. So hitting the limit in one window put the unlock screen in front of every window. Closing it anywhere closed it everywhere. And the app only had room to remember one waiting export, so if you hit the limit in a second window, the first window's waiting export was quietly forgotten — meaning you could pay and have the wrong export run, or none at all.
The fix gives each window its own note. What you have bought, and how many free exports remain, are still shared across the whole app — those are facts about your account, not about a window.
The worst version of the old bug is the one where you pay money and the thing you paid for doesn't happen, with no error message to tell you. That is the failure this change removes.
StoreManager is an app-wide @Observable @MainActor singleton held as @State on PrismApp and injected into every scene. It carried three presentation fields — showPaywall, pendingExportAction, didCompletePurchase — alongside genuinely account-scoped state. Each window's MainContentView bound its own .sheet to that one flag.
The change splits by scope. StoreManager keeps entitlement, products, the export counter, and the gate decision checkExport(). A new PaywallPresenter owns isPresented, pendingExportAction, didCompletePurchase, and the gated-export runner runGatedExport(storeManager:retry:perform:). Each scene creates its own, injects it into the environment, and attaches a shared paywallPresentation(...) host modifier that owns the sheet and calls handleDismiss(entitlementState:).
extension View host. Matches the existing FootnotePresenter / View.footnotePresentation(coordinator:session:) shape, so the sheet and its dismissal rule exist in exactly one implementation that both scenes apply.@Observable as @State. Matches DocumentFlowCoordinator and RemoteContentCoordinator, already per-window in the same view.handleDismiss was view code inside MainContentView; it is now a method with unit tests covering req 6.7's two-part condition (purchased and .unlocked).The decision log records the rejected alternative honestly: an owner token on StoreManager keeps the state app-wide and tags it with the requesting scene. That was rejected because only one request could exist app-wide — a second window's blocked export would still displace the first — and because correctness would depend on every future call site remembering to pass and check the token. The chosen design has no app-wide flag left to bind to.
The cost is a footgun in the retry closures. Because pendingExportAction lives on the presenter, a strong capture is a self-retain cycle that now leaks a window; but everything else the retry needs must be captured strongly, because the presenter outlives the document screen. Weak on the presenter, strong on the models. Both halves were got wrong once during review, which is a fair signal about how easy it is to get wrong.
The defect was not a bug in any one line — every individual call site was correct. It was a scope mismatch that the type system had no opinion about: a per-window fact stored on an app-wide object. Storing it there made the wrong behaviour the default for every future call site, and no local reading of any call site would reveal it. The fix's real merit is that after it, there is no app-wide flag left for two windows to bind to, so the class of defect is closed rather than the instance.
The interesting engineering is in the capture rules, which pull in opposite directions:
pendingExportAction is stored on the presenter, so capturing the presenter strongly is a self-cycle. Harmless while the presenter was immortal; now it leaks the presenter and everything the retry captured, per window torn down with a blocked export's paywall up. Hence [weak paywall] at all three sites.ExportNotesFlow is document-scoped (owned by DocumentLayoutCoordinator, itself @State in DocumentReaderView); the presenter is window-scoped. So [weak self] on the flow made a navigate-back / document-switch / requestClose() a silent no-op: purchase completes, handleDismiss fires the retry, self?.run(...) no-ops, and the user has paid for nothing with no error surfaced. Hence [self, weak paywall].I verified the chain terminates: presenter → retry → ExportNotesFlow → {pendingShareContext, onBanner}, and onBanner is assigned { [weak self] … } on the coordinator, so it never reaches back to the coordinator or the view. Not a cycle, and the net-new retention is one small ExportNotesFlow — the models it holds (session, notesManager, settings, storeManager) were already captured strongly by the old closure.
DocumentReaderView's File > Export needed the same treatment but had an implicit capture: the view holds this window's presenter through @Environment, so capturing self captures the presenter. The restructure to a static runGatedFileExport plus closure-valued computed properties (exportResumeAction, performFileExport) that rebind each dependency by hand is the mechanism that severs it. I confirmed neither property is evaluated during body — both are reached only from action closures — so there is no per-render allocation.
exportUsername was ever set, the surviving retry can only ask for one, and its alert host is gone. That is a property of the alert-hosting design (notes-action-placement Decision 13), not of the capture — and the identical shape exists on the macOS File > Export path, which this review cross-referenced at the call site. The share path, taken by every user who has exported before, completes.present() set isPresented without clearing the retry. Unreachable today because every dismissal path clears it — but reachable the moment a .blocked gate's sheet fails to present (sheet-over-sheet on iOS), at which point a Settings-initiated purchase fires a stale export, violating 6.8's "SHALL dismiss without triggering any export action". Now cleared structurally, with a test.@Environment(PaywallPresenter.self) non-optionally. I traced every path: the iOS Settings sheet inherits the value because .environment(paywall) is applied after the .sheet in the modifier chain and therefore wraps it. That is correct but is a subtlety a future reordering could silently break, so this review injected it explicitly at the settings sheet as well — belt and braces, the same reason .applyTheme is already re-applied there.Every test drove PaywallPresenter by direct invocation, and a direct-invocation test cannot see missing wiring. Deleting .paywallPresentation(...) from either scene compiles, passes the entire suite, and ships a paywall that never appears; dropping the .environment(...) injection instead traps at runtime. This repo has been bitten by exactly this before (T-1943, where crash recovery was dead code in production through the cutover and every review) and has a house answer for it (FootnotePresentationHostTests, T-1893). Both halves are now pinned by source-structural reads of prismApp.swift.
prism/Views/PaywallPresenter.swift
Why it matters. The whole defect in one file. isPresented / pendingExportAction / didCompletePurchase are per-window facts that lived on an app-wide object, so every window's sheet bound to one flag. Read runGatedExport closely: the gate decision stays on StoreManager.checkExport() and only the presentation side effect is per-scene — that separation is what keeps entitlement and the export counter app-wide.
What to look at. PaywallPresenter.swift:29-125 (state + runGatedExport + handleDismiss), :140-165 (PaywallHost + paywallPresentation)
prism/Views/DocumentLayoutCoordinator.swift
Why it matters. This is the subtlest thing in the PR and both halves were got wrong once during review. The retry must hold the presenter WEAKLY (it is stored on the presenter — a strong capture is a self-cycle that now leaks a window) and everything else STRONGLY (the presenter outlives the document screen, so a weakly-held owner makes a paid-for export a silent no-op). ExportNotesFlow.run therefore captures [self, weak paywall].
prism/Views/DocumentReaderView.swift
Why it matters. The File > Export retry captured `self`, and the view holds this window's presenter through @Environment — so it was the same self-retain cycle, just invisible. The fix makes the gate loop a static function holding the presenter weakly, and turns the resume/save-panel halves into closure-valued computed properties that rebind each dependency explicitly rather than reaching through `self`.
What to look at. DocumentReaderView.swift:470-506 (triggerExport + static runGatedFileExport), :508-536 (exportResumeAction), :578-620 (performFileExport)
prismTests/PaywallPresenterTests.swift
Why it matters. Every existing test drove PaywallPresenter by direct invocation, and a direct-invocation test cannot see missing wiring. Deleting .paywallPresentation(...) from either scene compiles, passes the entire suite, and ships a paywall that can never appear. This repo has a name for that failure (T-1943) and a house answer for it (FootnotePresentationHostTests, T-1893).
What to look at. PaywallPresenterTests.swift:344-403 (bothScenesAttachThePaywallHost, bothScenesInjectTheirPresenter)
prism/Views/PaywallPresenter.swift
Why it matters. present() — the Settings 'Unlock' row — is documented as presenting 'without a pending export', but only set isPresented. Requirement 6.8 says a Settings-initiated purchase SHALL dismiss without triggering any export action. That held only because every dismissal path happens to clear the retry.
What to look at. PaywallPresenter.swift:58-72 (present); PaywallPresenterTests.swift:84-112 (presentClearsAStrandedPendingExport)
docs/agent-notes/inapp-purchase.md
Why it matters. The agent note still described StoreManager as owning showPaywall / pendingExportAction / didCompletePurchase, still documented StoreManager.runGatedExport with a code sample, and still described the sheet as attached at MainContentView with handlePaywallDismiss. Per the project's own convention, a stale note is worse than no note.
What to look at. docs/agent-notes/inapp-purchase.md:16, :19, :24, :42-60 (rewritten, plus a new section stating the capture rule and the accepted residual)
Decision 13 in specs/inapp-purchase/decision_log.md. Keeping the state app-wide and tagging it with the requesting scene's id was rejected on two grounds: only one paywall request can exist app-wide, so a second window's blocked export must still displace the first (the ticket's own scenario stays broken); and correctness would depend on every future call site remembering to pass and check the token. Moving the state removes the shared flag entirely.
Also rejected in Decision 13: putting the state on DocumentLayoutCoordinator, which is already per-document and already owns ExportNotesFlow. Rejected because it is per-document, not per-window, and the paywall must also be presentable from the home screen and the macOS Settings scene, neither of which has a coordinator. This is also why the retry's lifetime problem exists at all — the flow is document-scoped while the presenter is window-scoped.
Established across the two review-fix commits and documented on pendingExportAction. The two halves have opposite justifications — cycle avoidance one way, lifetime survival the other — which is why stating it in one place, where the next call site will look, matters more than the individual capture lists. This review added the same statement to the agent note.
If the document screen is torn down and no exportUsername was ever set, the surviving retry can only ask for one and its alert host is gone. Accepted because it is a property of the alert-hosting design (notes-action-placement Decision 13), not of this change, and the share path — every user who has exported before — completes. This review added it to Decision 13's Negative consequences, since it is a documented divergence from req 6.7 and had lived only in a code comment, and cross-referenced the identical shape on the macOS File > Export path.
Rather than the presenter holding a reference to it. This keeps PaywallPresenter free of any dependency on the store, so it stays trivially constructible in tests and in previews, and it keeps the gate decision visibly on StoreManager.checkExport() rather than appearing to move with the presentation. Not stated by the author.
No view renders from it, so tracking it would only add registrar overhead per gate call. Correct — but the original comment justified it as avoiding invalidation of 'every reader on each gate call', which misstates how Observation works (it is per-property, and there are no readers). The rationale was corrected in this review; the annotation itself is right.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | prismTests/PaywallPresenterTests.swift — scene wiring | Nothing pinned that either scene attaches .paywallPresentation(...) or injects its presenter. Every test drove PaywallPresenter directly, and a direct-invocation test cannot see missing wiring: removing the host from either scene compiles, passes the whole suite, and ships a paywall that can never appear (a missing injection instead traps, since five views read the value non-optionally). This is the T-1943 failure shape, for which the repo already has a house answer. | Added bothScenesAttachThePaywallHost and bothScenesInjectTheirPresenter — source-structural reads of prismApp.swift following the FootnotePresentationHostTests (T-1893) precedent. Verified green and non-vacuous (the host assertion counts exactly 2 occurrences). |
| major | docs/agent-notes/inapp-purchase.md | The agent note — the first thing a future session reads — still described StoreManager as owning showPaywall / pendingExportAction / didCompletePurchase, still documented StoreManager.runGatedExport with a worked code sample, and still described the sheet as attached at MainContentView with onDismiss: handlePaywallDismiss. The project's own convention is that a stale note is worse than no note. | Rewrote the StoreManager entry, added a PaywallPresenter entry, rewrote the App-wiring entry, and retargeted the gating-helper section — plus a new section stating the weak-presenter/strong-models capture rule and the accepted residual, which is exactly the non-obvious knowledge agent-notes exist to carry. |
| minor | prism/Views/PaywallPresenter.swift — present() | present() is documented as presenting 'without a pending export' but only set isPresented, leaving req 6.8 ('a Settings-initiated purchase SHALL dismiss without triggering any export action') true only because every dismissal path happens to clear the retry. Reachable once a .blocked gate's sheet fails to present (sheet-over-sheet on iOS): the stranded retry then fires on a later Settings purchase. | present() now clears pendingExportAction, making the requirement structural. Requirement 6.8 was read directly to confirm the intended semantics first. Covered by presentClearsAStrandedPendingExport. |
| minor | prism/prismApp.swift — iOS settings sheet | SettingsView reads @Environment(PaywallPresenter.self) non-optionally, so it traps rather than degrading if the value fails to reach it. It relied on .environment(paywall) being applied after the .sheet in the modifier chain and therefore wrapping it. That is correct SwiftUI behaviour — verified — but it is a modifier-ordering subtlety whose failure mode is a crash on opening Settings. | Injected the presenter explicitly on the settings sheet content, the same way .applyTheme is already re-applied there. One line, removes the ordering dependence entirely. |
| minor | specs/inapp-purchase/decision_log.md — Decision 13 | The accepted divergence from req 6.7 (the username-prompt residual after document-screen teardown) lived only in a code comment. Project convention puts accepted divergences from an acceptance criterion in the decision log. The two-sided capture rule and its review-enforced nature were also unrecorded as a consequence. | Added both to Decision 13's Negative consequences, plus a note in Impact recording that the scene wiring is now pinned by source-structural tests and why. |
| minor | prism/Views/DocumentReaderView.swift — exportResumeAction | The macOS File > Export path carries the identical username-prompt residual as the share path (it writes through @State bindings whose alerts are hosted on the view that may be gone), but the doc comment did not mention it — so a reader would believe the residual was confined to ExportNotesFlow. | Cross-referenced the residual at the declaration, pointing to ExportNotesFlow.run and Decision 13. |
| minor | specs/inapp-purchase/implementation.md + design.md | As-built and design docs still asserted StoreManager owns the paywall flags, showed storeManager.runGatedExport code samples, and — in design.md — made the specific claim Decision 13 overturns ('attached at MainContentView … ensuring it can present from both document views and Settings', which also overlooks that macOS Settings is a separate scene). | Corrected the factually wrong statements in implementation.md and added 'Superseded by Decision 13' pointers to both, rather than rewriting point-in-time artefacts wholesale. |
| minor | Stale API names in doc comments | Three comments still named the moved API: prismApp.swift (ExportFileButton, 'StoreManager.runGatedExport'), ExportFileMenuActionTests.swift header, and ExportNotesFlowTests.swift header ('StoreManager.pendingExportAction'). | Retargeted all three to PaywallPresenter. |
| nit | prism/Views/PaywallPresenter.swift — @ObservationIgnored rationale | The annotation is correct, but its stated reason ('a closure-valued property would invalidate every reader on each gate call') misstates Observation, which is per-property — and there are no readers of this property at all. | Reworded to the actual reason: no view renders from it, so tracking it would only add registrar overhead. |
| nit | prismTests/PaywallPresenterTests.swift + StoreManager.swift | An unused `let store = makeStore()` in settingsUnlockRequestIsScopedToItsScene; a makeWindows doc comment promising a shared StoreManager the helper does not create; and a stray blank line left in StoreManager by the runGatedExport removal. | All three cleaned up. |
| major | prism/Views/PaywallPresenter.swift — handleDismiss, cross-scene purchase | didCompletePurchase narrowed from app-wide to per-scene along with the rest of the presentation state, but entitlement did not. A purchase completed in a different scene therefore does not mark the blocked scene's presenter. On macOS, where sheets are window-modal: window A blocks an export, the user leaves A's paywall up, buys or restores from the Settings window, returns to A and dismisses — A's queued export is silently discarded, where the old app-wide flag would have run it. The suite does not catch this because twoBlockedExportsKeepIndependentRetries sets the second window's flag by hand. | Documented in Decision 13 with the trade-off stated, and the CHANGELOG's 'a purchase always finishes the export that prompted it' softened to the claim that actually holds. Not changed in code: keying handleDismiss on entitlementState == .unlocked alone would fix it (a pending retry exists only because the gate blocked while locked-and-over-limit, so being unlocked at dismissal already means entitlement arrived in between) but would also drop the 'purchased then dismissed' versus 'swiped away' distinction that flag exists to draw. That trades one edge case for another and changes semantics predating this split — a call for the author, not a pre-push edit. |
| minor | prism/Views/PaywallPresenter.swift — pendingExportAction access | pendingExportAction was internally settable, so the weak-capture contract documented on it could be broken from outside the class by assigning a strongly-capturing closure directly — the exact hole the doc comment warns about. | Made private(set). All four legitimate writers are methods on the class; tests only read and invoke it, so nothing was lost. |
| major | PaywallPresenter.runGatedExport — retry capture contract | The weak-presenter prologue (`retry: { [weak paywall] in guard let paywall else { return } … }`) is hand-written at all three call sites, each with a paragraph re-explaining it, and the rule is enforced only by prose on pendingExportAction. A future call site that forgets two words leaks a window. Passing the presenter into the retry — `retry: @escaping (PaywallPresenter) -> Void`, with the presenter capturing itself weakly once — would make the rule structural, which is the same argument Decision 13 uses for the split itself. | Not applied. It changes the signature at three call sites and would require editing tests that pass `retry: {}` — and a refactor that forces test changes is one to reconsider rather than land during a pre-push pass. Raised as a follow-up; the current form is correct, documented, and covered by two non-vacuous lifetime tests. |
| minor | prism/Views/DocumentReaderView.swift — duplicated export flow | exportResumeAction duplicates the 'no username → prompt, else perform' branch already implemented in ExportNotesFlow.run, and the view's showExportUsernamePrompt / exportUsernameInput / showExportValidationError / exportError duplicate ExportNotesFlow's equivalents. The closure-valued-computed-property device exists only to avoid capturing self; a coordinator-owned object (the pattern the share flow already uses) would need no weak dance at all. | Not applied — the duplication is pre-existing and unifying the two flows is a refactor well beyond this bugfix. Worth a follow-up ticket. |
| minor | prismTests — makeStore helper duplication | makeStore is now copy #4 across PaywallPresenterTests, CopyNotesButtonTests, ExportNotesFlowTests, and ExportFileMenuActionTests, with no shared helper — despite these same suites already sharing MockKeyValueStore, MockNotesStore, and NotesManager.makeForTesting. | Not applied — a test-only refactor touching four suites, out of scope for a pre-push pass on a bugfix. Worth folding into the follow-up above. |
| nit | Naming and file location | The two existing hosted presentations name the ViewModifier XPresenter (FootnotePresenter, MediaZoomPresenter); here PaywallPresenter is the state class and the modifier is PaywallHost, so a reader who knows the others looks for a modifier and finds a model. The class's closest peers (DocumentFlowCoordinator, RemoteContentCoordinator) live in prism/ViewModels/. | Not applied — DocumentLayoutCoordinator in Views/ is mixed precedent, and renaming a new public type at pre-push time costs more than the inconsistency does. |
| minor | No UI-level paywall coverage | prismUITests contains no paywall test at all, so no test exercises an actual sheet presentation end to end. The new source-structural pins close the 'is it wired' gap but not the 'does it present' one. | Not applied — a StoreKit-driven UI test is a substantial piece of work and a poor fit for this bugfix. Noted for the backlog; the structural pins are the high-value 80% and are in place. |
Click to expand.
diff --git a/prism/Views/PaywallPresenter.swift b/prism/Views/PaywallPresenter.swiftnew file mode 100644index 0000000..d375816--- /dev/null+++ b/prism/Views/PaywallPresenter.swift@@ -0,0 +1,164 @@+//+// PaywallPresenter.swift+// prism+//+// Per-scene owner of paywall presentation and the post-purchase retry+// (T-1779).+//++import SwiftUI++/// Owns everything about the paywall that belongs to ONE scene: whether the+/// sheet is presented, the export to retry once a purchase completes, and+/// whether that purchase actually happened.+///+/// `StoreManager` stays app-wide because entitlement, products, and the export+/// counter describe the *account*, not a window. Presentation does not: a+/// blocked export happens in one window, and only that window may present the+/// paywall and only that window's export may be retried afterwards. Holding+/// the presentation flags on the shared `StoreManager` made every window's+/// sheet binding read one flag, so a paywall raised in window A presented (and+/// dismissed) in every window, a second blocked export overwrote the first+/// window's pending retry, and a dismissal anywhere discarded it (T-1779).+///+/// Each scene creates its own instance and injects it into the environment;+/// `PaywallHost` attaches the sheet. Two windows can therefore hold two+/// independent paywall requests without either one seeing the other.+@Observable+@MainActor+final class PaywallPresenter {+ /// Drives this scene's paywall sheet. Bound from the view layer.+ var isPresented: Bool = false++ /// Set to `true` by the paywall when a purchase (or a restore that lands+ /// on `.unlocked`) succeeds, so `handleDismiss` can distinguish+ /// "purchased, then dismissed" from "swiped away".+ var didCompletePurchase: Bool = false++ /// The export to re-run after a successful purchase. Not observed — no+ /// view renders from it, and a closure-valued property would invalidate+ /// every reader on each gate call.+ ///+ /// Whatever is stored here must hold this presenter WEAKLY: the closure+ /// lives on the presenter, so a strong capture — including an implicit+ /// one, such as a SwiftUI view that reads the presenter from+ /// `@Environment` capturing `self` — is a retain cycle. Since the+ /// presenter is per-window it must be free to deallocate with its window,+ /// and a cycle here leaks it, and everything the retry captured, for the+ /// life of the app.+ ///+ /// Everything else the retry needs it must hold STRONGLY. This presenter+ /// outlives the document screen that raised the paywall, so a retry that+ /// reaches back through a shorter-lived owner finds it gone and no-ops+ /// silently — the user paid and got nothing, with nothing to show them+ /// (T-1779 review). Weak on the presenter, strong on the models.+ @ObservationIgnored+ var pendingExportAction: (() -> Void)?++ /// Presents the paywall without a pending export — the Settings "Unlock"+ /// row, which has nothing to retry.+ func present() {+ isPresented = true+ }++ /// Runs a gated export action, handling the loading / blocked / allowed+ /// branches uniformly. Used by every gated call site to avoid duplicating+ /// the switch and the paywall-presentation wiring (req 4.1–4.4).+ ///+ /// Returns the gate result so callers can branch on `.blocked`+ /// deterministically (e.g. dismiss the presenting pane so the paywall is+ /// visible) instead of re-reading `isPresented` after the fact+ /// (notes-action-placement Decision 13).+ ///+ /// - Parameters:+ /// - storeManager: the app-wide gate authority (entitlement + counter).+ /// - retry: invoked on `.blocked` as `pendingExportAction` so this+ /// scene's `handleDismiss` can re-run the export after a successful+ /// purchase (req 6.7). Must capture this presenter weakly and+ /// everything else it needs strongly — see `pendingExportAction`.+ /// - perform: receives the gate result for the allowed branches.+ @discardableResult+ func runGatedExport(+ storeManager: StoreManager,+ retry: @escaping () -> Void,+ perform: (ExportGateResult) -> Void+ ) -> ExportGateResult {+ let gate = storeManager.checkExport()+ switch gate {+ case .loading:+ break+ case .blocked:+ pendingExportAction = retry+ isPresented = true+ case .allowed, .allowedWithNudge:+ perform(gate)+ }+ return gate+ }++ /// Drops this scene's pending retry. Called by the paywall's explicit+ /// Close button: closing without buying abandons the blocked export.+ func cancelPendingExport() {+ pendingExportAction = nil+ }++ /// Handles this scene's paywall dismissal. Fires the pending export action+ /// only when a purchase actually completed and the entitlement agrees+ /// (req 6.7). On dismissal without purchase (Close/swipe), clears state+ /// without triggering the action.+ func handleDismiss(entitlementState: EntitlementState) {+ let action = pendingExportAction+ let purchased = didCompletePurchase+ pendingExportAction = nil+ didCompletePurchase = false+ if purchased, entitlementState == .unlocked, let action {+ action()+ }+ }+}++// MARK: - Scene host++/// Hosts one scene's paywall sheet. Both scenes that can raise a paywall — the+/// document `WindowGroup` root and the macOS Settings scene — attach this, so+/// the sheet and its dismissal handling exist in exactly one implementation+/// and each scene presents its own presenter's request (T-1779).+private struct PaywallHost: ViewModifier {+ @Bindable var presenter: PaywallPresenter+ let storeManager: StoreManager+ let settings: AppSettings+ let systemObserver: SystemColorSchemeObserver++ func body(content: Content) -> some View {+ content+ .sheet(+ isPresented: $presenter.isPresented,+ onDismiss: {+ presenter.handleDismiss(entitlementState: storeManager.entitlementState)+ },+ content: {+ PaywallSheet()+ .environment(storeManager)+ .environment(presenter)+ .applyTheme(settings: settings, systemObserver: systemObserver)+ }+ )+ }+}++extension View {+ /// Attaches this scene's paywall sheet.+ func paywallPresentation(+ presenter: PaywallPresenter,+ storeManager: StoreManager,+ settings: AppSettings,+ systemObserver: SystemColorSchemeObserver+ ) -> some View {+ modifier(PaywallHost(+ presenter: presenter,+ storeManager: storeManager,+ settings: settings,+ systemObserver: systemObserver+ ))+ }+}diff --git a/prism/Views/PaywallPresenter.swift b/prism/Views/PaywallPresenter.swiftindex d375816..ab2c861 100644--- a/prism/Views/PaywallPresenter.swift+++ b/prism/Views/PaywallPresenter.swift@@ -36,8 +36,8 @@ final class PaywallPresenter { var didCompletePurchase: Bool = false /// The export to re-run after a successful purchase. Not observed — no- /// view renders from it, and a closure-valued property would invalidate- /// every reader on each gate call.+ /// view renders from it, so tracking it would only add registrar overhead+ /// on every gate call. /// /// Whatever is stored here must hold this presenter WEAKLY: the closure /// lives on the presenter, so a strong capture — including an implicit@@ -52,12 +52,24 @@ final class PaywallPresenter { /// reaches back through a shorter-lived owner finds it gone and no-ops /// silently — the user paid and got nothing, with nothing to show them /// (T-1779 review). Weak on the presenter, strong on the models.+ /// `private(set)` so the capture rule above can only be broken by code in+ /// this file: every legitimate writer is a method here, and callers reach+ /// it through `runGatedExport` / `cancelPendingExport` / `handleDismiss`.+ /// Reading and invoking it stays available to tests. @ObservationIgnored- var pendingExportAction: (() -> Void)?+ private(set) var pendingExportAction: (() -> Void)? /// Presents the paywall without a pending export — the Settings "Unlock" /// row, which has nothing to retry.+ ///+ /// Clearing the retry is what makes "without a pending export" true rather+ /// than merely usually true. Every dismissal path already clears it, so+ /// today nothing is pending here; but a `.blocked` gate whose sheet never+ /// actually presented (sheet-over-sheet on iOS) leaves one stranded, and a+ /// later Settings-initiated purchase would then fire an export the user+ /// never asked for on this screen (req 6.8). func present() {+ pendingExportAction = nil isPresented = true }
diff --git a/prism/prismApp.swift b/prism/prismApp.swiftindex 59b76fe..0839c07 100644--- a/prism/prismApp.swift+++ b/prism/prismApp.swift@@ -52,6 +52,14 @@ struct PrismApp: App { /// App-wide StoreManager for in-app purchases and export gating. @State private var storeManager = StoreManager() + /// Paywall presentation state for the macOS Settings scene (T-1779).+ /// Document windows own their own presenter inside `MainContentView`; the+ /// Settings scene is a separate scene, so an unlock request made there+ /// must present there rather than in a document window.+ #if os(macOS)+ @State private var settingsPaywall = PaywallPresenter()+ #endif+ #if os(macOS) /// App-wide manager for diagram window lifecycle (macOS only). @State private var diagramWindowManager = DiagramWindowManager()@@ -175,6 +183,13 @@ struct PrismApp: App { SettingsView(settings: settings) .environment(settings) .environment(storeManager)+ .environment(settingsPaywall)+ .paywallPresentation(+ presenter: settingsPaywall,+ storeManager: storeManager,+ settings: settings,+ systemObserver: systemColorSchemeObserver+ ) .applyTheme(settings: settings, systemObserver: systemColorSchemeObserver) } @@ -233,9 +248,14 @@ struct MainContentView: View { /// Bundle of image/diagram services (injected from PrismApp). @Environment(\.imageServices) private var imageServices - /// App-wide StoreManager (injected from PrismApp) for paywall presentation.+ /// App-wide StoreManager (injected from PrismApp) for the export gate. @Environment(StoreManager.self) private var storeManager + /// This window's paywall presentation state (T-1779). Per-window `@State`,+ /// injected into the environment so every gated call site in this window+ /// raises the paywall here and nowhere else.+ @State private var paywall = PaywallPresenter()+ #if os(macOS) /// Diagram window manager (injected from PrismApp). Passed to the flow /// coordinator so document close can tear down owned diagram windows (T-1504).@@ -351,14 +371,13 @@ struct MainContentView: View { flowCoordinator.requestOpenRemoteURL(url) } }- .sheet(- isPresented: Bindable(storeManager).showPaywall,- onDismiss: handlePaywallDismiss- ) {- PaywallSheet()- .environment(storeManager)- .applyTheme(settings: settings, systemObserver: systemColorSchemeObserver)- }+ .environment(paywall)+ .paywallPresentation(+ presenter: paywall,+ storeManager: storeManager,+ settings: settings,+ systemObserver: systemColorSchemeObserver+ ) .overlay { if remoteCoordinator.isDownloading { ZStack {@@ -475,22 +494,6 @@ struct MainContentView: View { #endif } - // MARK: - Paywall Dismiss Handling-- /// Handles paywall sheet dismissal. Fires the pending export action only- /// when a purchase actually completed (req 6.7). On dismissal without- /// purchase (Close/swipe), clears state without triggering the action.- private func handlePaywallDismiss() {- let action = storeManager.pendingExportAction- let purchased = storeManager.didCompletePurchase- let unlocked = storeManager.entitlementState == .unlocked- storeManager.pendingExportAction = nil- storeManager.didCompletePurchase = false- if purchased, unlocked, let action {- action()- }- }- // MARK: - Link Handling /// Routes a resolved link to the appropriate action.diff --git a/prism/prismApp.swift b/prism/prismApp.swiftindex 0839c07..7f82054 100644--- a/prism/prismApp.swift+++ b/prism/prismApp.swift@@ -364,6 +364,13 @@ struct MainContentView: View { } .sheet(isPresented: $showSettings) { SettingsView(settings: settings)+ // Injected explicitly, as `.applyTheme` is: `SettingsView`+ // reads the presenter non-optionally, so it TRAPS rather than+ // no-ops if the value fails to reach it. Sheet content does+ // inherit `.environment(paywall)` from further down this+ // chain, but that is a modifier-ordering subtlety — one line+ // here makes the settings sheet independent of it (T-1779).+ .environment(paywall) .applyTheme(settings: settings, systemObserver: systemColorSchemeObserver) } .sheet(isPresented: $remoteCoordinator.isURLInputPresented) {@@ -596,7 +603,7 @@ struct PasteFromClipboardButton: View { /// /// Disables itself while no document is focused and while /// entitlement verification is loading — otherwise the click would route-/// to `StoreManager.runGatedExport`, whose `.loading` branch is a silent+/// to `PaywallPresenter.runGatedExport`, whose `.loading` branch is a silent /// no-op (T-1138 / req 4.1). struct ExportFileButton: View { @FocusedValue(\.exportToFileAction) var exportAction
diff --git a/prism/Services/StoreManager.swift b/prism/Services/StoreManager.swiftindex 06a1693..f2877e0 100644--- a/prism/Services/StoreManager.swift+++ b/prism/Services/StoreManager.swift@@ -4,8 +4,12 @@ import StoreKit private let logger = Logger.prism(category: "Store") -/// Manages StoreKit 2 product fetching, entitlement verification, the export-/// counter, and the paywall presentation flow.+/// Manages StoreKit 2 product fetching, entitlement verification, and the+/// export counter — the facts that describe the *account*, app-wide.+///+/// Presenting the paywall and retrying the export that raised it are NOT part+/// of this type: they belong to a single window and live on `PaywallPresenter`+/// (T-1779). /// /// Requirements covered: 1.1–1.4, 2.1–2.13, 3.7, 4.1–4.4, 5.1, 6.7, 6.8. @Observable@@ -42,16 +46,10 @@ final class StoreManager { /// Recomputed inside `fetchProducts()` for the same reason. private(set) var tipProducts: [Product] = [] - /// Drives the paywall sheet presentation. Bound from the view layer.- var showPaywall: Bool = false-- /// Closure to execute after a successful purchase triggered by a blocked- /// export. Cleared by the paywall sheet's `onDismiss`.- var pendingExportAction: (() -> Void)?-- /// Set to `true` by the paywall when a purchase succeeds, so `onDismiss`- /// can distinguish "purchased, then dismissed" from "swiped away".- var didCompletePurchase: Bool = false+ // Paywall presentation state deliberately does NOT live here: it is+ // per-scene, and holding it on this app-wide object presented and+ // dismissed the paywall in every open window (T-1779). See+ // `PaywallPresenter`. // MARK: - Export counter @@ -64,8 +62,8 @@ final class StoreManager { /// True when a gated export action should be exposed in the UI as /// available. While `entitlementState == .loading`, gated exports are a- /// silent no-op (see `runGatedExport`), so call sites that publish- /// menu commands without their own UI affordance must disable themselves+ /// silent no-op (see `PaywallPresenter.runGatedExport`), so call sites that+ /// publish menu commands without their own UI affordance must disable themselves /// based on this flag (T-1138 / req 4.1). The locked-and-over-the-limit /// case stays available because the action still has a visible effect — /// presenting the paywall.@@ -359,37 +357,6 @@ extension StoreManager { return "\(remainingFreeExports) of \(Self.freeExportLimit) free exports remaining" } - /// Runs a gated export action, handling the loading / blocked / allowed- /// branches uniformly. Used by every gated call site to avoid duplicating- /// the switch and the paywall-presentation wiring (req 4.1–4.4).- ///- /// Returns the gate result so callers can branch on `.blocked`- /// deterministically (e.g. dismiss the presenting pane so the paywall is- /// visible) instead of re-reading `showPaywall` after the fact- /// (notes-action-placement Decision 13).- ///- /// - Parameters:- /// - retry: invoked on `.blocked` as `pendingExportAction` so the- /// paywall's `onDismiss` can re-run the export after a successful- /// purchase (req 6.7).- /// - perform: receives the gate result for the allowed branches.- @discardableResult- func runGatedExport(- retry: @escaping () -> Void,- perform: (ExportGateResult) -> Void- ) -> ExportGateResult {- let gate = checkExport()- switch gate {- case .loading:- break- case .blocked:- pendingExportAction = retry- showPaywall = true- case .allowed, .allowedWithNudge:- perform(gate)- }- return gate- } } #if DEBUGdiff --git a/prism/Services/StoreManager.swift b/prism/Services/StoreManager.swiftindex f2877e0..a8c41bd 100644--- a/prism/Services/StoreManager.swift+++ b/prism/Services/StoreManager.swift@@ -356,7 +356,6 @@ extension StoreManager { exportCount >= Self.nudgeThreshold else { return nil } return "\(remainingFreeExports) of \(Self.freeExportLimit) free exports remaining" }- } #if DEBUG
diff --git a/prism/Views/DocumentReaderView.swift b/prism/Views/DocumentReaderView.swiftindex 43db527..d4af0cd 100644--- a/prism/Views/DocumentReaderView.swift+++ b/prism/Views/DocumentReaderView.swift@@ -118,6 +118,10 @@ struct DocumentReaderView: View { @Environment(RecentFilesManager.self) private var recentFilesManager @Environment(StoreManager.self) private var storeManager + /// This window's paywall presenter (T-1779): File > Export raising the+ /// paywall must present it in this window only.+ @Environment(PaywallPresenter.self) private var paywall+ private var minimumRequiredWidth: CGFloat { let dividerWidth = DraggableDivider.hitTargetWidth return Self.minimumContentWidth@@ -464,19 +468,65 @@ struct DocumentReaderView: View { /// prompt + save panel flow (req 4.2, 4.3). @MainActor private func triggerExport() {- storeManager.runGatedExport(- retry: { triggerExport() },- perform: { _ in- if settings.exportUsername.isEmpty {- exportUsernameInput = ""- showExportUsernamePrompt = true- } else {- performFileExport()- }- }+ Self.runGatedFileExport(+ paywall: paywall,+ storeManager: storeManager,+ resume: exportResumeAction+ )+ }++ /// Runs the export gate, storing a retry that re-runs it once a purchase+ /// completes.+ ///+ /// Static, and holding the presenter weakly, because the retry is stored+ /// ON the presenter: capturing it strongly — directly, or by capturing+ /// `self`, which holds this window's presenter through `@Environment` —+ /// is a self-retain cycle. Since T-1779 the presenter is per-window, so+ /// that cycle would leak it, and everything else the retry captured, for+ /// the life of the app whenever a window is torn down with a blocked+ /// export's paywall still up.+ @MainActor+ private static func runGatedFileExport(+ paywall: PaywallPresenter,+ storeManager: StoreManager,+ resume: @escaping @MainActor () -> Void+ ) {+ paywall.runGatedExport(+ storeManager: storeManager,+ retry: { [weak paywall] in+ guard let paywall else { return }+ runGatedFileExport(+ paywall: paywall,+ storeManager: storeManager,+ resume: resume+ )+ },+ perform: { _ in resume() } ) } + /// The post-gate half of File > Export: username prompt when no name is+ /// set (req 4.2), otherwise straight to the save panel (req 4.3).+ ///+ /// Bound to its dependencies explicitly rather than reached through+ /// `self`, so the stored retry never captures this view — and through it+ /// this window's presenter (see `runGatedFileExport`).+ @MainActor+ private var exportResumeAction: @MainActor () -> Void {+ let settings = self.settings+ let usernameInput = $exportUsernameInput+ let showUsernamePrompt = $showExportUsernamePrompt+ let export = performFileExport+ return {+ if settings.exportUsername.isEmpty {+ usernameInput.wrappedValue = ""+ showUsernamePrompt.wrappedValue = true+ } else {+ export()+ }+ }+ }+ // MARK: - Sidebar Auto-Collapse (T-495) /// Automatically collapses sidebars when the macOS window is too narrow,@@ -518,34 +568,49 @@ struct DocumentReaderView: View { // MARK: - File Export /// Generates export content and presents an NSSavePanel.- private func performFileExport() {- let content = notesManager.exportWithInlineNotes(- rawSource: session.content,- blocks: session.parsedBlocks,- username: settings.exportUsername,- includeResolved: settings.exportIncludeResolved,- showHTMLComments: settings.showHTMLComments- )-- let panel = NSSavePanel()- panel.allowedContentTypes = [.markdown]- panel.nameFieldStringValue = session.source.displayTitle- panel.begin { response in- // AppKit guarantees main-thread delivery for NSSavePanel.begin callbacks.- MainActor.assumeIsolated {- // Counter increments only on successful save (Decision 6, req 4.7).- guard response == .OK, let url = panel.url else { return }- do {- try content.write(to: url, atomically: true, encoding: .utf8)- storeManager.incrementExportCount()- // Nudge routes to the shared coordinator banner (req 5.1;- // notes-action-placement task 8). Only assign when a nudge- // fired so a nil result can't clear an in-flight banner.- if let nudge = storeManager.postIncrementNudgeMessage() {- coordinator.bannerMessage = nudge+ ///+ /// A bound closure rather than a method so the post-purchase retry can+ /// hold it without capturing `self` — the view carries this window's+ /// `PaywallPresenter` in `@Environment`, and the retry lives on that+ /// presenter (see `runGatedFileExport`). Call sites are unchanged:+ /// `performFileExport()` invokes the returned closure.+ @MainActor+ private var performFileExport: @MainActor () -> Void {+ let notesManager = self.notesManager+ let session = self.session+ let settings = self.settings+ let storeManager = self.storeManager+ let coordinator = self.coordinator+ let exportError = $exportError+ return {+ let content = notesManager.exportWithInlineNotes(+ rawSource: session.content,+ blocks: session.parsedBlocks,+ username: settings.exportUsername,+ includeResolved: settings.exportIncludeResolved,+ showHTMLComments: settings.showHTMLComments+ )++ let panel = NSSavePanel()+ panel.allowedContentTypes = [.markdown]+ panel.nameFieldStringValue = session.source.displayTitle+ panel.begin { response in+ // AppKit guarantees main-thread delivery for NSSavePanel.begin callbacks.+ MainActor.assumeIsolated {+ // Counter increments only on successful save (Decision 6, req 4.7).+ guard response == .OK, let url = panel.url else { return }+ do {+ try content.write(to: url, atomically: true, encoding: .utf8)+ storeManager.incrementExportCount()+ // Nudge routes to the shared coordinator banner (req 5.1;+ // notes-action-placement task 8). Only assign when a nudge+ // fired so a nil result can't clear an in-flight banner.+ if let nudge = storeManager.postIncrementNudgeMessage() {+ coordinator.bannerMessage = nudge+ }+ } catch {+ exportError.wrappedValue = error.localizedDescription }- } catch {- exportError = error.localizedDescription } } }diff --git a/prism/Views/DocumentReaderView.swift b/prism/Views/DocumentReaderView.swiftindex d4af0cd..196a3e8 100644--- a/prism/Views/DocumentReaderView.swift+++ b/prism/Views/DocumentReaderView.swift@@ -511,6 +511,14 @@ struct DocumentReaderView: View { /// Bound to its dependencies explicitly rather than reached through /// `self`, so the stored retry never captures this view — and through it /// this window's presenter (see `runGatedFileExport`).+ ///+ /// Carries the same residual as the share path, for the same reason: the+ /// prompt branch writes through `@State` bindings whose alerts are hosted+ /// on this view, so if the reader is popped while the paywall is up and no+ /// `exportUsername` was ever set, the retry has nowhere to show the prompt.+ /// A property of the alert host, not of the binding capture — see+ /// `ExportNotesFlow.run` and inapp-purchase Decision 13. The save-panel+ /// branch, taken by every user who has exported before, completes. @MainActor private var exportResumeAction: @MainActor () -> Void { let settings = self.settings
diff --git a/prism/Views/DocumentLayoutCoordinator.swift b/prism/Views/DocumentLayoutCoordinator.swiftindex 5860dae..827fccc 100644--- a/prism/Views/DocumentLayoutCoordinator.swift+++ b/prism/Views/DocumentLayoutCoordinator.swift@@ -651,7 +651,7 @@ final class DocumentLayoutCoordinator { /// (regular toolbar, notes pane, sidebar). Owned by /// `DocumentLayoutCoordinator` with alerts hosted by `DocumentReaderView`, /// so the flow survives dismissal of the pane that triggered the export:-/// the post-purchase retry stored in `StoreManager.pendingExportAction`+/// the post-purchase retry stored in `PaywallPresenter.pendingExportAction` /// references `run`, never view-local `@State` (Req 2.4/2.5, design M1). @Observable @MainActor@@ -714,16 +714,33 @@ final class ExportNotesFlow { /// then either shows the username prompt (no username set) or performs /// the share directly (req 4.2, 4.3). ///- /// On `.blocked` the retry stored in `pendingExportAction` is `run`- /// itself, so a purchase completed after the originating surface+ /// On `.blocked` the retry stored in the presenter's `pendingExportAction`+ /// is `run` itself, so a purchase completed after the originating surface /// dismissed still re-runs the full flow — including the username- /// prompt — from the document screen (Req 2.4/2.5).+ /// prompt — from the document screen (Req 2.4/2.5). The presenter is the+ /// triggering window's, so a second window's blocked export cannot+ /// displace this one (T-1779).+ ///+ /// The retry outlives this flow's owner on purpose. `ExportNotesFlow` is+ /// document-scoped (owned by `DocumentLayoutCoordinator`, itself `@State`+ /// in `DocumentReaderView`) while the presenter holding the retry is+ /// window-scoped, so the document screen can be torn down — navigating+ /// back, switching documents, `requestClose()` — while the paywall sheet+ /// is still up. Capturing `self` weakly made that case a silent no-op: the+ /// user paid, `handleDismiss` fired the retry, and nothing happened.+ ///+ /// One sub-case stays out of reach and is a property of the alert host,+ /// not of the capture: with no `exportUsername` set the retry can only ask+ /// for one, and `ExportNotesFlowAlerts` lives on the very+ /// `DocumentReaderView` that is gone, so the prompt has nowhere to appear.+ /// The share path — every user who has exported before — completes. @discardableResult func run( notesManager: NotesManager, session: DocumentSession, settings: AppSettings,- storeManager: StoreManager+ storeManager: StoreManager,+ paywall: PaywallPresenter ) -> ExportGateResult { let context = ShareContext( notesManager: notesManager,@@ -731,13 +748,28 @@ final class ExportNotesFlow { settings: settings, storeManager: storeManager )- return storeManager.runGatedExport(- retry: { [weak self] in- self?.run(+ return paywall.runGatedExport(+ storeManager: storeManager,+ // Strong on the models, weak on the presenter — the same split the+ // other two gated call sites make. `self` joins the document+ // context this closure already retains (`session`, `notesManager`,+ // `settings`, `storeManager`); dropping it alone would leave the+ // retry holding everything it needs except the object that knows+ // how to use it. That is not a cycle: `ExportNotesFlow` never+ // references the presenter, it receives one per call, so the chain+ // is presenter → retry → flow and it ends there. Nor does it leak:+ // every dismissal path clears `pendingExportAction`, and a window+ // torn down with the sheet up deallocates the presenter itself —+ // which is exactly what holding `paywall` weakly guarantees, since+ // the retry is stored ON the presenter (T-1779 review).+ retry: { [self, weak paywall] in+ guard let paywall else { return }+ run( notesManager: notesManager, session: session, settings: settings,- storeManager: storeManager+ storeManager: storeManager,+ paywall: paywall ) }, perform: { _ in
diff --git a/prism/Views/CopyNotesButton.swift b/prism/Views/CopyNotesButton.swiftindex 4747657..a8ff87b 100644--- a/prism/Views/CopyNotesButton.swift+++ b/prism/Views/CopyNotesButton.swift@@ -39,7 +39,7 @@ struct CopyNotesButton: View { /// Invoked when the export gate blocks (the paywall is presenting); the /// notes pane passes pane-dismiss on iOS (Req 2.4). Driven by the- /// returned gate result, never by re-reading `showPaywall`.+ /// returned gate result, never by re-reading the presenter. var onBlocked: (() -> Void)? /// Receives the confirmation / free-tier nudge banner text (Req 1.3).@@ -54,6 +54,10 @@ struct CopyNotesButton: View { @Environment(AppSettings.self) private var settings @Environment(StoreManager.self) private var storeManager + /// This window's paywall presenter (T-1779) — a blocked copy must raise+ /// the paywall in the window it was triggered from, and nowhere else.+ @Environment(PaywallPresenter.self) private var paywall+ var body: some View { // Self-hides when nothing would be copied — defence in depth behind // the toolbar helper's conditional (Req 1.4: hidden wins over disabled).@@ -77,6 +81,7 @@ struct CopyNotesButton: View { blocks: blocks, settings: settings, storeManager: storeManager,+ paywall: paywall, onBanner: onBanner, retryOnBanner: retryOnBanner, onBlocked: onBlocked@@ -105,9 +110,9 @@ struct CopyNotesButton: View { /// sequence (Req 1.3). The unit-testable entry point shared by every /// copy surface; SwiftUI bodies are not unit-inspectable. ///- /// On `.blocked` the retry stored in `pendingExportAction` is `perform`- /// itself — never view-local state — so a purchase completed after the- /// originating surface dismissed still re-runs the copy (Req 2.4).+ /// On `.blocked` the retry stored in the presenter's `pendingExportAction`+ /// is `perform` itself — never view-local state — so a purchase completed+ /// after the originating surface dismissed still re-runs the copy (Req 2.4). /// `onBlocked` fires iff the returned gate is `.blocked`; `.loading` is /// a silent no-op. ///@@ -123,18 +128,28 @@ struct CopyNotesButton: View { blocks: [MarkdownBlock], settings: AppSettings, storeManager: StoreManager,+ paywall: PaywallPresenter, onBanner: @escaping (String) -> Void, retryOnBanner: ((String) -> Void)? = nil, onBlocked: (() -> Void)? = nil ) -> ExportGateResult { let retryBanner = retryOnBanner ?? onBanner- let gate = storeManager.runGatedExport(- retry: {+ let gate = paywall.runGatedExport(+ storeManager: storeManager,+ // The retry is stored ON the presenter, so capturing it strongly+ // would be a self-retain cycle. That was harmless while the+ // presenter was an app-wide singleton; now that it dies with its+ // window, a strong capture would leak the presenter — and+ // everything else this closure holds — whenever a window closes+ // with a blocked export's paywall still up (T-1779 review).+ retry: { [weak paywall] in+ guard let paywall else { return } perform( notesManager: notesManager, blocks: blocks, settings: settings, storeManager: storeManager,+ paywall: paywall, onBanner: retryBanner, onBlocked: onBlocked )
diff --git a/prism/Views/PaywallSheet.swift b/prism/Views/PaywallSheet.swiftindex d991a21..8a7acc9 100644--- a/prism/Views/PaywallSheet.swift+++ b/prism/Views/PaywallSheet.swift@@ -19,6 +19,11 @@ enum PaywallPurchaseState: Equatable { /// Requirements covered: 6.1–6.11, 11.3, 11.4. struct PaywallSheet: View { @Environment(StoreManager.self) private var storeManager++ /// The presenting scene's paywall state (T-1779). Purchase completion and+ /// the pending retry belong to the window that raised this sheet.+ @Environment(PaywallPresenter.self) private var paywall+ @Environment(\.dismiss) private var dismiss @State private var purchaseState: PaywallPurchaseState = .ready@@ -155,7 +160,7 @@ struct PaywallSheet: View { let state = try await storeManager.restorePurchases() if state == .unlocked { purchaseState = .succeeded- storeManager.didCompletePurchase = true+ paywall.didCompletePurchase = true AccessibilityNotification.Announcement("Purchases restored").post() dismiss() } else {@@ -180,7 +185,7 @@ struct PaywallSheet: View { switch result { case .success: purchaseState = .succeeded- storeManager.didCompletePurchase = true+ paywall.didCompletePurchase = true AccessibilityNotification.Announcement("Purchase successful").post() dismiss() case .pending:@@ -197,7 +202,7 @@ struct PaywallSheet: View { } private func handleDismiss() {- storeManager.pendingExportAction = nil+ paywall.cancelPendingExport() dismiss() } }
diff --git a/prism/Settings/SettingsView.swift b/prism/Settings/SettingsView.swiftindex ffe59e0..2412d1f 100644--- a/prism/Settings/SettingsView.swift+++ b/prism/Settings/SettingsView.swift@@ -11,6 +11,12 @@ struct SettingsView: View { @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL @Environment(StoreManager.self) private var storeManager++ /// The hosting scene's paywall state (T-1779): the macOS Settings scene+ /// presents its own paywall, while the iOS settings sheet defers to the+ /// window it was presented from (it dismisses itself first).+ @Environment(PaywallPresenter.self) private var paywall+ @Bindable var settings: AppSettings @State private var showingAcknowledgements = false@@ -414,7 +420,7 @@ struct SettingsView: View { private var purchaseSection: some View { Section { Button {- storeManager.showPaywall = true+ paywall.present() #if os(iOS) dismiss() #endif@@ -630,7 +636,9 @@ extension Bundle { #Preview { @Previewable @State var settings = AppSettings() @Previewable @State var storeManager = StoreManager()+ @Previewable @State var paywall = PaywallPresenter() SettingsView(settings: settings) .environment(storeManager)+ .environment(paywall) .applyTheme(settings: settings, systemObserver: SystemColorSchemeObserver()) }
diff --git a/prism/Services/InlineNotesShareHelper.swift b/prism/Services/InlineNotesShareHelper.swiftindex 2f82203..eea1fae 100644--- a/prism/Services/InlineNotesShareHelper.swift+++ b/prism/Services/InlineNotesShareHelper.swift@@ -124,19 +124,23 @@ struct ExportWithNotesButton: View { /// Invoked when the export gate blocks (the paywall is presenting); the /// notes pane passes pane-dismiss on iOS (Req 2.4). Driven by the- /// returned gate result, never by re-reading `showPaywall`.+ /// returned gate result, never by re-reading the presenter. var onBlocked: (() -> Void)? @Environment(AppSettings.self) private var settings @Environment(StoreManager.self) private var storeManager + /// This window's paywall presenter (T-1779).+ @Environment(PaywallPresenter.self) private var paywall+ var body: some View { Button { let gate = flow.run( notesManager: notesManager, session: session, settings: settings,- storeManager: storeManager+ storeManager: storeManager,+ paywall: paywall ) if gate == .blocked { onBlocked?()
diff --git a/prism/Views/NotesPanel.swift b/prism/Views/NotesPanel.swiftindex eab11f3..3729dce 100644--- a/prism/Views/NotesPanel.swift+++ b/prism/Views/NotesPanel.swift@@ -462,6 +462,7 @@ struct NotesPanel: View { #Preview("With Notes") { @Previewable @State var manager = NotesManager() @Previewable @State var storeManager = StoreManager()+ @Previewable @State var paywall = PaywallPresenter() NotesPanel( notesManager: manager, blocks: [],@@ -473,11 +474,13 @@ struct NotesPanel: View { onDismiss: {} ) .environment(storeManager)+ .environment(paywall) } #Preview("Empty State") { @Previewable @State var manager = NotesManager() @Previewable @State var storeManager = StoreManager()+ @Previewable @State var paywall = PaywallPresenter() NotesPanel( notesManager: manager, blocks: [],@@ -489,4 +492,5 @@ struct NotesPanel: View { onDismiss: {} ) .environment(storeManager)+ .environment(paywall) }
diff --git a/prism/Views/SidebarNotesView.swift b/prism/Views/SidebarNotesView.swiftindex 265dacb..addcd53 100644--- a/prism/Views/SidebarNotesView.swift+++ b/prism/Views/SidebarNotesView.swift@@ -305,6 +305,7 @@ struct SidebarNotesView: View { #Preview("With Notes") { @Previewable @State var manager = NotesManager() @Previewable @State var storeManager = StoreManager()+ @Previewable @State var paywall = PaywallPresenter() SidebarNotesView( notesManager: manager,@@ -315,12 +316,14 @@ struct SidebarNotesView: View { onNavigate: { id in print("Navigate to: \(id)") } ) .environment(storeManager)+ .environment(paywall) .frame(width: 280, height: 400) } #Preview("Empty State") { @Previewable @State var manager = NotesManager() @Previewable @State var storeManager = StoreManager()+ @Previewable @State var paywall = PaywallPresenter() SidebarNotesView( notesManager: manager,@@ -331,5 +334,6 @@ struct SidebarNotesView: View { onNavigate: { _ in } ) .environment(storeManager)+ .environment(paywall) .frame(width: 280, height: 400) }
diff --git a/prismTests/PaywallPresenterTests.swift b/prismTests/PaywallPresenterTests.swiftnew file mode 100644index 0000000..8e1a401--- /dev/null+++ b/prismTests/PaywallPresenterTests.swift@@ -0,0 +1,313 @@+//+// PaywallPresenterTests.swift+// prismTests+//+// Regression tests for T-1779: paywall presentation and the post-purchase+// retry are per-scene, while entitlement, products, and the export counter+// stay app-wide.+//+// Before the fix, `showPaywall` / `pendingExportAction` / `didCompletePurchase`+// lived on the app-wide `StoreManager`. Every window's sheet bound to the same+// flag, so a paywall raised in one window presented and dismissed in all of+// them; a second blocked export overwrote the first window's retry; and a+// dismissal in any window discarded it.+//+// Ticket: T-1779+//++import Foundation+import Testing+@testable import prism++@Suite("PaywallPresenter (multi-window scoping)")+struct PaywallPresenterTests {++ // MARK: - Helpers++ @MainActor+ private func makeStore(+ entitlementState: EntitlementState = .locked,+ count: Int = 0+ ) -> StoreManager {+ let kvs = ExportCounterTests.MockKeyValueStore()+ kvs.storage[ExportCounter.storageKey] = Int64(count)+ // Fresh suite name guarantees an empty UserDefaults — no clean-up needed.+ let defaults = UserDefaults(suiteName: UUID().uuidString)!+ return StoreManager(+ exportCounter: ExportCounter(kvs: kvs, defaults: defaults),+ entitlementState: entitlementState+ )+ }++ /// Two windows, each with its own presenter, as `PrismApp` wires them+ /// around one shared `StoreManager`.+ @MainActor+ private func makeWindows() -> (windowA: PaywallPresenter, windowB: PaywallPresenter) {+ (PaywallPresenter(), PaywallPresenter())+ }++ // MARK: - Presentation ownership++ @Test("a blocked export presents the paywall only in the window that raised it")+ @MainActor+ func blockedExportPresentsOnlyInInitiatingWindow() {+ let store = makeStore(count: StoreManager.freeExportLimit)+ let (windowA, windowB) = makeWindows()++ let gate = windowA.runGatedExport(+ storeManager: store,+ retry: {},+ perform: { _ in }+ )++ #expect(gate == .blocked)+ #expect(windowA.isPresented)+ #expect(windowA.pendingExportAction != nil)+ #expect(!windowB.isPresented, "The other window must not present the paywall")+ #expect(windowB.pendingExportAction == nil)+ }++ @Test("the Settings unlock request presents only in the scene that made it")+ @MainActor+ func settingsUnlockRequestIsScopedToItsScene() {+ let store = makeStore()+ let (windowA, windowB) = makeWindows()++ // The macOS Settings scene owns its own presenter; this stands in for it.+ windowA.present()++ #expect(windowA.isPresented)+ #expect(windowA.pendingExportAction == nil, "Settings has no export to retry")+ #expect(!windowB.isPresented)+ }++ // MARK: - Dismissal by a non-owner++ @Test("dismissing the paywall in another window leaves the owner's request intact")+ @MainActor+ func nonOwnerDismissalLeavesOwnerRequestIntact() {+ let store = makeStore(count: StoreManager.freeExportLimit)+ let (windowA, windowB) = makeWindows()+ var windowARetried = false++ windowA.runGatedExport(+ storeManager: store,+ retry: { windowARetried = true },+ perform: { _ in }+ )++ // Window B's paywall (raised from Settings there) is closed without a+ // purchase: its Close button clears its own pending export, its sheet+ // dismisses, and its onDismiss runs.+ windowB.present()+ windowB.cancelPendingExport()+ windowB.isPresented = false+ windowB.handleDismiss(entitlementState: store.entitlementState)++ #expect(windowA.isPresented, "Window A's paywall must stay up")+ #expect(windowA.pendingExportAction != nil, "Window A's retry must survive")+ #expect(!windowARetried)++ // Window A then completes the purchase itself.+ store.setEntitlementStateForTesting(.unlocked)+ windowA.didCompletePurchase = true+ windowA.handleDismiss(entitlementState: store.entitlementState)++ #expect(windowARetried)+ }++ // MARK: - Two blocked exports from two windows++ @Test("two blocked exports keep one retry each, and each window retries its own")+ @MainActor+ func twoBlockedExportsKeepIndependentRetries() {+ let store = makeStore(count: StoreManager.freeExportLimit)+ let (windowA, windowB) = makeWindows()+ var windowARetried = 0+ var windowBRetried = 0++ windowA.runGatedExport(+ storeManager: store,+ retry: { windowARetried += 1 },+ perform: { _ in }+ )+ windowB.runGatedExport(+ storeManager: store,+ retry: { windowBRetried += 1 },+ perform: { _ in }+ )++ // The purchase completes in window B.+ store.setEntitlementStateForTesting(.unlocked)+ windowB.didCompletePurchase = true+ windowB.handleDismiss(entitlementState: store.entitlementState)++ #expect(windowBRetried == 1)+ #expect(windowARetried == 0, "Window B's dismissal must not run window A's export")+ #expect(windowA.pendingExportAction != nil, "Window A's retry is still pending")++ // Window A's own dismissal (already unlocked, purchase seen there too)+ // then runs exactly its own export.+ windowA.didCompletePurchase = true+ windowA.handleDismiss(entitlementState: store.entitlementState)++ #expect(windowARetried == 1)+ #expect(windowBRetried == 1)+ }++ // MARK: - Dismiss semantics (req 6.7)++ @Test("dismissal without a purchase clears state without running the export")+ @MainActor+ func dismissalWithoutPurchaseDoesNotRetry() {+ let store = makeStore(count: StoreManager.freeExportLimit)+ let presenter = PaywallPresenter()+ var retried = false++ presenter.runGatedExport(+ storeManager: store,+ retry: { retried = true },+ perform: { _ in }+ )+ presenter.handleDismiss(entitlementState: store.entitlementState)++ #expect(!retried)+ #expect(presenter.pendingExportAction == nil)+ #expect(!presenter.didCompletePurchase)+ }++ @Test("a purchase flagged without an unlocked entitlement does not retry")+ @MainActor+ func purchaseFlagWithoutEntitlementDoesNotRetry() {+ let store = makeStore(count: StoreManager.freeExportLimit)+ let presenter = PaywallPresenter()+ var retried = false++ presenter.runGatedExport(+ storeManager: store,+ retry: { retried = true },+ perform: { _ in }+ )+ // Ask to Buy / a revoked purchase: the flag is set but entitlement+ // never reached `.unlocked`.+ presenter.didCompletePurchase = true+ presenter.handleDismiss(entitlementState: store.entitlementState)++ #expect(!retried)+ #expect(presenter.pendingExportAction == nil)+ }++ @Test("Close clears only the closing window's pending export")+ @MainActor+ func closeClearsOnlyItsOwnPendingExport() {+ let store = makeStore(count: StoreManager.freeExportLimit)+ let (windowA, windowB) = makeWindows()++ windowA.runGatedExport(storeManager: store, retry: {}, perform: { _ in })+ windowB.runGatedExport(storeManager: store, retry: {}, perform: { _ in })++ windowB.cancelPendingExport()++ #expect(windowB.pendingExportAction == nil)+ #expect(windowA.pendingExportAction != nil)+ }++ // MARK: - Presenter lifetime (review of T-1779)++ /// A retry stored on a blocked gate must not keep the presenter alive.+ ///+ /// `pendingExportAction` lives ON the presenter, so a retry that captured+ /// the presenter strongly is a self-retain cycle. That was invisible while+ /// the presenter was an app-wide singleton that never needed to+ /// deallocate; now that it is per-window, the cycle would leak one+ /// presenter — plus the notes, session, and settings its retry captured —+ /// for every window torn down with a blocked export's paywall still up.+ @Test("a pending copy retry does not keep its window's presenter alive")+ @MainActor+ func pendingCopyRetryDoesNotRetainPresenter() {+ let store = makeStore(count: StoreManager.freeExportLimit)+ let notesManager = NotesManager.makeForTesting(store: MockNotesStore())+ let settings = AppSettings()+ weak var weakPresenter: PaywallPresenter?++ do {+ let presenter = PaywallPresenter()+ weakPresenter = presenter+ let gate = CopyNotesButton.perform(+ notesManager: notesManager,+ blocks: [],+ settings: settings,+ storeManager: store,+ paywall: presenter,+ onBanner: { _ in }+ )+ #expect(gate == .blocked)+ #expect(presenter.pendingExportAction != nil, "the retry must actually be stored")+ }++ #expect(weakPresenter == nil, "The presenter must deallocate with its window")+ }++ @Test("a pending share retry does not keep its window's presenter alive")+ @MainActor+ func pendingShareRetryDoesNotRetainPresenter() {+ let store = makeStore(count: StoreManager.freeExportLimit)+ let flow = ExportNotesFlow()+ let session = DocumentSession(+ url: URL(fileURLWithPath: "/tmp/paywall-presenter-lifetime.md"),+ content: "# Title\n\nBody."+ )+ let notesManager = NotesManager.makeForTesting(store: MockNotesStore())+ let settings = AppSettings()+ weak var weakPresenter: PaywallPresenter?++ do {+ let presenter = PaywallPresenter()+ weakPresenter = presenter+ let gate = flow.run(+ notesManager: notesManager,+ session: session,+ settings: settings,+ storeManager: store,+ paywall: presenter+ )+ #expect(gate == .blocked)+ #expect(presenter.pendingExportAction != nil, "the retry must actually be stored")+ }++ // The flow outlives the closed window here, as the coordinator does+ // while another window stays open: it must hold nothing back.+ #expect(weakPresenter == nil, "The presenter must deallocate with its window")+ }++ // MARK: - Shared state stays shared++ @Test("entitlement and the export counter remain app-wide across windows")+ @MainActor+ func entitlementAndCounterStayAppWide() {+ let store = makeStore(count: StoreManager.freeExportLimit - 1)+ let (windowA, windowB) = makeWindows()++ // One free export left: window A spends it.+ let first = windowA.runGatedExport(+ storeManager: store,+ retry: {},+ perform: { _ in store.incrementExportCount() }+ )+ #expect(first == .allowedWithNudge(remaining: 0))++ // Window B sees the app-wide counter, not a per-window one.+ let second = windowB.runGatedExport(+ storeManager: store,+ retry: {},+ perform: { _ in store.incrementExportCount() }+ )+ #expect(second == .blocked)+ #expect(windowB.isPresented)+ #expect(!windowA.isPresented)++ // Unlocking is app-wide too: both windows gate as allowed afterwards.+ store.setEntitlementStateForTesting(.unlocked)+ #expect(store.checkExport() == .allowed)+ }+}diff --git a/prismTests/PaywallPresenterTests.swift b/prismTests/PaywallPresenterTests.swiftindex 8e1a401..5f37b16 100644--- a/prismTests/PaywallPresenterTests.swift+++ b/prismTests/PaywallPresenterTests.swift@@ -39,8 +39,8 @@ struct PaywallPresenterTests { ) } - /// Two windows, each with its own presenter, as `PrismApp` wires them- /// around one shared `StoreManager`.+ /// Two independent scene presenters, standing in for two windows — the+ /// shape `PrismApp` wires around one shared `StoreManager`. @MainActor private func makeWindows() -> (windowA: PaywallPresenter, windowB: PaywallPresenter) { (PaywallPresenter(), PaywallPresenter())@@ -70,7 +70,6 @@ struct PaywallPresenterTests { @Test("the Settings unlock request presents only in the scene that made it") @MainActor func settingsUnlockRequestIsScopedToItsScene() {- let store = makeStore() let (windowA, windowB) = makeWindows() // The macOS Settings scene owns its own presenter; this stands in for it.@@ -81,6 +80,37 @@ struct PaywallPresenterTests { #expect(!windowB.isPresented) } + /// `present()` promises "no pending export". Every dismissal path clears+ /// the retry, so nothing is normally stranded — but a `.blocked` gate+ /// whose sheet never actually presented (sheet-over-sheet on iOS) leaves+ /// one behind, and a later Settings-initiated purchase would then run an+ /// export the user never asked for on that screen (req 6.8).+ @Test("presenting from Settings abandons any export stranded by an unpresented gate")+ @MainActor+ func presentClearsAStrandedPendingExport() {+ let store = makeStore(count: StoreManager.freeExportLimit)+ let presenter = PaywallPresenter()+ var retried = false++ presenter.runGatedExport(+ storeManager: store,+ retry: { retried = true },+ perform: { _ in }+ )+ #expect(presenter.pendingExportAction != nil)++ // The sheet never appeared, so no dismissal ever cleared the retry.+ // The user later opens Settings and unlocks from there.+ presenter.present()+ #expect(presenter.pendingExportAction == nil)++ store.setEntitlementStateForTesting(.unlocked)+ presenter.didCompletePurchase = true+ presenter.handleDismiss(entitlementState: store.entitlementState)++ #expect(!retried, "An unlock from Settings must not run a stranded export")+ }+ // MARK: - Dismissal by a non-owner @Test("dismissing the paywall in another window leaves the owner's request intact")@@ -310,4 +340,64 @@ struct PaywallPresenterTests { store.setEntitlementStateForTesting(.unlocked) #expect(store.checkExport() == .allowed) }++ // MARK: - Scene wiring++ // Every test above drives `PaywallPresenter` directly, and a+ // direct-invocation test cannot see missing wiring — the failure mode+ // CLAUDE.md records for T-1943, where a whole recovery path was dead in+ // production while its unit tests stayed green. Deleting+ // `.paywallPresentation(...)` from either scene still compiles and still+ // passes every behavioural test above, and ships a paywall that can never+ // appear; dropping the `.environment(...)` injection instead TRAPS, since+ // the five reading views declare the value non-optionally. The source+ // reads below pin both halves, following `FootnotePresentationHostTests`+ // (the same structural answer T-1893 got for the same class of bug).++ /// `prismApp.swift`, read from disk relative to this file — the same+ /// `#filePath` approach `FootnotePresentationHostTests` uses, so the check+ /// needs no bundle resource wiring.+ private static func appSource() throws -> String {+ let url = URL(fileURLWithPath: #filePath)+ .deletingLastPathComponent() // prismTests+ .deletingLastPathComponent() // repo root+ .appendingPathComponent("prism")+ .appendingPathComponent("prismApp.swift")+ return try String(contentsOf: url, encoding: .utf8)+ }++ @Test("both paywall-raising scenes attach the shared presentation host")+ func bothScenesAttachThePaywallHost() throws {+ let source = try Self.appSource()+ let hosts = source.components(separatedBy: ".paywallPresentation(").count - 1++ #expect(+ hosts == 2,+ """+ prismApp.swift must apply `.paywallPresentation(...)` exactly twice: \+ once on the document WindowGroup root and once on the macOS Settings \+ scene. Found \(hosts). Without it a scene's `isPresented` flips with \+ no sheet attached, so the paywall never appears and the blocked \+ export is never retried (T-1779).+ """+ )+ }++ @Test("both paywall-raising scenes inject their presenter into the environment")+ func bothScenesInjectTheirPresenter() throws {+ let source = try Self.appSource()++ #expect(+ source.contains(".environment(settingsPaywall)"),+ "The macOS Settings scene must inject its own presenter (T-1779)."+ )+ #expect(+ source.contains(".environment(paywall)"),+ """+ MainContentView must inject this window's presenter. Every gated \+ call site reads `@Environment(PaywallPresenter.self)` \+ non-optionally, so a missing injection is a runtime trap (T-1779).+ """+ )+ } }
diff --git a/prismTests/ExportNotesFlowTests.swift b/prismTests/ExportNotesFlowTests.swiftindex c740bc6..7fbc034 100644--- a/prismTests/ExportNotesFlowTests.swift+++ b/prismTests/ExportNotesFlowTests.swift@@ -99,6 +99,7 @@ struct ExportNotesFlowTests { func runWithoutUsernameShowsPrompt() { defer { clearUsername() } let settings = makeSettings(username: nil)+ let paywall = PaywallPresenter() let store = makeStore() let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -109,7 +110,8 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) #expect(gate == .allowed)@@ -123,6 +125,7 @@ struct ExportNotesFlowTests { func submitUsernamePerformsPendingShare() { defer { clearUsername() } let settings = makeSettings(username: nil)+ let paywall = PaywallPresenter() let store = makeStore() let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -133,7 +136,8 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) flow.usernameInput = "Reviewer" flow.submitUsername()@@ -149,6 +153,7 @@ struct ExportNotesFlowTests { func submitInvalidUsernameShowsValidationError() { defer { clearUsername() } let settings = makeSettings(username: nil)+ let paywall = PaywallPresenter() let store = makeStore() let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -159,7 +164,8 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) flow.usernameInput = "Bad]Name" flow.submitUsername()@@ -182,6 +188,7 @@ struct ExportNotesFlowTests { func runWithUsernameSharesImmediately() { defer { clearUsername() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore() let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -192,7 +199,8 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) #expect(gate == .allowed)@@ -208,6 +216,7 @@ struct ExportNotesFlowTests { func blockedGateStoresRetry() { defer { clearUsername() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore(count: StoreManager.freeExportLimit) let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -218,12 +227,13 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) #expect(gate == .blocked)- #expect(store.pendingExportAction != nil)- #expect(store.showPaywall)+ #expect(paywall.pendingExportAction != nil)+ #expect(paywall.isPresented) #expect(share.callCount == 0) #expect(!flow.showUsernamePrompt) }@@ -237,6 +247,7 @@ struct ExportNotesFlowTests { func retryAfterPurchaseReachesPromptState() { defer { clearUsername() } let settings = makeSettings(username: nil)+ let paywall = PaywallPresenter() let store = makeStore(count: StoreManager.freeExportLimit) let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -247,16 +258,17 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) #expect(gate == .blocked)- #expect(store.pendingExportAction != nil)+ #expect(paywall.pendingExportAction != nil) #expect(!flow.showUsernamePrompt) // The originating pane is gone; the purchase completes and the // paywall's onDismiss fires the stored retry. store.setEntitlementStateForTesting(.unlocked)- store.pendingExportAction?()+ paywall.pendingExportAction?() #expect(flow.showUsernamePrompt) #expect(share.callCount == 0)@@ -268,6 +280,54 @@ struct ExportNotesFlowTests { #expect(settings.exportUsername == "Reviewer") } + /// T-1779 review regression: the flow is document-scoped (owned by+ /// `DocumentLayoutCoordinator`, itself `@State` in `DocumentReaderView`)+ /// while the presenter holding the retry is window-scoped, so the document+ /// screen can be torn down — navigating back, switching documents,+ /// `requestClose()` — with the paywall sheet still up. Holding the flow+ /// weakly in the retry made that a silent no-op: the purchase completed,+ /// `handleDismiss` fired the retry, and the paid-for export vanished with+ /// no share, no error, and nothing to tell the user.+ @Test("a completed purchase's retry still runs after the document screen is torn down")+ @MainActor+ func retrySurvivesDocumentScreenTeardown() {+ defer { clearUsername() }+ let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter()+ let store = makeStore(count: StoreManager.freeExportLimit)+ let share = ShareRecorder()+ weak var weakFlow: ExportNotesFlow?++ do {+ let coordinator = DocumentLayoutCoordinator()+ let flow = coordinator.exportNotesFlow+ weakFlow = flow+ share.install(on: flow)++ let gate = flow.run(+ notesManager: makeNotesManager(),+ session: makeSession(),+ settings: settings,+ storeManager: store,+ paywall: paywall+ )+ #expect(gate == .blocked)+ #expect(paywall.pendingExportAction != nil, "the retry must actually be stored")+ #expect(share.callCount == 0)+ }++ // The document screen is gone; only the window-scoped presenter — and+ // the retry it holds — is left.+ #expect(weakFlow != nil, "the stored retry must keep alive the flow it needs")++ // The purchase lands afterwards, and the paywall's onDismiss fires.+ store.setEntitlementStateForTesting(.unlocked)+ paywall.didCompletePurchase = true+ paywall.handleDismiss(entitlementState: .unlocked)++ #expect(share.callCount == 1, "the paid-for export must not be silently dropped")+ }+ // MARK: - Share failure @Test("share failure sets the export-error state")@@ -275,6 +335,7 @@ struct ExportNotesFlowTests { func shareFailureSetsErrorState() { defer { clearUsername() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore() let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -287,7 +348,8 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) #expect(flow.showExportError)@@ -302,6 +364,7 @@ struct ExportNotesFlowTests { func nudgeRoutesToCoordinatorBanner() { defer { clearUsername() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore(count: StoreManager.nudgeThreshold) let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -312,7 +375,8 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) // Count incremented exactly once: 10 → 11, so 9 of 20 remain.@@ -325,6 +389,7 @@ struct ExportNotesFlowTests { func noBannerBelowNudgeWindow() { defer { clearUsername() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore() let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -335,7 +400,8 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) #expect(store.exportCount == 1)@@ -353,6 +419,7 @@ struct ExportNotesFlowTests { func resetSessionStateDefusesOpenPrompt() { defer { clearUsername() } let settings = makeSettings(username: nil)+ let paywall = PaywallPresenter() let store = makeStore() let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -363,7 +430,8 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) coordinator.bannerMessage = "Notes copied" #expect(flow.showUsernamePrompt)@@ -388,6 +456,7 @@ struct ExportNotesFlowTests { func cancelUsernamePromptClearsPendingShare() { defer { clearUsername() } let settings = makeSettings(username: nil)+ let paywall = PaywallPresenter() let store = makeStore() let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -398,7 +467,8 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) flow.cancelUsernamePrompt() @@ -417,6 +487,7 @@ struct ExportNotesFlowTests { func loadingGateIsSilentNoOp() { defer { clearUsername() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore(entitlementState: .loading) let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow@@ -427,14 +498,15 @@ struct ExportNotesFlowTests { notesManager: makeNotesManager(), session: makeSession(), settings: settings,- storeManager: store+ storeManager: store,+ paywall: paywall ) #expect(gate == .loading) #expect(share.callCount == 0) #expect(!flow.showUsernamePrompt) #expect(!flow.showExportError)- #expect(store.pendingExportAction == nil)- #expect(!store.showPaywall)+ #expect(paywall.pendingExportAction == nil)+ #expect(!paywall.isPresented) } }diff --git a/prismTests/ExportNotesFlowTests.swift b/prismTests/ExportNotesFlowTests.swiftindex 7fbc034..6061677 100644--- a/prismTests/ExportNotesFlowTests.swift+++ b/prismTests/ExportNotesFlowTests.swift@@ -8,7 +8,7 @@ // The flow is owned by DocumentLayoutCoordinator and its alerts are hosted // by DocumentReaderView, so it survives dismissal of the pane that // triggered the export: the post-purchase retry stored in-// StoreManager.pendingExportAction references flow.run, never view-local+// PaywallPresenter.pendingExportAction references flow.run, never view-local // @State (Req 2.4/2.5). //
diff --git a/prismTests/CopyNotesButtonTests.swift b/prismTests/CopyNotesButtonTests.swiftindex 196bb77..fec7783 100644--- a/prismTests/CopyNotesButtonTests.swift+++ b/prismTests/CopyNotesButtonTests.swift@@ -139,6 +139,7 @@ struct CopyNotesButtonTests { defer { clearSettings() } defer { ClipboardRecorder.restore() } let settings = makeSettings(username: "Reviewer", includeResolved: true)+ let paywall = PaywallPresenter() let store = makeStore() let notes = makePopulatedNotesManager() let clipboard = ClipboardRecorder()@@ -150,6 +151,7 @@ struct CopyNotesButtonTests { blocks: notes.blocks, settings: settings, storeManager: store,+ paywall: paywall, onBanner: { banners.append($0) } ) @@ -176,6 +178,7 @@ struct CopyNotesButtonTests { defer { clearSettings() } defer { ClipboardRecorder.restore() } let settings = makeSettings(username: "Reviewer", includeResolved: false)+ let paywall = PaywallPresenter() let store = makeStore() let notes = makePopulatedNotesManager() let clipboard = ClipboardRecorder()@@ -186,6 +189,7 @@ struct CopyNotesButtonTests { blocks: notes.blocks, settings: settings, storeManager: store,+ paywall: paywall, onBanner: { _ in } ) @@ -207,6 +211,7 @@ struct CopyNotesButtonTests { defer { clearSettings() } defer { ClipboardRecorder.restore() } let settings = makeSettings(username: "Reviewer", includeResolved: true)+ let paywall = PaywallPresenter() let store = makeStore() let block = MarkdownBlock.paragraph(markdown: "Anchored paragraph content.")@@ -250,6 +255,7 @@ struct CopyNotesButtonTests { blocks: [block], settings: settings, storeManager: store,+ paywall: paywall, onBanner: { _ in } ) @@ -277,6 +283,7 @@ struct CopyNotesButtonTests { defer { clearSettings() } defer { ClipboardRecorder.restore() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore(count: StoreManager.nudgeThreshold) let notes = makePopulatedNotesManager() let clipboard = ClipboardRecorder()@@ -288,6 +295,7 @@ struct CopyNotesButtonTests { blocks: notes.blocks, settings: settings, storeManager: store,+ paywall: paywall, onBanner: { banners.append($0) } ) @@ -305,6 +313,7 @@ struct CopyNotesButtonTests { defer { clearSettings() } defer { ClipboardRecorder.restore() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore(count: StoreManager.freeExportLimit) let notes = makePopulatedNotesManager() let clipboard = ClipboardRecorder()@@ -317,14 +326,15 @@ struct CopyNotesButtonTests { blocks: notes.blocks, settings: settings, storeManager: store,+ paywall: paywall, onBanner: { banners.append($0) }, onBlocked: { onBlockedCount += 1 } ) #expect(gate == .blocked) #expect(onBlockedCount == 1)- #expect(store.pendingExportAction != nil)- #expect(store.showPaywall)+ #expect(paywall.pendingExportAction != nil)+ #expect(paywall.isPresented) #expect(clipboard.copied.isEmpty) #expect(store.exportCount == StoreManager.freeExportLimit) #expect(banners.isEmpty)@@ -339,6 +349,7 @@ struct CopyNotesButtonTests { defer { clearSettings() } defer { ClipboardRecorder.restore() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore(count: StoreManager.freeExportLimit) let notes = makePopulatedNotesManager() let clipboard = ClipboardRecorder()@@ -350,6 +361,7 @@ struct CopyNotesButtonTests { blocks: notes.blocks, settings: settings, storeManager: store,+ paywall: paywall, onBanner: { banners.append($0) } ) #expect(gate == .blocked)@@ -357,7 +369,7 @@ struct CopyNotesButtonTests { // The purchase completes and the paywall's onDismiss fires the retry. store.setEntitlementStateForTesting(.unlocked)- store.pendingExportAction?()+ paywall.pendingExportAction?() #expect(clipboard.copied.count == 1) #expect(clipboard.copied.first?.contains(notes.activeMarker) == true)@@ -374,6 +386,7 @@ struct CopyNotesButtonTests { defer { clearSettings() } defer { ClipboardRecorder.restore() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore() let notes = makePopulatedNotesManager() let clipboard = ClipboardRecorder()@@ -386,6 +399,7 @@ struct CopyNotesButtonTests { blocks: notes.blocks, settings: settings, storeManager: store,+ paywall: paywall, onBanner: { paneBanners.append($0) }, retryOnBanner: { coordinatorBanners.append($0) } )@@ -405,6 +419,7 @@ struct CopyNotesButtonTests { defer { clearSettings() } defer { ClipboardRecorder.restore() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore(count: StoreManager.freeExportLimit) let notes = makePopulatedNotesManager() let clipboard = ClipboardRecorder()@@ -417,6 +432,7 @@ struct CopyNotesButtonTests { blocks: notes.blocks, settings: settings, storeManager: store,+ paywall: paywall, onBanner: { paneBanners.append($0) }, retryOnBanner: { coordinatorBanners.append($0) } )@@ -426,7 +442,7 @@ struct CopyNotesButtonTests { // The purchase completes and the paywall's onDismiss fires the retry. store.setEntitlementStateForTesting(.unlocked)- store.pendingExportAction?()+ paywall.pendingExportAction?() #expect(clipboard.copied.count == 1) #expect(paneBanners.isEmpty, "The dismissed pane's banner must not receive the retry banner")@@ -441,6 +457,7 @@ struct CopyNotesButtonTests { defer { clearSettings() } defer { ClipboardRecorder.restore() } let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter() let store = makeStore(entitlementState: .loading) let notes = makePopulatedNotesManager() let clipboard = ClipboardRecorder()@@ -453,6 +470,7 @@ struct CopyNotesButtonTests { blocks: notes.blocks, settings: settings, storeManager: store,+ paywall: paywall, onBanner: { banners.append($0) }, onBlocked: { onBlockedCount += 1 } )@@ -462,7 +480,7 @@ struct CopyNotesButtonTests { #expect(onBlockedCount == 0) #expect(clipboard.copied.isEmpty) #expect(store.exportCount == 0)- #expect(store.pendingExportAction == nil)- #expect(!store.showPaywall)+ #expect(paywall.pendingExportAction == nil)+ #expect(!paywall.isPresented) } }
diff --git a/prismTests/ExportFileMenuActionTests.swift b/prismTests/ExportFileMenuActionTests.swiftindex 2d3207b..19b9cf8 100644--- a/prismTests/ExportFileMenuActionTests.swift+++ b/prismTests/ExportFileMenuActionTests.swift@@ -72,18 +72,20 @@ struct ExportFileMenuActionTests { @MainActor func runGatedExportSilentWhileLoading() { let counter = makeCounter()+ let paywall = PaywallPresenter() let store = StoreManager(exportCounter: counter, entitlementState: .loading) var performCalls = 0 var retryCalls = 0- store.runGatedExport(+ paywall.runGatedExport(+ storeManager: store, retry: { retryCalls += 1 }, perform: { _ in performCalls += 1 } ) #expect(performCalls == 0) #expect(retryCalls == 0)- #expect(store.showPaywall == false)- #expect(store.pendingExportAction == nil)+ #expect(paywall.isPresented == false)+ #expect(paywall.pendingExportAction == nil) } }diff --git a/prismTests/ExportFileMenuActionTests.swift b/prismTests/ExportFileMenuActionTests.swiftindex 19b9cf8..ae08d60 100644--- a/prismTests/ExportFileMenuActionTests.swift+++ b/prismTests/ExportFileMenuActionTests.swift@@ -7,8 +7,9 @@ import Foundation /// /// Before the fix, the macOS File > Export command stayed enabled while /// `StoreManager.entitlementState == .loading`. Clicking it routed through-/// `StoreManager.runGatedExport(...)`, whose `.loading` branch returns-/// immediately, producing a silent no-op.+/// the gated-export runner (`PaywallPresenter.runGatedExport(...)` since+/// T-1779), whose `.loading` branch returns immediately, producing a silent+/// no-op. /// /// The fix exposes an `isAvailable` flag on the focused-scene export action /// so the menu item can disable itself while entitlements load. These tests
diff --git a/specs/inapp-purchase/decision_log.md b/specs/inapp-purchase/decision_log.mdindex a5dec76..00b116c 100644--- a/specs/inapp-purchase/decision_log.md+++ b/specs/inapp-purchase/decision_log.md@@ -409,3 +409,45 @@ The check is one line and reuses the existing `PrismApp.isUnitTestHost` constant - `StoreManager` knows about the `PrismApp.isUnitTestHost` test detector (acceptable — both belong to the same module) ---++## Decision 13: Paywall Presentation Is Per-Scene, Entitlement Stays App-Wide++**Date**: 2026-08-15+**Status**: accepted++### Context++`StoreManager` is app-wide — `PrismApp` owns one `@State` instance and injects it into every scene — and it originally carried the paywall's presentation state as well: `showPaywall`, `pendingExportAction`, and `didCompletePurchase`. Each window's `MainContentView` bound its own sheet to that one flag and each window's `onDismiss` read and cleared the same pending retry.++With more than one window open (macOS, iPadOS multi-scene) the paywall behaved as one object with several owners: raising it in one window presented it in all of them, dismissing it anywhere cleared everyone's state, a second blocked export overwrote the first window's retry, and the macOS Settings scene's unlock row presented the paywall over a document window rather than over Settings (T-1779).++### Decision++Split the state by scope. `StoreManager` keeps only account-scoped truth (entitlement, products, export counter, `checkExport()`). A new per-scene `PaywallPresenter` (`prism/Views/PaywallPresenter.swift`) owns `isPresented`, `pendingExportAction`, `didCompletePurchase`, and the gated-export runner `runGatedExport(storeManager:retry:perform:)`. Every scene that can raise a paywall — each document window and the macOS Settings scene — creates its own presenter, injects it into the environment, and attaches the shared `paywallPresentation(...)` host modifier.++### Rationale++Which sheet is up, and which export to re-run afterwards, are facts about one window. Holding them on the app-wide object made a per-window fact global, and nothing in the type system objected. Once the fields live on a per-scene object there is no app-wide flag left for two windows to bind to, so the defect cannot be reintroduced by adding another gated call site. It also produces the right behaviour in the case the ticket calls out: two windows blocked at once keep one request each instead of one displacing the other.++### Alternatives Considered++- **Owner token on `StoreManager`**: keep the state app-wide, tag it with the requesting scene's id, and have each sheet binding compare before presenting - rejected. Only one paywall request can exist app-wide, so a second window's blocked export must still displace the first, and correctness depends on every future call site remembering to pass and check the token.+- **Move the state into `DocumentLayoutCoordinator`**: it is already per-document and already owns `ExportNotesFlow` - rejected. It is per-document, not per-window, and the paywall must also be presentable from the home screen and from the macOS Settings scene, neither of which has a coordinator.++### Consequences++**Positive:**+- Presentation ownership is structural, enforced by the compiler rather than by review+- Two windows can hold independent paywall requests; each purchase completes the export that prompted it+- The macOS Settings scene presents its own paywall+- The dismiss rule (retry only on purchase + `.unlocked`) is now a unit-testable method rather than view code++**Negative:**+- Gated call sites take one more parameter (`CopyNotesButton.perform`, `ExportNotesFlow.run`), and views reading `@Environment(PaywallPresenter.self)` trap if a scene forgets to inject one — including SwiftUI previews+- The paywall's state is now duplicated per scene, so anything genuinely app-wide about presentation (there is nothing today) would need explicit coordination++### Impact++`StoreManager`, `prismApp.swift` (both scenes), `PaywallSheet`, `SettingsView`, and the three gated call sites. The T-1868 observation clock and every entitlement writer are untouched.++---diff --git a/specs/inapp-purchase/decision_log.md b/specs/inapp-purchase/decision_log.mdindex 00b116c..7bbfa46 100644--- a/specs/inapp-purchase/decision_log.md+++ b/specs/inapp-purchase/decision_log.md@@ -445,9 +445,14 @@ Which sheet is up, and which export to re-run afterwards, are facts about one wi **Negative:** - Gated call sites take one more parameter (`CopyNotesButton.perform`, `ExportNotesFlow.run`), and views reading `@Environment(PaywallPresenter.self)` trap if a scene forgets to inject one — including SwiftUI previews - The paywall's state is now duplicated per scene, so anything genuinely app-wide about presentation (there is nothing today) would need explicit coordination+- The retry capture rule is now two-sided and review-enforced rather than structural: weak on the presenter (a strong capture is a self-retain cycle that leaks the window), strong on everything else (the presenter outlives the document screen, so a weakly-held owner makes a paid-for export a silent no-op). Both halves were got wrong once during review. Passing the presenter into the retry closure instead of having each call site capture it would make the rule structural; deferred because it changes the signature at three call sites and their tests+- Requirement 6.7 keeps a residual the split does not remove: if the document screen is torn down *and* no `exportUsername` was ever set, the surviving retry can only ask for one, and its alert host (`ExportNotesFlowAlerts`, and the equivalent `@State` alerts on the macOS File > Export path) lives on the `DocumentReaderView` that is gone. This is a property of the alert-hosting design (notes-action-placement Decision 13), not of this change — the share path, taken by every user who has exported before, completes+- `didCompletePurchase` narrows with the split, and one cross-scene case regresses. Entitlement is app-wide but the flag is now per-scene, so a purchase completed in a *different* scene does not mark the blocked scene's presenter. On macOS, where sheets are window-modal: window A blocks an export, the user leaves A's paywall up, purchases (or restores) from the Settings window, returns to A and dismisses its paywall — A's queued export is silently discarded, where the app-wide flag would have run it. Keying `handleDismiss` on `entitlementState == .unlocked` alone would fix it (a pending retry exists only because the gate blocked while locked-and-over-limit, so being unlocked at dismissal time already means entitlement arrived in between), but it would also drop the "purchased, then dismissed" versus "swiped away" distinction that flag exists to draw — a deliberate purchase declined by swipe would then fire the export. Left as-is pending a decision, because it trades one edge case for another and changes semantics that predate this split. Note `twoBlockedExportsKeepIndependentRetries` sets the second window's flag by hand, which is what makes the gap invisible in the suite ### Impact `StoreManager`, `prismApp.swift` (both scenes), `PaywallSheet`, `SettingsView`, and the three gated call sites. The T-1868 observation clock and every entitlement writer are untouched. +Both wiring halves in `prismApp.swift` are pinned by source-structural tests in `PaywallPresenterTests` (`bothScenesAttachThePaywallHost`, `bothScenesInjectTheirPresenter`), following the `FootnotePresentationHostTests` precedent: a direct-invocation test cannot see a scene that stops attaching the host, and that omission compiles and ships a paywall that never appears.+ ---
diff --git a/specs/inapp-purchase/implementation.md b/specs/inapp-purchase/implementation.mdindex 6395e5e..2012db8 100644--- a/specs/inapp-purchase/implementation.md+++ b/specs/inapp-purchase/implementation.md@@ -30,21 +30,25 @@ If something goes wrong (no internet, App Store down, products not loading), the `ExportCounter` is `@Observable @MainActor`, owning a single `Int`. It reads from both `NSUbiquitousKeyValueStore` (iCloud) and `UserDefaults` on init, takes the higher value, and writes back only the delta. It listens for `didChangeExternallyNotification` and reconciles by taking `max(remote, local)`. MainActor isolation serialises intra-device increments; cross-device offline races resolve to a slightly low total (Decision 8 — accepted for a soft paywall). -`StoreManager` is `@Observable @MainActor`. On init it spawns a `Transaction.updates` listener task (lifetime of the app) and a one-shot init task that runs `fetchProducts()` and `verifyEntitlements()` in parallel via `async let`. It owns the entitlement state, products, paywall presentation flags, the export counter, and a `pendingExportAction` closure used by the paywall flow. Its convenience `init()` detects `XCTestConfigurationFilePath` and degrades to a no-StoreKit test seam under xctest.+`StoreManager` is `@Observable @MainActor`. On init it spawns a `Transaction.updates` listener task (lifetime of the app) and a one-shot init task that runs `fetchProducts()` and `verifyEntitlements()` in parallel via `async let`. It owns the entitlement state, products, and the export counter. Its convenience `init()` detects `XCTestConfigurationFilePath` and degrades to a no-StoreKit test seam under xctest. -A single helper `runGatedExport(retry:perform:)` wraps the gate-check switch so all five call sites share one branching policy.+> **Superseded by Decision 13 (T-1779).** As originally built, `StoreManager` also owned the paywall presentation flags (`showPaywall`, `didCompletePurchase`) and the `pendingExportAction` closure, and the gating helper was `StoreManager.runGatedExport(retry:perform:)`. Because `StoreManager` is app-wide, every window's sheet bound to one flag. Those three fields and the helper now live on the per-scene `PaywallPresenter`; `StoreManager` keeps only account-scoped truth. The paragraphs below describe behaviour that is unchanged — but the owning type is not.++A single helper — `PaywallPresenter.runGatedExport(storeManager:retry:perform:)` since T-1779 — wraps the gate-check switch so every gated call site shares one branching policy. ### One paywall, two entry points -`PaywallSheet` is attached at `MainContentView` so it can present from any document view or from Settings. It owns a local `purchaseState: PaywallPurchaseState` (`ready | purchasing | succeeded | pending | error`), drives accessibility announcements on each transition, and handles both purchase and restore through the same state machine.+`PaywallSheet` is attached by the shared `paywallPresentation(...)` host, applied by each scene that can raise a paywall: each document window, and the macOS Settings scene. It owns a local `purchaseState: PaywallPurchaseState` (`ready | purchasing | succeeded | pending | error`), drives accessibility announcements on each transition, and handles both purchase and restore through the same state machine. -`MainContentView.handlePaywallDismiss` fires `pendingExportAction` only when `entitlementState == .unlocked` AND `didCompletePurchase`. The `didCompletePurchase` flag distinguishes "purchased then dismissed" from "swiped away" — the latter must not auto-trigger the blocked export.+`PaywallPresenter.handleDismiss(entitlementState:)` fires `pendingExportAction` only when `entitlementState == .unlocked` AND `didCompletePurchase`. The `didCompletePurchase` flag distinguishes "purchased then dismissed" from "swiped away" — the latter must not auto-trigger the blocked export. (Originally `MainContentView.handlePaywallDismiss`; same rule, moved onto the presenter by Decision 13.) ### Five call sites, one helper All gated exports go through: ```swift+// Since T-1779: paywall.runGatedExport(storeManager: storeManager, …),+// and the retry must capture the presenter weakly. storeManager.runGatedExport( retry: { triggerExportRecursively() }, perform: { gate in
diff --git a/specs/inapp-purchase/design.md b/specs/inapp-purchase/design.mdindex d767dd4..a962c4a 100644--- a/specs/inapp-purchase/design.md+++ b/specs/inapp-purchase/design.md@@ -906,6 +906,8 @@ The `ExportWithNotesButton` wraps its button action with the gate check. For iOS ### Paywall Sheet Attachment +> **Superseded by Decision 13 (T-1779).** This section — and every `storeManager.runGatedExport` / `showPaywall` / `pendingExportAction` reference elsewhere in this document — describes the original app-wide ownership. A single attachment point meant every window's sheet bound to one flag on the app-wide `StoreManager`, so the paywall presented and dismissed in all windows at once, a second blocked export overwrote the first window's retry, and the macOS Settings scene (a separate scene, which the claim below overlooks) presented over a document window. Presentation state and the gated-export runner now live on the per-scene `PaywallPresenter`, and every paywall-raising scene attaches the shared `paywallPresentation(...)` host. The dismiss rule below is unchanged; only its owner moved.+ The paywall sheet is attached at `MainContentView` in `prismApp.swift` (the root view of the window group), ensuring it can present from both document views and Settings. The `onDismiss` callback uses a `didCompletePurchase` flag to safely fire the pending action only when a purchase actually completed: ```swift
diff --git a/docs/agent-notes/inapp-purchase.md b/docs/agent-notes/inapp-purchase.mdindex 2d60c4b..314138a 100644--- a/docs/agent-notes/inapp-purchase.md+++ b/docs/agent-notes/inapp-purchase.md@@ -13,15 +13,16 @@ StoreKit 2 unlock + tip jar with a 20-export soft paywall on annotation exports. ### Services - **`prism/Services/KeyValueStoreProtocol.swift`** — abstraction over `NSUbiquitousKeyValueStore` so tests can inject a mock. `NSUbiquitousKeyValueStore` is extended to conform. - **`prism/Services/ExportCounter.swift`** — `@Observable @MainActor` class. Mirrors a single `Int` between iCloud KVS and `UserDefaults` under one `storageKey = "exportCount"`. Init reconciles by taking `max(remote, local)` and writes back only the delta. Listens for `NSUbiquitousKeyValueStore.didChangeExternallyNotification` via `addObserver(forName:object:queue:.main, using:)` (NOT the async-sequence variant — avoids `Sendable` issues with `Notification` and matches `SystemColorSchemeObserver`'s pattern). The observer block hops to MainActor via `Task { @MainActor in ... }`.-- **`prism/Services/StoreManager.swift`** — `@Observable @MainActor` class. Owns `entitlementState`, `products`, `productLoadError`, paywall flags (`showPaywall`, `pendingExportAction`, `didCompletePurchase`), and the `ExportCounter`. `init(exportCounter:)` launches a single `Task` that runs `fetchProducts()` and `verifyEntitlements()` in parallel via `async let`, plus a long-lived `Transaction.updates` listener task. Both, plus `fetchProductsTask`, are stored and cancelled in `deinit`.+- **`prism/Services/StoreManager.swift`** — `@Observable @MainActor` class. Owns `entitlementState`, `products`, `productLoadError`, and the `ExportCounter` — everything scoped to the **account**. It owns no paywall presentation state; that is per-scene and lives on `PaywallPresenter` (T-1779, see below). `init(exportCounter:)` launches a single `Task` that runs `fetchProducts()` and `verifyEntitlements()` in parallel via `async let`, plus a long-lived `Transaction.updates` listener task. Both, plus `fetchProductsTask`, are stored and cancelled in `deinit`. ### Views+- **`prism/Views/PaywallPresenter.swift`** — `@Observable @MainActor` class, one **per scene**. Owns `isPresented`, `pendingExportAction`, `didCompletePurchase`, plus `runGatedExport(storeManager:retry:perform:)` and `handleDismiss(entitlementState:)`. The same file carries the `PaywallHost` modifier, applied as `View.paywallPresentation(presenter:storeManager:settings:systemObserver:)`, which owns the sheet and calls `handleDismiss`. Holding these on the app-wide `StoreManager` meant every window's sheet bound to one flag: raising the paywall in one window presented it in all of them, a second blocked export overwrote the first window's retry, and the macOS Settings scene presented over a document window (T-1779, Decision 13). - **`prism/Views/PaywallSheet.swift`** — sheet UI with `purchaseSection`, `stateMessage`, restore button. Drives `purchaseState: PaywallPurchaseState` for `.ready`/`.purchasing`/`.succeeded`/`.pending`/`.error`. Posts `AccessibilityNotification.Announcement` on each transition. Restore goes through the same `purchaseState` machine so failures surface as an in-sheet error message. - **`prism/Views/RemainingExportsLabel.swift`** — shared caption rendered in `NotesPanel` and `SidebarNotesView`. Visibility predicate is `entitlementState == .locked && exportCount >= nudgeThreshold` — i.e. shown **after** the user has used 10 free exports (req 5.2). - **Settings (`prism/Settings/SettingsView.swift`)** — adds a "Support" sidebar category. Renders `purchaseSection` (Unlock + Restore rows) when `entitlementState != .unlocked` and `tipJarSection` (thank-you + three consumable tips, sorted by price) once unlocked. Tip purchases use `.toast(message:)` for feedback; `.pending` shows "Tip is awaiting approval." rather than being silent. ### App wiring-- **`prism/prismApp.swift`** — `@State private var storeManager = StoreManager()` injected via `.environment()`. Paywall sheet attached at `MainContentView` with `onDismiss: handlePaywallDismiss` that fires the captured `pendingExportAction` only when `entitlementState == .unlocked` AND `didCompletePurchase`. `ScenePhase` `.active` re-runs `verifyEntitlements()` and `exportCounter.reconcileFromKVS()` so other-device purchases / counter updates land on resume.+- **`prism/prismApp.swift`** — `@State private var storeManager = StoreManager()` injected via `.environment()`, app-wide. Each paywall-raising scene additionally owns a `PaywallPresenter`: `MainContentView` holds one per window, and the macOS `Settings` scene holds `settingsPaywall`. Both inject it and attach `.paywallPresentation(...)`; the host's `onDismiss` calls `handleDismiss(entitlementState:)`, which fires the captured `pendingExportAction` only when `didCompletePurchase` AND `entitlementState == .unlocked`. Both halves of that wiring are pinned by source-structural tests in `PaywallPresenterTests` — dropping the host compiles and ships a paywall that never appears; dropping the injection traps, since the reading views declare the value non-optionally. `ScenePhase` `.active` re-runs `verifyEntitlements()` and `exportCounter.reconcileFromKVS()` so other-device purchases / counter updates land on resume. - **`ExportFileButton`** (macOS File > Export...) consumes the `exportToFileAction` `FocusedValue`, which is an `ExportToFileAction` struct (`perform` + `isAvailable`). `DocumentReaderView` derives `isAvailable` from `StoreManager.isExportMenuAvailable` (false while loading) so the menu disables itself and avoids the silent `runGatedExport` `.loading` no-op (T-1138). Locked-and-over-the-limit stays available because the action presents the paywall. ## Init seam for tests@@ -38,15 +39,27 @@ The convenience `init()` also detects `XCTestConfigurationFilePath` in the envir ## Gating helper -`StoreManager.runGatedExport(retry:perform:)` is the single entry point for gated actions:+`PaywallPresenter.runGatedExport(storeManager:retry:perform:)` is the single entry point for gated actions (it lived on `StoreManager` until T-1779): ```swift-storeManager.runGatedExport(retry: { copyNotes() }) { gate in+paywall.runGatedExport(storeManager: storeManager, retry: { [weak paywall] in+ guard let paywall else { return }+ copyNotes()+}) { gate in // execute the export, then increment, then optionally show nudge } ``` -It handles `.loading` (silent return — buttons are disabled while loading), `.blocked` (sets `pendingExportAction = retry; showPaywall = true`), and routes `.allowed` / `.allowedWithNudge` to the closure with the gate result preserved. All five call sites (NotesPanel copy, SidebarNotesView copy + share, ExportWithNotesButton, DocumentReaderView macOS export) collapse to ~5 lines each instead of the original 15-line switch.+It handles `.loading` (silent return — buttons are disabled while loading), `.blocked` (sets `pendingExportAction = retry; isPresented = true` on **that scene's** presenter), and routes `.allowed` / `.allowedWithNudge` to the closure with the gate result preserved. The gate decision itself stays on `StoreManager.checkExport()`; only the presentation side effect is per-scene. The three call sites (`CopyNotesButton.perform`, `ExportNotesFlow.run`, `DocumentReaderView.runGatedFileExport`) each collapse to ~5 lines instead of the original 15-line switch.++### The retry capture rule: weak on the presenter, strong on the models++Non-obvious, and both halves have already been got wrong once (T-1779 review):++- **Weak on the presenter.** `pendingExportAction` is stored *on* the presenter, so a strong capture is a self-retain cycle. That was harmless while the presenter was an immortal app-wide singleton; now that it dies with its window, the cycle leaks the presenter and everything the retry captured. The implicit form is the trap: a SwiftUI view that reads the presenter from `@Environment` capturing `self` is a strong capture. That is why `DocumentReaderView.runGatedFileExport` is `static` and its resume/save-panel halves bind their dependencies explicitly rather than reaching through `self`.+- **Strong on everything else.** The presenter outlives the *document screen* that raised the paywall (`ExportNotesFlow` is document-scoped; the presenter is window-scoped), so a retry that holds its owner weakly finds it gone after a navigate-back / document-switch / `requestClose()` and no-ops silently — the user paid and got nothing, with no error to show them. `ExportNotesFlow.run` therefore captures `[self, weak paywall]`. It is not a cycle: the flow never references the presenter, so the chain is presenter → retry → flow and ends there.++**Residual, accepted:** if the document screen is gone *and* no `exportUsername` was ever set, the surviving share retry can only ask for one — and its alert host (`ExportNotesFlowAlerts`) lives on the `DocumentReaderView` that is gone, so the prompt has nowhere to appear. A property of the alert-hosting design (notes-action-placement Decision 13), not of the capture. The share path — every user who has exported before — completes. The same shape exists on the macOS File > Export path. For the nudge message, two helpers cover the two shapes of call site: - `StoreManager.nudgeMessage(for: gate)` for synchronous flows that still hold the gate value.
diff --git a/CLAUDE.md b/CLAUDE.mdindex 498f0a6..e9d0831 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -94,16 +94,17 @@ Implementation history and gotchas (the `<script>` data-island CSP trap, the nat 12. Linked images show a two-button overlay ("View Image" / "Follow Link") matching the MermaidPreviewCard pattern: iOS uses tap-to-reveal with 3s auto-dismiss; macOS uses hover ### In-App Purchase System-1. `StoreManager` (`@Observable @MainActor`) owns StoreKit 2 product fetching, entitlement verification, the export counter, and paywall presentation flag (`showPaywall`)+1. `StoreManager` (`@Observable @MainActor`) owns StoreKit 2 product fetching, entitlement verification, and the export counter — everything scoped to the ACCOUNT. It owns no paywall presentation state: that is per-scene and lives on `PaywallPresenter` (T-1779) 2. `EntitlementState` is tri-state (`.loading` / `.locked` / `.unlocked`); the paywall and Settings UI branch on it. All writes flow through one commit point ordered by a monotonic observation clock — recency of information wins, never completion order — because a verification scan suspends mid-read and MainActor isolation does not cover that reentrancy window (T-1868). The StoreKit scan sits behind the injectable `EntitlementSource` seam so the ordering rules are unit-testable 3. `ExportCounter` tracks the export count with iCloud KVS sync and a UserDefaults mirror; reconciles on init and on external KVS change notifications 4. `KeyValueStoreProtocol` abstracts `NSUbiquitousKeyValueStore` so unit tests can inject a stub-5. Export gating: callers use `StoreManager.runGatedExport(retry:perform:)`, which returns `.loading` (no-op), presents the paywall on `.blocked`, or invokes the perform closure on `.allowed` / `.allowedWithNudge`-6. `PaywallSheet` runs the purchase flow with explicit ready / processing / pending (Ask to Buy) / error states and surfaces restore success / "no purchases" / failure to the user-7. `pendingExportAction` + `didCompletePurchase` route a successful purchase back through `MainContentView`'s paywall `onDismiss` to retry the originally-blocked export — swipe-to-dismiss without a purchase clears state without firing the action-8. Free-tier mechanics: 20 free exports total. Toast nudge fires on the 10th completion (event: `count >= nudgeThreshold - 1` pre-increment); persistent indicator shows once the count is at or above 10 (state: `count >= nudgeThreshold` post-increment)-9. Gated actions: copy notes, share notes, file export with inline notes. Reading features and code/mermaid copy remain free-10. `Products.storekit` (in `prism/Resources/`) supplies the four products (1 non-consumable unlock, 3 consumable tips) for local StoreKit testing; the scheme references it via `StoreKitConfigurationFileReference`+5. `PaywallPresenter` (`prism/Views/PaywallPresenter.swift`, `@Observable @MainActor`) is the PER-SCENE owner of `isPresented`, `pendingExportAction`, and `didCompletePurchase`. `MainContentView` holds one as `@State` per window and the macOS `Settings` scene holds its own; each attaches the shared `paywallPresentation(presenter:storeManager:settings:systemObserver:)` host, which owns the sheet and calls `handleDismiss(entitlementState:)`. Holding those three fields on the app-wide `StoreManager` meant every window's sheet bound to one flag — the paywall presented and dismissed in all windows, a second blocked export overwrote the first window's retry, and a Settings-initiated unlock presented over a document window (T-1779)+6. Export gating: callers use `PaywallPresenter.runGatedExport(storeManager:retry:perform:)`, which returns `.loading` (no-op), presents THAT scene's paywall on `.blocked`, or invokes the perform closure on `.allowed` / `.allowedWithNudge`. The gate decision itself stays on `StoreManager.checkExport()`; only the presentation side effect is per-scene+7. `PaywallSheet` runs the purchase flow with explicit ready / processing / pending (Ask to Buy) / error states and surfaces restore success / "no purchases" / failure to the user+8. The presenter's `pendingExportAction` + `didCompletePurchase` route a successful purchase back through its own `handleDismiss` to retry the originally-blocked export — swipe-to-dismiss without a purchase, or a purchase whose entitlement never reached `.unlocked`, clears state without firing the action+9. Free-tier mechanics: 20 free exports total. Toast nudge fires on the 10th completion (event: `count >= nudgeThreshold - 1` pre-increment); persistent indicator shows once the count is at or above 10 (state: `count >= nudgeThreshold` post-increment)+10. Gated actions: copy notes, share notes, file export with inline notes. Reading features and code/mermaid copy remain free+11. `Products.storekit` (in `prism/Resources/`) supplies the four products (1 non-consumable unlock, 3 consumable tips) for local StoreKit testing; the scheme references it via `StoreKitConfigurationFileReference` ### Security for WKWebView Rendering - All WKWebViews use `websiteDataStore: .nonPersistent()`@@ -210,6 +211,7 @@ prism/ │ ├── NotesPanel.swift │ ├── URLInputSheet.swift # URL input dialog for remote files │ ├── PaywallSheet.swift # In-app purchase paywall UI+│ ├── PaywallPresenter.swift # Per-scene paywall presentation + gated-export runner │ ├── RemainingExportsLabel.swift # Persistent indicator shown once free quota crosses nudge threshold │ ├── FontPickerView.swift # Font picker with platform variants │ ├── TypographyModifier.swift # Document content font application
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9938073..fd10022 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The unlock screen now belongs to the window that asked for it (T-1779). With more than one document window open the paywall was a single app-wide thing: reaching the free-export limit in one window put it in front of every window, closing it anywhere closed it everywhere, and a second blocked export elsewhere quietly took over the first one's place — so a completed purchase could run the wrong export, or none at all. An unlock started from Settings on the Mac appeared over a document window rather than over Settings. Each window, and the Settings window, now keeps its own unlock screen and its own memory of which export was waiting, so a purchase always finishes the export that prompted it and leaves other windows untouched. What you have bought and how many free exports remain are still shared across the whole app, as before. - Arrow keys, Page Up/Down, Space, and the View menu's **Page Down**/**Page Up**/**Scroll to Top**/**Scroll to Bottom** no longer scroll the document behind an open note, footnote, add-note, reply, or document-note modal (T-1099, reopened). This was fixed once before; the WebKit rendering cutover restructured the views that carried the fix and the binding was never rebuilt, so scroll commands kept reaching the document underneath a modal that should have blocked them. The gate is restored on both the iPhone and iPad/Mac layouts, taking effect immediately when a modal is already open and staying live across every presentation and dismissal. - Replying to a document-level note is no longer silently discarded on a document that has only imported notes (T-1865). NotesPanel and SidebarNotesView create replies through a convenience method that used the document's saved user notes as its source of context; on a document where no user note had ever been created — only imported ones — that context was `nil`, so the guard returned early before the reply was ever built, leaving the tap with no visible effect and nothing written to disk. The method now falls back to the document's cached identifier, the same fallback its sibling document-note-creation method already used, so a reply always creates the note container it needs. - The safeguard that stops a broken document from reloading forever now holds when the crashes keep landing mid-load (T-2107). When a document's rendering process stops, the app reloads it, and if the reloads repeatedly fail to bring the document back it gives up after a few attempts and shows a banner offering a manual reload rather than retrying endlessly (T-1943 below). But a reload was counted as having succeeded the moment the page reported in — before it had finished laying out — so a renderer that reliably crashed in that window looked like a fresh failure each time instead of the same one continuing: the count started over on every attempt, and the document reloaded forever, which is exactly the loop the safeguard exists to prevent. A recovery now only counts as successful once the reloaded document has actually settled on screen, so crashes landing in that window accumulate toward the limit and reach the banner. Recoveries that do bring the document back still reset the count, and the banner's reload still restores everything as before.diff --git a/CHANGELOG.md b/CHANGELOG.mdindex fd10022..e1816a5 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- The unlock screen now belongs to the window that asked for it (T-1779). With more than one document window open the paywall was a single app-wide thing: reaching the free-export limit in one window put it in front of every window, closing it anywhere closed it everywhere, and a second blocked export elsewhere quietly took over the first one's place — so a completed purchase could run the wrong export, or none at all. An unlock started from Settings on the Mac appeared over a document window rather than over Settings. Each window, and the Settings window, now keeps its own unlock screen and its own memory of which export was waiting, so a purchase always finishes the export that prompted it and leaves other windows untouched. What you have bought and how many free exports remain are still shared across the whole app, as before.+- The unlock screen now belongs to the window that asked for it (T-1779). With more than one document window open the paywall was a single app-wide thing: reaching the free-export limit in one window put it in front of every window, closing it anywhere closed it everywhere, and a second blocked export elsewhere quietly took over the first one's place — so a completed purchase could run the wrong export, or none at all. An unlock started from Settings on the Mac appeared over a document window rather than over Settings. Each window, and the Settings window, now keeps its own unlock screen and its own memory of which export was waiting, so buying from the unlock screen a blocked export raised finishes that export and leaves other windows untouched. What you have bought and how many free exports remain are still shared across the whole app, as before. - Arrow keys, Page Up/Down, Space, and the View menu's **Page Down**/**Page Up**/**Scroll to Top**/**Scroll to Bottom** no longer scroll the document behind an open note, footnote, add-note, reply, or document-note modal (T-1099, reopened). This was fixed once before; the WebKit rendering cutover restructured the views that carried the fix and the binding was never rebuilt, so scroll commands kept reaching the document underneath a modal that should have blocked them. The gate is restored on both the iPhone and iPad/Mac layouts, taking effect immediately when a modal is already open and staying live across every presentation and dismissal. - Replying to a document-level note is no longer silently discarded on a document that has only imported notes (T-1865). NotesPanel and SidebarNotesView create replies through a convenience method that used the document's saved user notes as its source of context; on a document where no user note had ever been created — only imported ones — that context was `nil`, so the guard returned early before the reply was ever built, leaving the tap with no visible effect and nothing written to disk. The method now falls back to the document's cached identifier, the same fallback its sibling document-note-creation method already used, so a reply always creates the note container it needs. - The safeguard that stops a broken document from reloading forever now holds when the crashes keep landing mid-load (T-2107). When a document's rendering process stops, the app reloads it, and if the reloads repeatedly fail to bring the document back it gives up after a few attempts and shows a banner offering a manual reload rather than retrying endlessly (T-1943 below). But a reload was counted as having succeeded the moment the page reported in — before it had finished laying out — so a renderer that reliably crashed in that window looked like a fresh failure each time instead of the same one continuing: the count started over on every attempt, and the document reloaded forever, which is exactly the loop the safeguard exists to prevent. A recovery now only counts as successful once the reloaded document has actually settled on screen, so crashes landing in that window accumulate toward the limit and reach the banner. Recoveries that do bring the document back still reset the count, and the banner's reload still restores everything as before.
All eleven files fixed in this pass are in the working tree, unstaged and uncommitted, so they can be reviewed before they join the branch. git diff in the worktree shows exactly this pass; the PR itself is the three commits.
iOS and macOS builds both succeeded; SwiftLint reports 0 violations across 530 files. The macOS build emits pre-existing warnings at four sites (StoreManager default args, CopyNotesButton.copyToClipboard, DocumentLayoutCoordinator.onSettled) — all verified present unchanged on origin/main and none in the changed regions or the new file.
Two make test-quick attempts died mid-compile and a third hung with the process at 0% CPU. The cause was another worktree (T-1812) running its own macOS suite concurrently — two xcodebuild macOS runs both launch prism.app and contend. Not a defect in this branch. Once the machine was free the targeted suites ran clean: 74/74 passed, including all six lifetime and wiring tests.
The clean full run finished 4272 passed / 1 failed / 37 skipped / 2 expected failures. The failure was WebScrollabilityReportingTests/reportArrivesWithinMaxWaitDuringTriggerBurst — a debounce-under-load assertion ("only 1 triggers in 0.19 seconds") in a subsystem this PR does not touch. A contended earlier run failed a different timing test (RawSourceViewModelTests/rapidLoadContentLeavesConsistentState); a different flake each run is the signature of load sensitivity, not a regression. Both classes were re-run in isolation and pass: 103/103.
Worth a moment before merge. It is documented, not fixed, because the fix is a semantics change rather than a bug fix — see the findings table. If the answer is "the user became entitled, run their export", the change is one line in handleDismiss plus removing didCompletePurchase as dead state; if it is "a swipe means no", the current behaviour is already right and only the documentation needed to catch up, which it now has.
CopyNotesButtonTests / ExportNotesFlowTests carry known parallel-locale flakiness (T-1652) that does not reproduce in a single-configuration run — and did not here: both suites passed in the targeted run under -only-test-configuration "en (base)" -parallel-testing-worker-count 1.
The skill's default is to write the three-level explanation to specs/{feature}/implementation.md, but that path already holds the feature's as-built document. Overwriting it would have destroyed content, so the explanation lives in this page only; implementation.md was instead corrected where it had gone stale.