prism branch T-1979/bugfix-…-stale-state commits 2 files 6 touched lines +366 / -15 targeted tests 19 passed / 0 failed lint 0 violations

Pre-push review: T-1979 — open footnote popovers keep stale render state

PR #340. An open footnote popover now re-presents whenever its render inputs move, driven by FootnotePopoverWebPage.RenderKey and one .onChange(of: renderKey, initial: true) computed in body. This review additionally adjudicates whether the ticket's third scenario — a document reload replacing FootnoteData under the same identifier — actually works in production, since the fixer round deferred it as a coverage hole.

At a glance

  • Root cause was twofold and both halves are fixed: the trigger was keyed on the one input that never moves (footnoteId), and renderSettings was read only inside an onAppear closure, so the AppSettings reads registered nothing with Observation and no body pass ever happened to re-fire a trigger. Computing renderKey in body fixes the registration; keying on the whole value fixes the trigger.
  • Reload scenario adjudication: WORKS. Verified by running the exact SwiftUI topology as a standalone probe — a @Observable read inside a .sheet content closure is tracked as its own graph attribute, so replacing session.footnoteData re-invokes the closure and rebuilds FootnotePopoverView with fresh data even when neither the layout body nor FootnotePresenter.body(content:) re-runs.
  • Mutation-checked independently. I reverted present's guard to key.footnoteId != renderKey?.footnoteId and re-ran the suite: rePresentIsDedupedByRenderKey, liveDefinitionReplacementRefreshesOpenPopover and liveThemeChangeRefreshesOpenPopover all failed. Reverted afterwards; working tree is clean.
  • The dedupe is load-bearing, not an optimisation. Without it every unrelated body pass would re-emit identical bytes and blank a live page mid-read. reset() deliberately stays unconditional so the Req 8.4 isolation guarantee is never skipped on a key comparison.
  • One real gap remains, and it is test-only: nothing pins that FootnotePresenter hands the popover live session.footnoteData. A refactor that snapshots the data (a stored let captured at presentation, or a @State copy) would silently re-break scenario 3 with every existing test still green.

Verdict

Ready to push

The fix is correct, minimal, and well pinned. All three ticket scenarios work in production — including the deferred reload scenario, which I verified empirically rather than by inspection (see the adjudication below). Targeted suites (WebFootnotePopoverTests + FootnotePresentationHostTests) run 19/19 green, make lint reports 0 violations, and a mutation check I ran myself (reverting present to id-keyed dedupe) turns 3 of the new tests red, so they are not vacuous. Nothing here is a blocker; the remaining items are one genuine test-coverage gap and two nits.

Review findings

5 raised · 0 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Tapping a footnote badge opens a small sheet showing that footnote's text. That sheet is drawn by a tiny embedded web page. Unlike the main document, this little page has no live channel back to the app — everything it shows (the text, the colours, the font, the text size, whether HTML comments are visible) is baked into the HTML at the moment it is built.

That means the only way to change what an open sheet shows is to rebuild and reload it. The app used to rebuild it only when you opened it or when you switched to a different footnote. So if you changed the theme, turned on Increase Contrast, changed the font or text size, toggled Show HTML comments, or the file changed on disk while the sheet was open, the sheet just sat there showing whatever it looked like when it opened.

Why it matters

It is a visible inconsistency — the document behind the sheet updates and the sheet does not — and at accessibility settings it is worse than cosmetic: an open footnote could stay small and low-contrast while everything around it grew.

Key concepts

  • Render key: a single value that bundles up everything the sheet's appearance depends on. If any part of it differs, the sheet is rebuilt; if none does, nothing happens (rebuilding for no reason would blank the page while you are reading it).
  • Observation: SwiftUI only notices that a setting changed if the view reads that setting while drawing itself. The old code read the settings inside a "when this appears" callback, which does not count as drawing — so nothing was ever noticed. Moving the read into the view's body is what makes the whole fix work.

Architecture

FootnotePopoverWebPage is bridge-less by design (webview-rendering Decision 10): it serves emitted HTML through PrismDocSchemeHandler with allowsContentJavaScript = false and no message handlers, so palette, contrast, typography and comment visibility are baked in by BlockHTMLEmitter at emit time. There is no applyTheme/applyTypography push available the way there is for the document surface — a re-emit plus reload is the only delivery mechanism.

The fix introduces FootnotePopoverWebPage.RenderKey — an Equatable, Sendable triple of footnoteId, the resolved FootnoteDefinition?, and the RenderSettings value. Resolving the definition inside the key's initialiser is the interesting choice: it makes the key sensitive to a same-id content replacement (the reload case) while staying insensitive to reloads that touch other footnotes, so an unrelated document change does not blank the popover.

FootnotePopoverView computes renderKey as a property read from body and drives one .onChange(of: renderKey, initial: true). initial: true replaces the old onAppear, so there is exactly one present trigger.

Patterns

  • Idempotent present. present compares the incoming key against the stored one and returns early when unchanged. The caller is now free to fire on any body pass.
  • Generation counter. renderGeneration increments per actual load, doubles as the ?rev= query so consecutive presents are distinct navigations rather than a collapsed same-URL reload, and guards the async load consumer so a superseded task neither acts on its events nor logs a failure that only means "you were overtaken".
  • Synchronous state, async transport. Correctness of overlapping presents comes from htmlBox, which present writes before calling load() — every navigation, superseded or not, is served the newest bytes. The generation guard is robustness on top, and the second commit corrected both the code comment and the test that had claimed otherwise.

Trade-offs

Re-presenting is a full reload of the sheet's page: scroll position inside a long footnote is lost on a theme flip. Given the medium-detent sheet and the alternative (no update at all) that is the right call, and the dedupe keeps it from happening when nothing moved.

The Observation defect is the real fix

Keying on the composite value is necessary but not sufficient. In the pre-fix code renderSettings was materialised only inside the onAppear closure. Closures run outside ViewBodyAccessor's withObservationTracking scope, so the reads of settings.showHTMLComments and settings.theme(for:) registered no dependency and the view was never invalidated. Even a correctly-keyed onChange would have sat there unevaluated. Hoisting the read into body via the renderKey property is what closes it, and the code comment says so explicitly — worth preserving verbatim, because it is not recoverable from the diff shape.

Reload-path adjudication (the deferred item)

The question is whether a DocumentSession reload that replaces footnoteData reaches a mounted popover, given that FootnotePresenter passes data: session.footnoteData from inside an escaping .sheet content closure and FootnotePopoverView.data is a plain stored let.

Chain: parseAndApplyBlocks assigns footnoteData (an @Observable private(set) var) after its detached parse; reloadDocument does not clear coordinator.activeFootnoteId (only resetSessionState(), fired on session.id change, does), so the sheet survives the reload. The open question was whether the sheet's stored content closure is re-invoked.

I settled it by building the exact topology as a standalone SwiftUI binary — an @Observable session, a ViewModifier holding two class refs whose body(content:) attaches .sheet(isPresented:) with a closure reading session.data, hosted in an NSHostingView. Result: mutating session.data alone re-ran the presented view's body with the new value while neither the parent body nor the modifier's body(content:) re-ran. SwiftUI evaluates the presentation's content closure as its own observation-tracked graph attribute. So the reload scenario works, for a reason that survives the fact that the modifier value is pointer-stable.

Edge cases

  • FootnoteData is Equatable, so a reload producing an identical value costs a closure re-evaluation and an equal renderKey — no reload of the page. Correct.
  • Reload dropping the footnote entirely: definition goes nil, content switches to the "not found" branch, and present still fires with an empty block list. The outer onDisappear is attached above the _ConditionalContent, so the branch switch does not spuriously reset().
  • No feedback loop: present mutates footnoteId/renderKey/renderGeneration on an @Observable page, but the view's body reads only popoverPage.page (a let), so the mutation cannot re-invalidate the body that caused it.
  • The guard self?.renderGeneration == generation inside the for try await is correct on deallocation too — nil != generation ends the consumer.

Important changes — detailed

FootnotePopoverView: renderKey computed in body, one onChange(initial: true)

prism/Views/FootnotePopoverView.swift

Why it matters. This is the whole fix. Both halves live here: the trigger is keyed on the composite value, and — more subtly — the settings are read during body evaluation so Observation registers them at all.

What to look at. FootnotePopoverView.swift:91-106

Takeaway. If a SwiftUI view must react to @Observable state, the read has to happen inside `body`. A read inside `onAppear` (or any escaping callback) registers no dependency, so the view is never invalidated and no `onChange` will ever fire. Hoisting the read into a computed property that `body` consumes is the idiom.
Rationale. Stated in the commit body and in the code comment: `onAppear` + `onChange(of: footnoteId)` could not work because the identifier is the one input that does not move in any failing scenario, and because the AppSettings reads were unregistered.

RenderKey holds the resolved FootnoteDefinition, not the whole FootnoteData

prism/ViewModels/FootnotePopoverWebPage.swift

Why it matters. This is what makes the reload scenario surgical: a document change that rewrites this footnote reloads the popover, while one that touches any other footnote leaves it alone. Keying on the whole `FootnoteData` would blank an open popover on every unrelated save.

What to look at. FootnotePopoverWebPage.swift:78-88

Takeaway. When keying a cache/dedupe on upstream state, key on the *projection you actually render*, not the container it came from. It buys precision in both directions for free.
Rationale. Documented on the type itself; pinned by `renderKeyIsInputSensitive`, which asserts both directions (same-id replacement moves the key, other-id addition does not).

present() no-ops on an unchanged key; reset() stays unconditional

prism/ViewModels/FootnotePopoverWebPage.swift

Why it matters. The dedupe is what lets the view fire on every body pass without thrashing the WebPage — an identical re-emit would blank the live page mid-read. The asymmetry with reset() is deliberate and is the Req 8.4 isolation guarantee.

What to look at. FootnotePopoverWebPage.swift:117-137

Takeaway. Make the expensive operation idempotent in its inputs, then let callers over-fire. That is far more robust than trying to make every call site fire exactly once — which is precisely the bug being fixed here.
Rationale. Stated in the doc comment on `present` and in the inline comment on `reset`'s unconditional `renderKey = nil`.

renderGeneration: URL rev + superseded-task guard

prism/ViewModels/FootnotePopoverWebPage.swift

Why it matters. Two distinct jobs in one counter. Varying `?rev=` stops consecutive presents collapsing into a single same-URL navigation; the guard stops a superseded consumer acting on events or logging a failure that only means 'you were overtaken'.

What to look at. FootnotePopoverWebPage.swift:161-186

Takeaway. Guarding a superseded async consumer is often safer than cancelling it: `cancel` on the old consumer can land after the swap and tear down the new navigation instead. Ordering, meanwhile, came from writing the shared state synchronously before starting the async work — not from the counter.
Rationale. The second commit corrected this comment after the first round overstated the guard's role; the test now asserts the property that actually holds (htmlBox written synchronously, so every navigation is served the newest bytes).

Regression suite: structural pin + input sensitivity + three live-WebPage tests

prismTests/WebRendering/WebFootnotePopoverTests.swift

Why it matters. The live tests assert the resolved `getComputedStyle` background rather than the emitted attribute, so a bake that never reaches the renderer fails. The theme test deliberately opens on dark because `prism-light` is document.css's `:root` default and a light-first test passes with no palette baked at all.

What to look at. WebFootnotePopoverTests.swift:134-368

Takeaway. When asserting that a value was applied, never start from the value that is also the default — the assertion is vacuous. Start from a non-default state so the first assertion proves the mechanism works, then move to a third distinct state so a mechanism that only works once also fails.
Rationale. Both the vacuous-default trap and the mutation checks are written up in the second commit's message; I independently re-ran one mutation (id-keyed dedupe) and confirmed 3 tests go red.

Key decisions

Re-present rather than push updates over a bridge.

The popover page is bridge-less by design (webview-rendering Decision 10) — no message handlers, allowsContentJavaScript = false. Adding a bridge purely to keep a footnote sheet fresh would enlarge the audited surface for a sheet that shows at most a few paragraphs. Re-emitting and reloading is the cheaper, smaller-surface answer.

The dedupe lives on the page, not at the call site.

Putting the key comparison inside present makes the operation idempotent in its inputs, which is what allows the view to fire from a body-driven onChange without any call-site bookkeeping. The alternative — a @State "last presented key" in the view — would duplicate the page's own state and drift from it after a reset.

(inferred — not stated by the author.)
Guard the superseded load task instead of cancelling it.

Cancelling the old consumer risks tearing down the new navigation if the cancellation lands after the swap. The superseded navigation ends on its own once the newer load starts, so a generation check on the consumer is sufficient.

Decision 5's 'no live update' limitation is struck through rather than deleted.

specs/a11y-increase-contrast/decision_log.md keeps the original Negative consequence with a strikethrough plus a Closed by T-1979 note. That preserves the historical record of the trade-off as it stood, which is the point of a decision log.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
adjudicationReload scenario (ticket scenario 3)The fixer round deferred a 'FootnotePresenter closure re-evaluation gap' as the remaining hole on the reload scenario. If the sheet's content closure captured stale FootnoteData and never re-evaluated, scenario 3 would be functionally unfixed.WORKS in production. Verified empirically, not by inspection: I built the exact topology (an @Observable session, a ViewModifier holding two class references whose body(content:) attaches .sheet with a closure reading session.data, hosted in an NSHostingView) and mutated the observable while the sheet was open. The presented view's body re-ran with the new value even when neither the parent body nor the modifier's body(content:) re-ran — SwiftUI evaluates the presentation content closure as its own observation-tracked attribute. Combined with: reloadDocument does not clear coordinator.activeFootnoteId (so the sheet survives), parseAndApplyBlocks assigns the @Observable footnoteData, FootnotePopoverView.data is rebuilt from it, and renderKey therefore recomputes and fires present.
minorFootnotePresenter test coverageNothing pins that FootnotePresenter supplies LIVE session.footnoteData. The mechanism that makes scenario 3 work is entirely implicit in the closure body: `data: session.footnoteData` read inside the .sheet content closure. A future refactor that snapshots it — storing `let data: FootnoteData` on the modifier, or copying into @State in the popover view — would silently re-break the reload scenario with all 19 existing tests still green. The three new live tests exercise FootnotePopoverWebPage directly and never touch the presenter; renderKeyIsInputSensitive constructs the key by hand.Reported, not fixed (report-only review; the fix would be a new test). The cheap version is a source-structural assertion in FootnotePresentationHostTests — which already greps FootnotePresenter.swift for `FootnotePopoverView(` — extended to require `data: session.footnoteData` at the call site, with a comment explaining that a captured snapshot is the regression. Not a blocker: the production behaviour is verified above.
nitFootnotePopoverView.bodyrenderSettings is computed twice on a firing pass — once building renderKey for the comparison, once inside the onChange closure for the present call — and each pass rebuilds RenderSettings.Strings via ~13 String(localized:) lookups plus a TypographyResolver.Left alone. Popover body passes are rare and the cost is microseconds; a `present(key:)` overload to avoid it would have to widen RenderKey to carry FootnoteData, which is worse than the duplicate work. Noted only so a future reader does not mistake it for an oversight.
nitWebFootnotePopoverTests.viewPresentsOnRenderKey`#expect(!code.contains(".onAppear"))` bans any use of onAppear anywhere in FootnotePopoverView.swift forever, including legitimate future uses unrelated to presenting (accessibility focus, analytics). The failure message explains the intent, so a future author is not left guessing.Left alone — the assertion is doing real work (an onAppear-driven present would mask a dropped `initial: true`), and the file is small and single-purpose. Flagged so the trade-off is a known one.
infoIndependent mutation checkThe second commit claims the strengthened tests are mutation-verified. I re-ran one mutation myself: reverting present's guard to `key.footnoteId != renderKey?.footnoteId`.Confirmed — rePresentIsDedupedByRenderKey, liveDefinitionReplacementRefreshesOpenPopover and liveThemeChangeRefreshesOpenPopover all failed (15 tests, 3 failed). Mutation reverted; working tree verified clean.

Per-file diffs

Click to expand.

prism/Views/FootnotePopoverView.swift Modified +24 / -7
diff --git a/prism/Views/FootnotePopoverView.swift b/prism/Views/FootnotePopoverView.swiftindex 1fdb637..73cf1ff 100644--- a/prism/Views/FootnotePopoverView.swift+++ b/prism/Views/FootnotePopoverView.swift@@ -10,7 +10,8 @@ //  header stays a native (localised) label; the WebView shows the rendered content. // //  Isolation (Req 8.4): the shared page loads fresh content per presentation and retains-//  nothing between footnotes — `present` on appear, `reset` on disappear.+//  nothing between footnotes — `present` whenever the render key moves (including on+//  appear), `reset` on disappear. //  import SwiftUI@@ -44,8 +45,8 @@ struct FootnotePopoverView: View {      /// The popover page's complete render input, as one equatable value.     ///-    /// Static and pure so it can be asserted without mounting the view — and so a later-    /// change can key a re-present on it (T-1979) rather than re-deriving the inputs.+    /// Static and pure so it can be asserted without mounting the view — and so the+    /// re-present key (T-1979) observes exactly these inputs rather than re-deriving them.     ///     /// `contrast` and `dynamicTypeSize` carry no default, matching `typographyVariables`     /// and `pushInitialState`: a silently `.standard`/`.large` default is invisible at the@@ -79,14 +80,30 @@ struct FootnotePopoverView: View {         )     } +    /// The popover page's complete render input, including which footnote and which+    /// definition — the value an open popover must stay in step with (T-1979).+    ///+    /// Evaluated in `body`, which is what makes this work at all: reading+    /// `settings.showHTMLComments` and `settings.theme(for:)` here registers them with+    /// Observation, so an `AppSettings` change invalidates the body and re-runs the+    /// comparison below. Reading them only inside the old `onAppear` closure registered+    /// nothing, so nothing ever re-presented.+    private var renderKey: FootnotePopoverWebPage.RenderKey {+        FootnotePopoverWebPage.RenderKey(+            footnoteId: footnoteId, data: data, settings: renderSettings+        )+    }+     var body: some View {         content-            .onAppear {+            // One trigger for every input, fired on appear too (`initial: true`). The page+            // ignores an unchanged key, so this cannot thrash the WebPage on an unrelated+            // body pass. Splitting it back into an appear hook plus an identifier-only+            // trigger is the bug: theme, contrast, typography, comment visibility, and a+            // same-id definition replacement all move the key, none move the identifier.+            .onChange(of: renderKey, initial: true) {                 popoverPage.present(footnoteId: footnoteId, data: data, settings: renderSettings)             }-            .onChange(of: footnoteId) { _, newID in-                popoverPage.present(footnoteId: newID, data: data, settings: renderSettings)-            }             .onDisappear {                 // Retain nothing once dismissed (Req 8.4).                 popoverPage.reset()
prism/ViewModels/FootnotePopoverWebPage.swift Modified +75 / -5
diff --git a/prism/ViewModels/FootnotePopoverWebPage.swift b/prism/ViewModels/FootnotePopoverWebPage.swiftindex 699473c..921161c 100644--- a/prism/ViewModels/FootnotePopoverWebPage.swift+++ b/prism/ViewModels/FootnotePopoverWebPage.swift@@ -12,6 +12,11 @@ //  between footnotes or documents. `present` replaces the served HTML and reloads; //  `reset` clears it back to empty so a dismissed popover holds no prior content. //+//  Freshness (T-1979): the page is bridge-less, so everything it renders — content,+//  palette, contrast, typography, comment visibility — is baked in at emit time and can+//  only change by re-presenting. `present` is therefore keyed on the whole `RenderKey`:+//  it reloads when any of those move and no-ops when none did.+// //  Reuse: footnote content is parsed with the same `MarkdownBlockParser.parseFragment` //  used by the legacy popover and rendered through `BlockHTMLEmitter` — the same emitter //  the document surface uses — so popover rendering matches document rendering. Only the@@ -38,10 +43,49 @@ final class FootnotePopoverWebPage {     /// The identifier of the footnote currently rendered, for the view's header.     private(set) var footnoteId: String? +    /// The render input the currently-served HTML was emitted from, or nil when the page+    /// holds the empty reset content. Compared on every `present` so an unchanged+    /// re-evaluation is a no-op rather than a reload (T-1979).+    private(set) var renderKey: RenderKey?++    /// Bumped by every actual load. Doubles as the presentation generation: a load task+    /// started for an earlier state observes a newer value and stops, so an older refresh+    /// can never report after a newer one began (T-1979). Also drives the document URL's+    /// `rev`, so each present is a distinct navigation rather than a same-URL reload.+    private(set) var renderGeneration: Int = 0+     private static let logger = Logger.prism(category: "FootnotePopover") -    /// A stable per-page document URL (the content varies; the URL does not).-    private static let documentURL = URL(string: "prism-doc://document/footnote?rev=1")!+    /// The document URL for a given generation. The route ignores the query (any session+    /// id / rev resolves to `.document`); varying it keeps consecutive presents from+    /// collapsing into one navigation.+    private static func documentURL(generation: Int) -> URL {+        URL(string: "prism-doc://document/footnote?rev=\(generation)")!+    }++    /// The complete set of inputs the served HTML is a function of.+    ///+    /// The page is bridge-less by design (Decision 10): palette, contrast, typography,+    /// comment visibility and content are all baked into the HTML at emit time, so a live+    /// page does not change when any of them change — only a re-present updates it. That+    /// is why presentation is keyed on this whole value and not on `footnoteId` alone,+    /// which is the T-1979 bug: every input except the identifier could change under an+    /// open popover and leave it showing the state it was born with.+    ///+    /// It holds the resolved `FootnoteDefinition` rather than the whole `FootnoteData`, so+    /// a document reload that leaves this footnote untouched does not reload the popover,+    /// while one that replaces its content under the same identifier does.+    struct RenderKey: Equatable, Sendable {+        let footnoteId: String+        let definition: FootnoteDefinition?+        let settings: RenderSettings++        init(footnoteId: String, data: FootnoteData, settings: RenderSettings) {+            self.footnoteId = footnoteId+            self.definition = data.definition(for: footnoteId)+            self.settings = settings+        }+    }      init() {         let htmlBox = HTMLBox()@@ -66,7 +110,14 @@ final class FootnotePopoverWebPage {      /// Renders `footnoteId`'s content fresh and loads it (Req 7.1). Replaces any prior     /// content first so a re-present never shows the previous footnote.+    ///+    /// Idempotent in its inputs (T-1979): callers re-present whenever any render input+    /// may have moved, and an unchanged call must not reload — that would re-emit the+    /// same bytes and blank the live page mid-read for nothing.     func present(footnoteId: String, data: FootnoteData, settings: RenderSettings) {+        let key = RenderKey(footnoteId: footnoteId, data: data, settings: settings)+        guard key != renderKey else { return }+        renderKey = key         self.footnoteId = footnoteId         htmlBox.set(Self.emitHTML(footnoteId: footnoteId, data: data, settings: settings))         load()@@ -78,6 +129,9 @@ final class FootnotePopoverWebPage {     /// present(). (Pre-existing gap surfaced by WebFootnotePopoverTests.liveResetEmpties.)     func reset() {         footnoteId = nil+        // Unconditional, unlike `present`: clearing is the isolation guarantee, so it is+        // never skipped on the strength of a key comparison.+        renderKey = nil         htmlBox.set(Self.emitHTML(footnoteId: nil, data: .empty, settings: RenderSettings()))         load()     }@@ -105,11 +159,27 @@ final class FootnotePopoverWebPage {     // MARK: - Private      private func load() {-        let url = Self.documentURL-        Task { [page] in+        renderGeneration += 1+        let generation = renderGeneration+        let url = Self.documentURL(generation: generation)+        // The guard is robustness, not ordering. Nothing here orders the navigations+        // themselves — two rapid presents enqueue two tasks — and nothing needs to:+        // content correctness comes from `htmlBox`, which `present` writes synchronously+        // before calling this, so every navigation (superseded or not) is served the+        // newest bytes and there are no older bytes left to serve. What the guard buys is+        // that a task overtaken by a newer load does not act on its events, and does not+        // log a failure that only means "you were superseded" (T-1979).+        //+        // Guarded rather than cancelled: the superseded navigation ends on its own once+        // the newer load starts, and calling `cancel` on the old consumer risks tearing+        // down the new navigation instead if it lands after the swap.+        Task { [weak self, page] in             do {-                for try await _ in page.load(URLRequest(url: url)) {}+                for try await _ in page.load(URLRequest(url: url)) {+                    guard self?.renderGeneration == generation else { return }+                }             } catch {+                guard self?.renderGeneration == generation else { return }                 Self.logger.error("Footnote popover load failed: \(error.localizedDescription)")             }         }
prismTests/WebRendering/WebFootnotePopoverTests.swift Modified +263 / -0
diff --git a/prismTests/WebRendering/WebFootnotePopoverTests.swift b/prismTests/WebRendering/WebFootnotePopoverTests.swiftindex abfbb4d..245078c 100644--- a/prismTests/WebRendering/WebFootnotePopoverTests.swift+++ b/prismTests/WebRendering/WebFootnotePopoverTests.swift@@ -131,6 +131,242 @@ struct WebFootnotePopoverTests {         #expect(try await waitForBodyText(page.page, absent: "First footnote body."))     } +    // MARK: - Live render-state refresh (T-1979)++    /// The view is the only thing that can refresh a bridge-less page, so this pins the+    /// wiring: presenting must be keyed on the WHOLE render input, not on `footnoteId`.+    ///+    /// Keying on `footnoteId` alone is the bug — a theme flip, an Increase Contrast+    /// change, a comment-visibility toggle, a Dynamic Type change, or a document reload+    /// that replaces the definition under the same identifier all leave the identifier+    /// untouched, so the open popover kept the HTML it was born with.+    @Test("The popover presents on its whole render key, not on the footnote id alone")+    func viewPresentsOnRenderKey() throws {+        let source = try String(+            contentsOf: URL(fileURLWithPath: #filePath)+                .deletingLastPathComponent()   // WebRendering+                .deletingLastPathComponent()   // prismTests+                .deletingLastPathComponent()   // repo root+                .appendingPathComponent("prism/Views/FootnotePopoverView.swift"),+            encoding: .utf8+        )+        // Assert on the CODE, not on the comments — which discuss the old `onAppear`+        // trigger by name and would otherwise satisfy (or defeat) every check below.+        let code = source+            .split(separator: "\n", omittingEmptySubsequences: false)+            .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") }+            .joined(separator: "\n")++        // `initial: true` is load-bearing and is the most likely accidental edit: without+        // it the FIRST present never fires, so a sheet opened after a prior `reset()` shows+        // the empty document. Pin the whole trigger, not just its key.+        #expect(+            code.contains("onChange(of: renderKey, initial: true)"),+            """+            FootnotePopoverView must drive `popoverPage.present` from the composite \+            render key, fired on appear too (`initial: true`). Presenting only \+            `onAppear`/`onChange(of: footnoteId)` leaves an open popover showing stale \+            theme, contrast, typography, comment visibility, and definition content; \+            dropping `initial: true` leaves a freshly-opened popover on the empty \+            document until some input happens to move (T-1979).+            """+        )+        #expect(+            !code.contains("onChange(of: footnoteId)"),+            "The footnote id is one component of the render key, not a separate trigger."+        )+        // …and the trigger must actually present. A key-only `onChange` that did+        // something else would satisfy the checks above.+        let trigger = code.range(of: "onChange(of: renderKey, initial: true)").map {+            String(code[$0.upperBound...].prefix(300))+        }+        #expect(+            trigger?.contains("popoverPage.present(") == true,+            "The render-key trigger must call popoverPage.present."+        )+        // The key trigger is the ONLY present trigger: an `onAppear`-driven present would+        // hide a regression that drops `initial: true` above.+        #expect(+            !code.contains(".onAppear"),+            "The render key is the only present trigger; an onAppear hook is the old bug."+        )+    }++    /// The render key must move for every input the emitted HTML depends on — it is the+    /// only thing standing between a changed input and a stale open popover.+    @Test("The render key moves with every input the emitted HTML depends on")+    func renderKeyIsInputSensitive() {+        let data = footnoteData()+        func key(+            id: String = "1",+            data keyData: FootnoteData? = nil,+            settings: RenderSettings = RenderSettings()+        ) -> FootnotePopoverWebPage.RenderKey {+            FootnotePopoverWebPage.RenderKey(+                footnoteId: id, data: keyData ?? data, settings: settings+            )+        }+        let baseline = key()+        #expect(baseline == key())++        // Identifier.+        #expect(baseline != key(id: "2"))+        // Palette: theme key and Increase Contrast (T-1829, one pair).+        #expect(baseline != key(settings: RenderSettings(+            palette: WebPaletteFeed(themeKey: "prism-dark", contrast: .standard)+        )))+        #expect(+            key(settings: RenderSettings(+                palette: WebPaletteFeed(themeKey: "prism-dark", contrast: .standard)+            ))+            != key(settings: RenderSettings(+                palette: WebPaletteFeed(themeKey: "prism-dark", contrast: .increased)+            ))+        )+        // Comment visibility.+        #expect(baseline != key(settings: RenderSettings(showHTMLComments: true)))+        // Typography (T-1978).+        #expect(baseline != key(settings: RenderSettings(typography: ["--prism-scale": "1.500"])))++        // A definition replaced under the SAME identifier — the document-reload case.+        var replaced = data.definitions+        replaced["1"] = FootnoteDefinition(identifier: "1", displayNumber: 1, content: "Rewritten body.")+        let reloaded = FootnoteData(definitions: replaced, referenceOrder: data.referenceOrder)+        #expect(baseline != key(data: reloaded))++        // …but a reload that leaves THIS footnote alone must not move the key, or every+        // document change would reload the open popover for nothing.+        var untouched = data.definitions+        untouched["2"] = FootnoteDefinition(identifier: "2", displayNumber: 2, content: "Different.")+        #expect(baseline == key(data: FootnoteData(+            definitions: untouched, referenceOrder: data.referenceOrder+        )))+    }++    @Test("An unchanged re-present does not reload; a changed input does")+    func rePresentIsDedupedByRenderKey() {+        let page = FootnotePopoverWebPage()+        let data = footnoteData()++        page.present(footnoteId: "1", data: data, settings: RenderSettings())+        let first = page.renderGeneration+        #expect(first > 0)++        // Same inputs: no reload (the live page would otherwise blank and re-render).+        page.present(footnoteId: "1", data: data, settings: RenderSettings())+        #expect(page.renderGeneration == first)++        // Changed palette: reload.+        page.present(footnoteId: "1", data: data, settings: RenderSettings(+            palette: WebPaletteFeed(themeKey: "prism-dark", contrast: .standard)+        ))+        #expect(page.renderGeneration == first + 1)++        // Reset always reloads, then the same footnote presents again from scratch.+        page.reset()+        #expect(page.renderKey == nil)+        #expect(page.renderGeneration == first + 2)+        page.present(footnoteId: "1", data: data, settings: RenderSettings())+        #expect(page.renderGeneration == first + 3)+    }++    // MARK: - Live: an open popover follows its render inputs (T-1979)++    @Test("A live theme change re-renders the open popover in the new palette")+    func liveThemeChangeRefreshesOpenPopover() async throws {+        let page = FootnotePopoverWebPage()+        let data = footnoteData()+        func settings(_ themeKey: String) -> RenderSettings {+            RenderSettings(palette: WebPaletteFeed(themeKey: themeKey, contrast: .standard))+        }++        // DARK FIRST, deliberately: `prism-light` is document.css's `:root` default+        // (`:root, :root[data-prism-theme="prism-light"]`), so opening on light and+        // asserting light passes identically with no palette baked at all. Starting on a+        // non-default palette makes the first assertion prove the bake reached the+        // renderer, and every later assertion differs from the state before it, so a bake+        // that stops applying part-way cannot slip through either.+        page.present(footnoteId: "1", data: data, settings: settings("prism-dark"))+        #expect(try await waitForBodyText(page.page, contains: "First footnote body."))+        // getComputedStyle, not the markup: the assertion is that the page actually+        // RESOLVES the baked palette, so a bake that never reached the renderer fails.+        #expect(try await waitForBackgroundColor(page.page, equals: Self.prismDarkBackground))++        // The same footnote, a new palette — the identifier has not moved, which is+        // exactly why this used to leave the popover on the old theme (T-1979). The page+        // is dark right now, so "renders light" here means it actually MOVED.+        page.present(footnoteId: "1", data: data, settings: settings("prism-light"))+        #expect(try await waitForBackgroundColor(page.page, equals: Self.prismLightBackground))+        // And once more, to a palette that is neither the default nor the previous value:+        // a re-present that only ever works the first time fails here.+        page.present(footnoteId: "1", data: data, settings: settings("refraction"))+        #expect(try await waitForBackgroundColor(page.page, equals: Self.refractionBackground))+        // The content survives the re-renders (this is a refresh, not a reset).+        #expect(try await bodyText(page.page).contains("First footnote body."))+    }++    @Test("A same-identifier definition replacement re-renders the open popover")+    func liveDefinitionReplacementRefreshesOpenPopover() async throws {+        let page = FootnotePopoverWebPage()+        page.present(footnoteId: "1", data: footnoteData(), settings: RenderSettings())+        #expect(try await waitForBodyText(page.page, contains: "First footnote body."))++        // A document reload supplies new content under the same identifier.+        let reloaded = FootnoteData(+            definitions: [+                "1": FootnoteDefinition(+                    identifier: "1", displayNumber: 1, content: "Rewritten after reload."+                ),+            ],+            referenceOrder: ["1"]+        )+        page.present(footnoteId: "1", data: reloaded, settings: RenderSettings())+        #expect(try await waitForBodyText(page.page, contains: "Rewritten after reload."))+        #expect(!(try await bodyText(page.page)).contains("First footnote body."))+    }++    /// What keeps overlapping presents correct is the HTML box, not the generation guard+    /// in `load()`: `present` writes the newly emitted HTML into the box SYNCHRONOUSLY,+    /// so whichever navigation the scheme handler ends up serving — the newest, or one+    /// that was superseded while still in flight — it is handed the newest bytes. There+    /// are no older bytes left to serve.+    ///+    /// So that is what this asserts. The generation guard orders nothing (see the comment+    /// on `FootnotePopoverWebPage.load()`); it only stops a superseded task from acting or+    /// logging, which is robustness, not this property. A regression that stopped writing+    /// the newest HTML up front — capturing it per load and committing it when that load+    /// runs — would let a superseded navigation paint older content, and fails here.+    @Test("A superseded, in-flight load still leaves the page on the newest render state")+    func overlappingPresentsSettleOnNewest() async throws {+        let page = FootnotePopoverWebPage()+        let data = footnoteData()++        page.present(footnoteId: "1", data: data, settings: RenderSettings())+        #expect(try await waitForBodyText(page.page, contains: "First footnote body."))++        // Start a second navigation and let it genuinely BEGIN — `load()`'s task is+        // MainActor-bound, so it cannot run until this suspension — then supersede it+        // mid-flight with a third.+        page.present(footnoteId: "2", data: data, settings: RenderSettings())+        try await Task.sleep(for: .milliseconds(5))+        page.present(+            footnoteId: "3", data: data,+            settings: RenderSettings(palette: WebPaletteFeed(themeKey: "prism-dark", contrast: .standard))+        )+        #expect(page.footnoteId == "3")++        #expect(try await waitForBodyText(page.page, contains: "item two"))+        #expect(try await waitForBackgroundColor(page.page, equals: Self.prismDarkBackground))++        // …and it stays settled: the superseded navigation cannot commit older content,+        // whichever order the two finish in.+        try await Task.sleep(for: .milliseconds(300))+        let text = try await bodyText(page.page)+        #expect(text.contains("item two"))+        #expect(!text.contains("First footnote body."))+        #expect(!text.contains("emphasis"))+    }+     // MARK: - Badge tap routing (Req 7.1)      @Test("A footnote deep link resolves to the identifier the coordinator presents")@@ -175,6 +411,33 @@ struct WebFootnotePopoverTests {         return !(try await bodyText(page)).contains(needle)     } +    /// `document.css`'s `--prism-background` for the two themes these tests flip between,+    /// as the renderer resolves it on `body { background-color: … }`.+    private static let prismLightBackground = "rgb(238, 240, 250)"   // #EEF0FA+    private static let prismDarkBackground = "rgb(11, 16, 32)"       // #0B1020+    private static let refractionBackground = "rgb(237, 235, 246)"   // #EDEBF6++    /// Polls the live page's RESOLVED body background colour until it matches.+    ///+    /// Asserting on computed style rather than on the emitted attribute is the point: it+    /// proves the baked palette reached the renderer, which is the whole failure mode of a+    /// bridge-less page that never re-presented.+    private func waitForBackgroundColor(_ page: WebPage, equals expected: String) async throws -> Bool {+        for _ in 0..<60 {+            if (try? await backgroundColor(page)) == expected { return true }+            try await Task.sleep(for: .milliseconds(50))+        }+        return (try await backgroundColor(page)) == expected+    }++    private func backgroundColor(_ page: WebPage) async throws -> String {+        let result = try await page.callJavaScript(+            "return document.body ? getComputedStyle(document.body).backgroundColor : '';",+            contentWorld: .page+        )+        return (result as? String) ?? ""+    }+     private func bodyText(_ page: WebPage) async throws -> String {         let result = try await page.callJavaScript(             "return document.body ? document.body.innerText : '';",
CHANGELOG.md Modified +2 / -1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 8b3f10d..45fa2fc 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,7 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed -- Footnote popovers now use the same text settings as the document (T-1978). A footnote's content ignored the **Body Font** you chose, the in-app **Text Size** slider, and the system text size (iOS Larger Text / macOS Text Size), always rendering in the system font at the default reading size — so footnote text could be noticeably smaller than the document it belongs to, and at accessibility text sizes it stayed small while everything around it grew. Footnote content now follows the same font, scale, and system text size as document text. A font that is no longer installed still falls back to the system font, and a font name is applied as text only, so it cannot alter the popover's styling. The system **Increase Contrast** setting had been missed in the same way and now reaches popovers too, though the only footnote content that looks different for it today is an HTML comment shown inline, which kept the standard low-contrast grey. A popover already open when you change these settings still shows the previous values until it is reopened; that is tracked separately (T-1979).+- Footnote popovers now use the same text settings as the document (T-1978). A footnote's content ignored the **Body Font** you chose, the in-app **Text Size** slider, and the system text size (iOS Larger Text / macOS Text Size), always rendering in the system font at the default reading size — so footnote text could be noticeably smaller than the document it belongs to, and at accessibility text sizes it stayed small while everything around it grew. Footnote content now follows the same font, scale, and system text size as document text. A font that is no longer installed still falls back to the system font, and a font name is applied as text only, so it cannot alter the popover's styling. The system **Increase Contrast** setting had been missed in the same way and now reaches popovers too, though the only footnote content that looks different for it today is an HTML comment shown inline, which kept the standard low-contrast grey. A popover already open when you change these settings updates in place, as of the fix below (T-1979).+- An open footnote popover now keeps up with the document instead of freezing at the moment it opened (T-1979). Switching to dark mode, turning **Increase Contrast** on, changing the **Body Font**, moving the **Text Size** slider, changing the system text size, or toggling **Show HTML comments** all left a popover that was already on screen showing the settings it opened with — and if the file changed on disk (or a URL document was refreshed) while the popover was open, the "Footnote N" heading could update to the new document while the footnote text below it still showed the old definition. The popover now re-renders whenever anything it displays changes, including replaced footnote content under the same reference, and leaves itself alone when nothing did, so reading is not interrupted by needless redraws. Changes arriving in quick succession settle on the newest one rather than whichever finished last. - Search and reading-position restore no longer lose their place to content the document hides (T-1944). Three faults shared one cause: hidden content — the body of a collapsed section, or the carrier holding a document's YAML frontmatter — measures as a zero-size box sitting exactly at the top of the window, and several parts of the app read that as "visible". Stepping to a search match inside a collapsed section silently did nothing: the section expanded, but the app had already treated the invisible match as on screen and skipped the scroll; the match is now brought to its usual resting place a third of the way down the window as soon as the section opens. Reopening a document after collapsing the section you were reading left the page at the top instead of anywhere near your place; the restore now lands on the collapsed heading that hides the saved block — and a stale saved position pointing at the hidden frontmatter carrier now falls back to the top of the document instead of doing nothing. Hidden matches also no longer count as on-screen when deciding which highlights to draw, and expanding a section lights its highlights up immediately instead of waiting for the next scroll. The zero-size test now lives in one shared check used by every reader of section layout, so a future feature cannot quietly reintroduce the assumption. Matches inside nested collapsed disclosure blocks (`<details>`) are tracked separately (T-1930). - Stepping to a search match now scrolls there in one motion and leaves the match itself in view (T-1918). Two parts of the app both moved the page on the same step — one lining the top of the match's block up with the top of the window, the other placing the match about a third of the way down — so the page could visibly jump twice, and which position stuck depended on timing. When the block was taller than the window, the block-top alignment could even settle with the match below the fold. One owner scrolls now, resting the match about a third of the way down the window every time. Two more gaps closed with it: stepping to a match far outside the part of the document being highlighted found no highlight to scroll to and relied on the other, block-based jump to get anywhere near it; and with a single match found, pressing next or previous after scrolling away to read something else did nothing — it now brings the match back into view. A match inside a collapsed section initially still did not scroll; the hidden-content fix above (T-1944) closed that gap. - Searching for text that lives in a footnote's definition now highlights and scrolls to the right reference badge when the same footnote is referenced more than once (T-1853). Badges were looked up document-wide by footnote number, so stepping to the match belonging to a later reference always marked and scrolled to the document's first badge — the one actually selected never even showed as matched. Each reference's badge is now resolved within its own block, and repeated references inside a single block are told apart by position, so stepping through matches visits each badge in turn. Text that merely looks like a reference — `` `[^1]` `` written in inline code — still occupies its place in the match order but renders no badge, so stepping onto it marks the nearest following badge in the block, or the last one when none follows.
CLAUDE.md Modified +1 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex d41122d..a054e97 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -65,7 +65,7 @@ Implementation history and gotchas (the `<script>` data-island CSP trap, the nat 4. Badge substitution happens inside `InlineHTMLRenderer.render`, NOT by splitting the source in the emitter (T-1716/T-1945). `FootnoteReferenceScanner.scan` walks the block's inline SOURCE once and marks every occurrence that is spelled verbatim (no backslash escape anywhere in the token) and resolvable, rewriting only its identifier characters into a reserved Private Use Area window. The marker is length-preserving (runs come out in block coordinates — no rebasing, `rebased` is gone) and parse-shape-preserving (`[`, `^`, `]` untouched, so cmark builds the same tree). The whole marked source is then parsed ONCE 5. `InlineHTMLRenderer.Walker` turns a marker into a badge only where a badge is legal: TEXT position **and** not nested inside an element that cannot contain an anchor. Text position alone is not sufficient — the badge is itself an `<a>`, so a `Link` ancestor disqualifies it (`<a>` inside `<a>` is invalid HTML), tracked by the walker's `linkDepth` counter. Every other landing place — a code span, a link destination, an image `src`/`alt`, raw inline HTML, or text inside a link — is restored to the literal `[^id]` the author wrote. Classification is never inferred from parsed text, which is the failure class the redesign removes. `BlockHTMLEmitter.footnoteBadge` builds the styled pill-badge anchor (`prism://footnote/{id}`, display number from `FootnoteData`) carrying `data-prism-chrome`: inert, unselectable, covered by no source-map run 6. Badge taps produce `prism://footnote/{id}` links → `linkActivated` over the bridge → `WebDocumentMessageRouter` → `DocumentLayoutCoordinator` popover state (`PrismLinkRoute.footnotePrefix`)-7. `FootnotePopoverView` displays rendered footnote content (paragraphs, lists, blockquotes only) over a shared, non-persistent `FootnotePopoverWebPage` (emitter-rendered fragment). Since the WebKit cutover it is presented as a sheet on every platform via the shared `FootnotePresenter` modifier (`View.footnotePresentation(coordinator:session:)`), which **both** `CompactDocumentLayout` and `RegularDocumentLayout` must apply — the regular layout lacking it is T-1893. The web-rendered badge has no SwiftUI anchor view, so the anchored popover the original footnotes spec called for (Req 3.1/3.6/3.7) no longer applies; `FootnotePopoverView` self-sizes per platform. The popover page is bridge-less, so everything the document surface receives over the bridge must instead be BAKED into the emitted HTML via `RenderSettings`. Baked today: the palette — theme key **and** Increase Contrast state, carried as one `WebPaletteFeed` so they can never be applied out of step (T-1542/T-1829), emitted as `data-prism-theme`/`data-prism-contrast` by `BlockHTMLEmitter.paletteAttributes`; and the typography custom properties (T-1978, emitted as a validated `<html style>` by `BlockHTMLEmitter.rootStyleAttribute`). Not baked: the mermaid theme config and the note/search/section state, none of which a footnote fragment can contain. `FootnotePopoverView.renderSettings(settings:colorScheme:contrast:dynamicTypeSize:)` is the single pure builder of that render input — it reuses `WebDocumentControllerFactory.typographyVariables` rather than resolving fonts again (font-settings Decision 18), and its equatable result is what a future re-present key (T-1979) observes+7. `FootnotePopoverView` displays rendered footnote content (paragraphs, lists, blockquotes only) over a shared, non-persistent `FootnotePopoverWebPage` (emitter-rendered fragment). Since the WebKit cutover it is presented as a sheet on every platform via the shared `FootnotePresenter` modifier (`View.footnotePresentation(coordinator:session:)`), which **both** `CompactDocumentLayout` and `RegularDocumentLayout` must apply — the regular layout lacking it is T-1893. The web-rendered badge has no SwiftUI anchor view, so the anchored popover the original footnotes spec called for (Req 3.1/3.6/3.7) no longer applies; `FootnotePopoverView` self-sizes per platform. The popover page is bridge-less, so everything the document surface receives over the bridge must instead be BAKED into the emitted HTML via `RenderSettings`. Baked today: the palette — theme key **and** Increase Contrast state, carried as one `WebPaletteFeed` so they can never be applied out of step (T-1542/T-1829), emitted as `data-prism-theme`/`data-prism-contrast` by `BlockHTMLEmitter.paletteAttributes`; and the typography custom properties (T-1978, emitted as a validated `<html style>` by `BlockHTMLEmitter.rootStyleAttribute`). Not baked: the mermaid theme config and the note/search/section state, none of which a footnote fragment can contain. `FootnotePopoverView.renderSettings(settings:colorScheme:contrast:dynamicTypeSize:)` is the single pure builder of that render input — it reuses `WebDocumentControllerFactory.typographyVariables` rather than resolving fonts again (font-settings Decision 18), and its equatable result is one half of the re-present key. Because a bake can only be applied by re-emitting, an OPEN popover stays current only by re-presenting: `FootnotePopoverWebPage.RenderKey` (footnote id + the resolved `FootnoteDefinition` + that `RenderSettings`) is the equatable trigger, and `FootnotePopoverView` drives it with one `.onChange(of: renderKey, initial: true)` — evaluated in `body`, which is what registers the `AppSettings` reads with Observation (reading them only inside `onAppear` registered nothing). `present` no-ops on an unchanged key (an identical re-emit would blank the live page mid-read) and `renderGeneration` guards overlapping loads so a superseded one cannot win; it also varies the document URL's `rev`. Keying on `footnoteId` alone was T-1979: the identifier is the one input that does not move when the theme, contrast, typography, comment visibility, or a same-id definition does 8. `FootnoteStripping` removes `[^id]` references from heading text used in ToC entries, anchor IDs, and window titles 9. Search integration: `searchableText(with:)` appends footnote content to host blocks; badge highlighting indicates matches 
specs/a11y-increase-contrast/decision_log.md Modified +1 / -1
diff --git a/specs/a11y-increase-contrast/decision_log.md b/specs/a11y-increase-contrast/decision_log.mdindex 6e27ceb..69d04e9 100644--- a/specs/a11y-increase-contrast/decision_log.md+++ b/specs/a11y-increase-contrast/decision_log.md@@ -193,7 +193,7 @@ The footnote popover bakes the contrast state alongside the theme key. `RenderSe - Q4's "revisit if the popover ever renders badges, comments or search highlights" is answered ahead of the trigger.  **Negative:**-- The popover still does not update live: a sheet already open when contrast (or typography) changes keeps its baked values until reopened. Tracked as T-1979.+- ~~The popover still does not update live: a sheet already open when contrast (or typography) changes keeps its baked values until reopened. Tracked as T-1979.~~ **Closed by T-1979.** An open popover now re-presents whenever its render key moves — footnote id + resolved `FootnoteDefinition` + this `RenderSettings` value — so palette, contrast, typography, and comment visibility all reach a sheet that is already on screen. Baking is still the only delivery mechanism; what changed is that it is re-applied on change instead of only on first present. - `RenderSettings.theme` was renamed, so spec text and agent notes referring to it needed a sweep.  ---

Things to double-check

WebContent termination while a popover is open.

The document surface recovers from a WebContent crash by bumping its process generation and replaying a state snapshot. The popover has no equivalent: if its content process dies, the sheet goes blank and a re-present with an unchanged key is now a no-op. This is not a regression — before this PR an open popover never re-presented at all — and any input change still recovers it. Worth knowing about rather than acting on.

Scroll position inside a long footnote.

A re-present is a full navigation, so a reader scrolled down inside a long footnote returns to the top when the theme, contrast, font, text size or comment visibility changes. Acceptable for a medium-detent sheet, and strictly better than the previous behaviour of not updating at all — but it is the visible cost of the fix.

The reload adjudication rests on SwiftUI's presentation-attribute tracking.

It is verified against the SDK on this machine, not documented by Apple. If a future OS stopped tracking observable reads inside .sheet content closures, scenario 3 would regress silently and no test would catch it. That is the same exposure described in the minor finding above, and the same cheap test would bound it.