prism branch T-1701/bugfix-raw-toggle-top-position commits 3 + merge files 8 touched (2 prod views, 1 model, 1 test, 2 docs) lines +802 / -26 prod code +318 / -23 (mostly doc comments) tests +372, 8 new cases

Pre-push review: T-1701/bugfix-raw-toggle-top-position

PR #348 — the raw → rendered toggle now honours a deliberate scroll to the top, via a handover-receipt token, a report-suppression window around the restore, and a rendered-ness filter on the native percentage→block picker.

At a glance

  • The bug: T-1639's blanket snapshot > 0 guard on raw → rendered dropped a legitimate "user scrolled raw source to the top" signal, so the stale mid-document DOM id got replayed into the remounted WebView.
  • The discriminator: not the percentage but pendingRestorePercentage, reused as a handover receipt — nil means the raw view owns its offset, so a reported 0 is the reader's doing.
  • Two release paths: the mount consumes a non-zero token (RawScrollHandover.onMount); a zero token, which the mount deliberately never consumes, is released by the first non-zero offset the reader produces (releasedByScroll, routed through the new reportRawScrollPercentage).
  • The seed and its defence: consuming the token immediately reports the handed-over percentage, and RawScrollReportGate suppresses offset == 0 probe reports until the restore lands, because LazyVStack row realisation re-fires the geometry probe at offset 0 and would clobber the seed — worst in exactly the large-document case. delayedScrollToID(onSettled:) closes the window on a 600 ms timer as a backstop.
  • Rendered-ness filter: MarkdownBlock.isRenderedInDocumentBody stops percentage: 0 selecting the hidden .metadata frontmatter carrier, which would persist an id scrollToBlock cannot land on. Deliberately excludes only frontmatter — hidden HTML comments keep a non-zero rect, so the JS side counts them as rendered and native must agree.
  • Honest caveat, stated by the author: restoreScrollPosition is a private method on a View, so its two wiring lines are uncovered by construction. Both pure units it calls are exercised directly and are mutation-checked.

Verdict

Ready to push

The fix is correct, narrow, and unusually well pinned: I mutated the three load-bearing mechanisms independently (isRenderedInDocumentBody → always true; the report(percentage) seed removed from consumeOnMount) and the intended tests went red — rawToRenderedAtTopSkipsFrontmatterCarrier, consumingHandoverSeedsSharedPercentage, lazyGrowthProbesDoNotOverwriteSeed, plus the pre-existing activatingAnotherSessionPersistsOutgoingPosition. Both builds are clean, SwiftLint --strict passes, and 59/59 targeted tests pass on the real head.

Two minor issues remain, neither blocking and neither fixed here (report-only run): an unguarded @State write on the raw-scroll hot path, and an onSettled backstop that can fire after the raw view has unmounted and write a stale value into the shared scrollPercentage. Both are cheap to address in a follow-up; see Findings.

Review findings

6 raised · 1 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism can show a document two ways: rendered (the pretty version) and raw source (the markdown text). There is a button to flip between them, and the app tries to keep your reading position across the flip.

One case was broken. If you were reading half-way down, flipped to raw source, scrolled the raw text back to the very top, and flipped back — the app took you back to the middle again. The one thing you had just asked for was the one thing it ignored.

Why it happened

The app tracks your position as a percentage. When you flip back it asks "where were you?", gets 0 (the top) and — because of an older fix — treated 0 as "nobody has told me anything yet" and threw it away. That older fix existed for a real reason: a freshly-opened scroll view also reports 0 before it has had a chance to restore anything, and acting on that would throw away a good saved position.

The idea behind the fix

The number 0 genuinely cannot tell those two cases apart, so the fix uses a second piece of information that was already lying around: the small note the app leaves for the raw view saying "restore to here". While that note is still un-torn-up, the raw view has not taken charge of its own position, so a 0 is untrustworthy. Once the note is gone, a 0 means the top, and the app honours it.

Key concepts

  • Handover token — a one-shot note passed from one view to another; its presence or absence carries meaning beyond its value.
  • Suppression window — ignoring position reports while the app itself is doing the scrolling, so the app does not mistake its own movement for yours.
  • Hidden blocks — a document's YAML frontmatter is parsed as a block but never drawn, so it is a position nothing can scroll to.

Architecture

Position handover between the raw and rendered surfaces goes through DocumentLayoutCoordinator: scrollPercentage is the shared channel, pendingRestorePercentage is a consume-and-clear token set on rendered → raw, and session.scrollPositionID is the exact DOM id the web document replays.

The change gives pendingRestorePercentage a second job. It was already a restore instruction; it is now also a receipt: rawScrollHandoverCompleted == (pendingRestorePercentage == nil). toggleRawSource applies on snapshot > 0 || rawScrollHandoverCompleted instead of snapshot > 0.

Patterns used

  • Pure decision types extracted from view code. RawScrollHandover and RawScrollReportGate are plain Equatable enums with static decision functions. The view keeps only the two lines that call them. This is what makes the rule testable at all — the enclosing restoreScrollPosition is private on a View.
  • Two-step token release. onMount refuses to consume a token of 0 (there is nothing to scroll to, and a raw view sitting at the top proves nothing while the rendered side is still inside its own ~720 ms report-suppression window). releasedByScroll then releases that zero token on the first real offset. Without step two, a raw session entered with a zero handover could never signal "top".
  • Mirroring an existing invariant across the language boundary. RawScrollReportGate is the Swift-side counterpart of programmaticScrollInFlight in prism-scroll.js, down to the 600 ms release timer; isRenderedInDocumentBody is the Swift-side counterpart of bridge.isSectionRendered's rect half.

Trade-offs

Consuming the token opens a window where the receipt says "raw owns its offset" while scrollPercentage is still the mount's 0. Two mechanisms close it: seed the channel at consume time, then suppress zero-offset probe reports until the restore lands. The author's round-2 finding was that the seed alone does not survive its own window, because LazyVStack growth re-fires the geometry probe at offset 0.

A residual over-claim is accepted and documented: if the delayed scrollTo never realises its target, the seeded value is wrong until the onSettled backstop reports the real percentage — the same over-claim the pre-fix code already had, now with a correction path.

Why the receipt, and not a dedicated flag

The discriminating bit is "has the raw view's offset become the reader's?", and pendingRestorePercentage already carries it for free in both directions. The doc comment states the invariant this rests on explicitly — a raw session is always preceded by a rendered → raw toggle in the same session, so nil can never mean "never handed over" — and names the change that would break it (raw mode becoming restorable state: a persisted view mode, or a deep link that opens in raw). I verified the invariant: showRawSource is written in exactly two places, toggleRawSource and resetSessionState, and the latter clears the token in the same breath.

Why consume-on-mount was rejected

The traced argument holds. Inside the ~720 ms after a document opens (600 ms of programmatic-scroll suppression + the 120 ms report debounce in prism-scroll.js) the handover is 0 while a mid-document position may be both persisted and already restored on the rendered side. Consuming a zero token on mount completion would make an accidental double-tap on the toggle write "top" over that position — re-introducing T-1639's symptom to fix T-1701's. Only "has the raw view been scrolled at all this session" separates the two, which is precisely what the two-step release encodes.

The rendered-ness filter's asymmetry

isRenderedInDocumentBody excludes .metadata only, and the doc comment justifies the exclusion of the obvious second candidate: a hidden HTML comment puts display: none on the inner .prism-comment, not the wrapping section, so the section keeps a full-width box and isSectionRendered (rect.width !== 0 || rect.height !== 0) counts it. I confirmed this against BlockHTMLEmitter: the only section-level hidden attribute in the emitter is on .metadata (line 361); the mermaid source <pre hidden> and prism-comment-hidden are both inner. The predicate and the emitter agree today, and the caller contract ("filter for collapsed sections first") is documented on the property because the data-prism-section-hidden half is session state, not document structure.

nearestRenderedIndex searches forward-then-backward, the opposite of the JS nearestRenderedSection, and the comment explains why it cannot matter: the JS excludes content under collapsed headings (whose nearest reachable anchor is the heading above), while native starts from a collapse-filtered list and can only ever exclude .metadata, which the parser emits at index 0 only. The backward loop is unreachable today and exists as a totality fallback. I verified the parser claim — parseWithFootnotes appends .metadata before any other block.

Edge cases I probed

  • Late onSettled. The 700 ms backstop closure survives the raw view's unmount, reads a detached @State box (last value true) and reports into the coordinator while the rendered surface is live. It cannot release a token (releasedByScroll needs a non-nil token and a pending non-zero restore is never released this way), so it cannot corrupt the handover; it can transiently set a stale scrollPercentage, which the rendered view's next visibleBlock report overwrites. Consumers in that gap: a section collapse taken within ~1 s of switching back would compute its anchor from the stale value.
  • Rubber-banding. The gate tests offset <= 0, so negative macOS overscroll offsets are suppressed rather than treated as real motion. Correct.
  • Content reload while in raw mode. onAppear does not re-fire, so no second restore; the gate stays closed and the token unchanged.
  • Frontmatter-only document. nearestRenderedIndex returns nil and no position is written, which is the agreeing outcome — the page reports nothing in that case either.

Important changes — detailed

toggleRawSource: the zero snapshot is no longer blanket-rejected

DocumentLayoutCoordinator.swift

Why it matters. This is the behaviour change. The guard moves from `snapshot > 0` to `snapshot > 0 || rawScrollHandoverCompleted`, which is what lets a deliberate top position through while still rejecting the mount's premature zero.

What to look at. DocumentLayoutCoordinator.swift:428-497 (toggleRawSource, rawScrollHandoverCompleted, reportRawScrollPercentage)

Takeaway. When a value is ambiguous, look for state you already hold that disambiguates it before inventing a new flag — and then write down the invariant that makes the reuse sound, plus the future change that would break it. The doc comment here names both.
Rationale. The percentage alone cannot separate 'the freshly-mounted ScrollView's first offset=0 event' from 'the reader scrolled to the top'. pendingRestorePercentage already tracks exactly whether the raw view has taken ownership of its offset, so it becomes the discriminator at no extra state cost.

RawScrollHandover: the release rule extracted as a pure enum

RawSourceView.swift

Why it matters. The whole fix rests on when the token is cleared, and the clearing happens inside a `private` method on a `View` that no test can drive. Extracting `onMount` / `consumeOnMount` / `releasedByScroll` is what makes the rule pinnable at all.

What to look at. RawSourceView.swift:372-432 (enum RawScrollHandover)

Takeaway. Testability of SwiftUI view logic is mostly a placement problem. Pull the decision into a pure value type, leave two lines of wiring in the view, and be explicit in the report about the wiring being the uncovered part rather than claiming full coverage.
Rationale. Stated in the commit and the doc comment: a rule this load-bearing must be pinned by tests rather than by a comment forbidding a tidy-up. I confirmed the sensitivity — deleting the `report(percentage)` line from `consumeOnMount` turns `consumingHandoverSeedsSharedPercentage` red.

RawScrollReportGate: suppress zero-offset reports while a restore is in flight

RawSourceView.swift

Why it matters. The round-2 finding, and the least obvious part of the fix: the seed does not survive its own window without it, because `lineByLineContent` is a LazyVStack whose row realisation re-fires the geometry probe with offset still 0.

What to look at. RawSourceView.swift:143-160 (geometry action), 434-476 (enum RawScrollReportGate)

Takeaway. A mitigation whose weakness scales with document size is weakest exactly where it is needed. The failure mode here — lazy layout re-firing a geometry callback at a stale offset — generalises to any 'seed then correct' scheme sitting on a lazy container.
Rationale. Deliberately mirrors `programmaticScrollInFlight` in prism-scroll.js, including the 600 ms release timer (`scrollSettleDelay`), so the two surfaces answer 'is this scroll the app's or the reader's?' the same way. Only a zero offset is suppressed, so a landing restore or a real scroll both publish and both close the window.

MarkdownBlock.isRenderedInDocumentBody + nearestRenderedIndex

MarkdownBlock.swift

Why it matters. Without it, `percentage: 0` on any document with YAML frontmatter selects the hidden `.metadata` carrier and persists a DOM id `scrollToBlock` cannot land on — the same defect class T-1851 removed from the JS reporting side.

What to look at. MarkdownBlock.swift:631-658; DocumentLayoutCoordinator.swift:516-556

Takeaway. When two implementations of the same predicate live on opposite sides of a bridge, encode the agreement explicitly and document the parts you deliberately did NOT mirror. Here: the rect half is mirrored, the `data-prism-section-hidden` half is a caller contract because collapse is session state, not document structure.
Rationale. Mirrors `bridge.isSectionRendered`. I verified against BlockHTMLEmitter that `.metadata` is the only block emitted with a section-level `hidden`, and against MarkdownBlockParser that it is only ever emitted at index 0 — both claims the doc comments make.

delayedScrollToID gains an onSettled backstop

DocumentLayoutCoordinator.swift

Why it matters. The terminating edge of the suppression window. Without it, a restore whose target never realises would leave the raw view silent for the rest of the session.

What to look at. DocumentLayoutCoordinator.swift:566-598

Takeaway. Every suppression window needs a release that does not depend on the thing being suppressed ever happening. Both the JS and now the Swift side use a timer for exactly this.
Rationale. Explicit in the doc comment and mirrored from the JS release timer. See Findings for the one consequence not covered: the timer outlives the view.

Key decisions

Reuse <code>pendingRestorePercentage</code> as the handover receipt rather than adding a <code>hasHandedOver</code> flag.

The token already tracks the exact bit needed, and the doc comment records the invariant that makes it safe (showRawSource is written only by toggleRawSource and resetSessionState, and the latter clears the token) together with the change that would silently break it (raw mode becoming restorable state). I verified both halves of that invariant against the codebase.

Reject consume-on-mount for a zero handover.

Consuming a zero token when the raw view finishes mounting would be simpler, and is wrong: inside the ~720 ms after a document opens the handover is 0 while a mid-document position may already be restored on the rendered side with its report still suppressed, so an accidental double-tap on the toggle would write "top" over the reader's place — T-1639's symptom. The two-step release (onMount declines, releasedByScroll releases on the first real offset) is the price of separating the two.

Seed <em>and</em> defend, rather than seed alone.

Round 2's finding. consumeOnMount reports the handed-over percentage the instant it clears the token, and RawScrollReportGate keeps LazyVStack growth events from overwriting that seed before the delayed scrollTo lands. Both are needed; the note in docs/agent-notes/scroll-persistence.md says so explicitly.

Clear the token <em>before</em> reporting the seed.

Order is load-bearing and called out in the doc comment: with the token already nil, releasedByScroll returns false, so the seed cannot look like the reader scrolling. Pinned by consumingHandoverSeedsSharedPercentage.

<code>isRenderedInDocumentBody</code> excludes frontmatter only, not hidden HTML comments.

Only the inner .prism-comment is display: none; the wrapping section keeps a full-width box, so isSectionRendered counts it as rendered and native must agree. Confirmed against BlockHTMLEmitter — the only section-level hidden is on .metadata.

Forward-then-backward search in <code>nearestRenderedIndex</code>, opposite to the JS.

The JS looks backwards because its excluded case is content under a collapsed heading, whose nearest reachable anchor is the heading above. Native never sees that case (its input is collapse-filtered) and can only exclude index 0, so the backward loop is unreachable today and exists as a totality fallback. Documented on the method.

No <code>specs/bugfixes/</code> report; the knowledge went into <code>docs/agent-notes/scroll-persistence.md</code>.

The +110-line addition to the existing scroll-persistence note carries the ambiguity analysis, the rejected alternative, the restore-window reasoning and the coverage caveat, updating a note that already covered T-1639 rather than starting a parallel document. Consistent with the repo's "update existing notes rather than creating duplicates" rule.

(inferred — not stated by the author.)
<code>scrollSettleDelay = 0.6&nbsp;s</code>.

Chosen to match the JS programmaticScrollInFlight release timer rather than derived from the 0.1 s scrollRestoreDelay it follows. The effective backstop is therefore ~0.7 s after the restore is issued.

Review findings

SeverityAreaFindingResolution
minorRawSourceView.swift:152 — scroll hot path`suppressScrollReports = gate.keepsSuppressing` is written unconditionally on every geometry probe, i.e. every scroll frame, almost always writing `false` over `false`. SwiftUI does not equality-dedupe `@State` assignments, so this plausibly invalidates the raw view's body once per scroll frame where nothing did before. The adjacent `keyboardScroll.contentHeight` write in the same closure is explicitly guarded by an `if` for exactly this reason, so the file already sets the precedent.Not fixed (report-only). Suggested follow-up: guard the write, e.g. `if suppressScrollReports { suppressScrollReports = gate.keepsSuppressing }` — the value can only ever transition true→false, so skipping the write while already false is behaviour-preserving and keeps the hot path allocation-free.
minorRawSourceView.swift:358-364 — onSettled outlives the viewThe `onSettled` closure fires ~0.7 s after the restore is issued whether or not the raw view is still mounted. If the reader toggles back to rendered inside that window, the closure reads a detached `@State` box (last value `true`), passes the guard, and reports `viewModel.scrollPercentage` into `coordinator.scrollPercentage` while the rendered surface is live. It cannot corrupt the handover token (`releasedByScroll` needs a non-nil token, and a token owing a non-zero restore is never released this way), but the shared percentage is also read by `SharedCollapsibleSections.toggleSection` — so a section collapse taken in the ~1 s after switching back could compute its viewport anchor from a raw-view value.Not fixed (report-only). Self-correcting in practice: the rendered view's next `visibleBlock` report overwrites it, and the anchor is only used to decide whether to scroll to the collapsed heading. Worth a cheap mount check (or cancelling the work item on disappear) if it is ever seen in the wild.
nitSharedCollapsibleSections.swift:112-118 — duplicated percentage→index math`currentAnchorSourceIndex` computes the same `Int((count-1) * percentage)` → clamp → `visibleBlocks[i].sourceIndex` as `applyScrollPositionID`, and did before this branch. The rendered-ness filter was added to only one of the two, so the duplicate now also diverges in behaviour.Not fixed. Harmless where it is used — the anchor index only feeds an is-it-inside-the-collapsed-range comparison, and picking the frontmatter carrier still reads as 'above the section'. Flagged so the divergence is a known one rather than a surprise if the two are ever unified.
nitDocumentLayoutCoordinator.swift:576-586 — single-caller optional`onSettled` is an optional parameter with a `nil` default, but `delayedScrollToID` has exactly one call site and it always passes a closure, so the `guard let onSettled else { return }` branch is dead.Not fixed. Defensible as API preservation on a static helper; noted only so a future reader does not go looking for the second caller.
nitDocumentLayoutCoordinator.swift:511-515 — doc comment wrapThe `applyScrollPositionID` doc comment breaks mid-phrase ("on any document with YAML / frontmatter selects…"), a leftover from an edit; every neighbouring comment wraps on clause boundaries.Not fixed. Editorial-only, deliberately left so the reviewed head stays byte-identical to the CI-verified head (50ac8db). Roll into the next touch of the file.
infoTest sensitivity — verified by mutationRather than trusting the review rounds' mutation tables, I re-ran two independent mutations against the suite: `isRenderedInDocumentBody` forced to `true`, and the `report(percentage)` seed deleted from `consumeOnMount`.Both were caught — `rawToRenderedAtTopSkipsFrontmatterCarrier`, `consumingHandoverSeedsSharedPercentage`, `lazyGrowthProbesDoNotOverwriteSeed` and the pre-existing `activatingAnotherSessionPersistsOutgoingPosition` went red (4 failures, 15 passes). Both files restored; tree verified clean at 50ac8db.

Per-file diffs

Click to expand.

prism/Views/RawSourceView.swift Modified +165 / -9
diff --git a/prism/Views/RawSourceView.swift b/prism/Views/RawSourceView.swiftindex ace3b60..1545b43 100644--- a/prism/Views/RawSourceView.swift+++ b/prism/Views/RawSourceView.swift@@ -58,6 +58,14 @@ struct RawSourceView: View {     @Environment(AppSettings.self) private var settings     @Environment(\.accessibilityReduceMotion) private var reduceMotion +    /// True between `restoreScrollPosition` seeding the shared percentage and+    /// the programmatic scroll it issues landing. See ``RawScrollReportGate``.+    ///+    /// View-local `@State` on purpose: the window belongs to one mount of the+    /// raw scroll view, so a remount (the next raw session) starts closed with+    /// no reset needed.+    @State private var suppressScrollReports = false+     private var typographyResolver: TypographyResolver {         TypographyResolver(from: settings)     }@@ -139,7 +147,14 @@ struct RawSourceView: View {                     )                 } action: { _, newValue in                     viewModel.updateScroll(offset: newValue.offset, contentHeight: newValue.scrollable)-                    onScrollPercentageChange(viewModel.scrollPercentage)+                    // The view model always tracks the probe; only the report+                    // out to the shared channel is gated (T-1701).+                    let gate = RawScrollReportGate.onProbe(suppressed: suppressScrollReports,+                                                           offset: newValue.offset)+                    suppressScrollReports = gate.keepsSuppressing+                    if gate == .report {+                        onScrollPercentageChange(viewModel.scrollPercentage)+                    }                     keyboardScroll.contentOffset = newValue.offset                     if keyboardScroll.contentHeight != newValue.scrollable {                         keyboardScroll.contentHeight = newValue.scrollable@@ -301,17 +316,158 @@ struct RawSourceView: View {     ///     /// `contentView` is only mounted once `!isLoading && !lines.isEmpty`,     /// so the lines check is belt-and-suspenders against future refactors-    /// that might call this from a different mount point. The binding is-    /// consumed (set to `nil`) *after* all preconditions pass — an-    /// early-return cannot leave a stale snapshot behind.+    /// that might call this from a different mount point.+    ///+    /// The decision itself lives in ``RawScrollHandover/onMount(token:hasLines:)``+    /// because it is load-bearing beyond this method — the coordinator reads a+    /// cleared token as "the raw view owns its scroll offset now" — and a rule+    /// that matters that much must be pinned by tests rather than by a comment+    /// forbidding a tidy-up (T-1701).+    ///+    /// Consuming the token also reports the percentage straight away+    /// (``RawScrollHandover/consumeOnMount(token:hasLines:report:)``). Without+    /// that, the shared `scrollPercentage` stays at the mount's 0 until the+    /// delayed `scrollTo` lands *and* the geometry event after it fires, so a+    /// toggle inside that window would translate the reader's position to the+    /// top.+    ///+    /// Seeding alone is not enough, which is why the restore also opens the+    /// ``RawScrollReportGate`` window: `lineByLineContent` is a `LazyVStack`, so+    /// its content height grows as rows realise and each growth fires the+    /// geometry probe again with `offset` still 0 — overwriting the seed before+    /// the delayed scroll lands, in exactly the large-document case where the+    /// window is widest. Suppressing `offset == 0` reports until the scroll+    /// lands is the raw-side counterpart of `programmaticScrollInFlight` in+    /// `prism-scroll.js`.+    ///+    /// The trade-off is unchanged: if the delayed scroll fails to realise its+    /// target, the seeded value over-claims — the same over-claim the+    /// pre-T-1701 behaviour already had. The `onSettled` release corrects it by+    /// reporting the real percentage once the window closes, mirroring the+    /// single settled report `prism-scroll.js` makes when its own suppression+    /// timer fires.     private func restoreScrollPosition(proxy: ScrollViewProxy) {-        guard !viewModel.lines.isEmpty,-              let percentage = pendingRestorePercentage,-              percentage > 0 else { return }-        pendingRestorePercentage = nil+        guard let percentage = RawScrollHandover.consumeOnMount(+            token: $pendingRestorePercentage,+            hasLines: !viewModel.lines.isEmpty,+            report: onScrollPercentageChange+        ) else { return }+        suppressScrollReports = true         let targetIndex = viewModel.targetLineIndex(for: percentage)         let targetId = viewModel.scrollTargetId(for: targetIndex)-        DocumentLayoutCoordinator.delayedScrollToID(targetId, scroll: keyboardScroll, proxy: proxy)+        DocumentLayoutCoordinator.delayedScrollToID(targetId,+                                                    scroll: keyboardScroll,+                                                    proxy: proxy) {+            guard suppressScrollReports else { return }+            suppressScrollReports = false+            onScrollPercentageChange(viewModel.scrollPercentage)+        }+    }+}++// MARK: - Scroll Handover Rule (T-1701)++/// What the raw source view does with the scroll-handover token+/// (`DocumentLayoutCoordinator.pendingRestorePercentage`).+///+/// The token is not just local bookkeeping: while it is set, the coordinator+/// treats a raw-mode percentage of 0 as "nothing has reported yet" rather than+/// "the user scrolled to the top" (`rawScrollHandoverCompleted`). Both halves of+/// the rule therefore live here, pure and free of view state, so neither can be+/// tidied away without a test going red.+enum RawScrollHandover: Equatable {+    /// Leave the token set: the raw view is not yet at a position of its own.+    case keep++    /// Clear the token and scroll to this percentage.+    case restore(CGFloat)++    /// The action to take when the raw scroll view mounts.+    ///+    /// A token of 0 is deliberately *not* consumed here. There is nothing to+    /// scroll to, and a freshly-mounted raw view sitting at the top proves+    /// nothing about the reader's intent: rendered → raw taken inside the+    /// ~720ms after a document opens (600ms of programmatic-scroll suppression+    /// plus the 120ms report debounce in `prism-scroll.js`) hands over 0 while+    /// the rendered view may already be at a restored mid-document position. An+    /// immediate toggle back must return there, not to the top.+    static func onMount(token: CGFloat?, hasLines: Bool) -> RawScrollHandover {+        guard hasLines, let token, token > 0 else { return .keep }+        return .restore(token)+    }++    /// Performs the mount decision: on ``restore``, clears `token`, seeds the+    /// shared scroll channel through `report`, and returns the percentage to+    /// scroll to. Returns `nil` when the mount declines the token (``keep``),+    /// leaving both untouched.+    ///+    /// The whole sequence lives here rather than inline in+    /// `RawSourceView.restoreScrollPosition` so tests exercise the real thing:+    /// as a `private` method on a `View` the sequence was unreachable, and a+    /// test that re-implemented it could not detect the seed being deleted.+    ///+    /// Order is load-bearing. The token is cleared *before* the report, so the+    /// report cannot release anything on its way out+    /// (``releasedByScroll(token:reported:)`` on a `nil` token is `false`) —+    /// the seed must not look like the reader scrolling.+    static func consumeOnMount(token: Binding<CGFloat?>,+                               hasLines: Bool,+                               report: (CGFloat) -> Void) -> CGFloat? {+        guard case .restore(let percentage) = onMount(token: token.wrappedValue,+                                                      hasLines: hasLines) else { return nil }+        token.wrappedValue = nil+        report(percentage)+        return percentage+    }++    /// Whether a reported offset of `reported` releases the token.+    ///+    /// This is what stops a zero handover from forfeiting the top signal for the+    /// whole raw session: the mount will never consume it, so it is the first+    /// offset the reader actually produces that makes the raw view the owner of+    /// its position — and only then does a later report of 0 mean "the top".+    /// A token the mount still owes a restore to is never released this way.+    static func releasedByScroll(token: CGFloat?, reported: CGFloat) -> Bool {+        guard token != nil, reported > 0 else { return false }+        return onMount(token: token, hasLines: true) == .keep+    }+}++/// Whether a geometry-probe report reaches the shared scroll channel while a+/// restore is in flight (T-1701).+///+/// The raw-side counterpart of `programmaticScrollInFlight` in+/// `prism-scroll.js`: a scroll the app issued must not be reported back as if+/// the reader had produced it, and the intermediate positions on the way there+/// least of all.+///+/// The problem it solves is specific. `restoreScrollPosition` seeds the shared+/// percentage the instant it consumes the handover token, but the scroll it+/// issues lands ~100 ms later, and the raw content is a `LazyVStack` — every+/// row that realises in between grows the content height and re-fires the probe+/// with `offset` still 0, recomputing the percentage as 0 and overwriting the+/// seed. The more rows there are to realise, the wider the window: the mitigation+/// would have been weakest in exactly the large-document case that needs it most.+///+/// Only a zero offset is suppressed. A non-zero one is real information — the+/// restore landing, or the reader scrolling — and both should be published and+/// both end the window, so a failed restore cannot leave the raw view silent for+/// the rest of the session. `delayedScrollToID(onSettled:)` closes the window on+/// a timer as the backstop for the case where neither ever happens.+enum RawScrollReportGate: Equatable {+    /// Drop the report: the restore's seed is still the better value.+    case suppress++    /// Publish the report and close the suppression window.+    case report++    /// Whether the suppression window stays open after this decision.+    var keepsSuppressing: Bool { self == .suppress }++    /// The decision for one geometry probe.+    static func onProbe(suppressed: Bool, offset: CGFloat) -> RawScrollReportGate {+        guard suppressed, offset <= 0 else { return .report }+        return .suppress     } } 
prism/Views/DocumentLayoutCoordinator.swift Modified +121 / -13
diff --git a/prism/Views/DocumentLayoutCoordinator.swift b/prism/Views/DocumentLayoutCoordinator.swiftindex 18d2ca7..9be46fc 100644--- a/prism/Views/DocumentLayoutCoordinator.swift+++ b/prism/Views/DocumentLayoutCoordinator.swift@@ -92,10 +92,13 @@ final class DocumentLayoutCoordinator {     /// event reports `offset = 0`, which would overwrite the value we need     /// for restoration before the receiver gets a chance to read it.     ///-    /// `@ObservationIgnored` because this is consume-and-clear state read-    /// only inside `RawSourceView.restoreScrollPosition(proxy:)` and written-    /// only by `toggleRawSource` / `resetSessionState`. No view body-    /// reactively depends on it (T-1289).+    /// It doubles as the raw view's handover receipt: `nil` means the raw view+    /// owns its scroll offset — see ``rawScrollHandoverCompleted`` (T-1701).+    ///+    /// `@ObservationIgnored` because this is consume-and-clear state read only+    /// inside `RawSourceView.restoreScrollPosition(proxy:)` and written only by+    /// `toggleRawSource`, `reportRawScrollPercentage`, `resetSessionState`, and+    /// that same consume. No view body reactively depends on it (T-1289).     @ObservationIgnored var pendingRestorePercentage: CGFloat?      /// ViewModel for raw source processing.@@ -431,18 +434,69 @@ final class DocumentLayoutCoordinator {             // leave a stale snapshot until the next toggle consumed it.             pendingRestorePercentage = snapshot             session.persistScrollPosition()-        } else if snapshot > 0 {-            // Raw → rendered. Skip when the user hasn't scrolled: an early-            // toggle (before any geometry event has reported a non-zero-            // offset, e.g. mid-cross-session-restore) would overwrite the-            // previously-persisted position with the first block and leave-            // the rendered view scrolled to the top.+        } else if snapshot > 0 || rawScrollHandoverCompleted {+            // Raw → rendered. A non-zero snapshot is unambiguous. A zero+            // snapshot only counts once the handover has completed — see+            // `rawScrollHandoverCompleted` for why the two cases cannot be+            // told apart from the percentage alone (T-1701).             applyScrollPositionID(percentage: snapshot, session: session)         }          showRawSource.toggle()     } +    /// Whether the raw view has taken ownership of the scroll offset for the+    /// current raw session, i.e. whether a reported percentage of 0 means+    /// "the user scrolled to the top" rather than "nothing has reported yet".+    ///+    /// A zero `scrollPercentage` in raw mode has two very different causes and+    /// the number alone cannot separate them (T-1701):+    ///+    /// - The freshly-mounted raw `ScrollView` fires one `onScrollGeometryChange`+    ///   with `offset = 0` before it restores. Acting on that would overwrite a+    ///   perfectly good persisted position with the first block — the reason the+    ///   original `snapshot > 0` guard existed.+    /// - The user deliberately scrolled the raw source back to the top and wants+    ///   the rendered view to follow.+    ///+    /// `pendingRestorePercentage` is the handover receipt that separates them,+    /// and ``RawScrollHandover`` owns the rule for when it is released:+    /// `RawSourceView.restoreScrollPosition` consumes it when it actually+    /// restores, and ``reportRawScrollPercentage(_:)`` releases a zero handover+    /// (which the mount never restores) on the first offset the reader produces.+    /// So a `nil` token means the raw view's offset is the reader's. A non-`nil`+    /// token means it is not yet: the view has not mounted, or it mounted with+    /// nothing to restore and has not been scrolled — which is exactly the+    /// ambiguous mid-restore case — so a zero snapshot is not trusted and the+    /// persisted position wins.+    ///+    /// Invariant this guard relies on: a raw session is always preceded by a+    /// rendered → raw toggle in the same session, so the token is always set on+    /// entry and `nil` can never mean "never handed over". `resetSessionState()`+    /// clears the token *and* forces `showRawSource = false`, and nothing else+    /// writes `showRawSource`. If raw mode ever becomes restorable state (a+    /// persisted view mode, a deep link that opens in raw), this guard silently+    /// stops holding and needs an explicit "handed over" flag instead.+    private var rawScrollHandoverCompleted: Bool {+        pendingRestorePercentage == nil+    }++    /// Records a scroll percentage reported by the raw source view.+    ///+    /// The layouts route `RawSourceView.onScrollPercentageChange` through here+    /// rather than assigning `scrollPercentage` directly, because the report is+    /// also what releases a zero handover token — see+    /// ``RawScrollHandover/releasedByScroll(token:reported:)``. Without it a raw+    /// session entered with a zero handover could never signal "the user is at+    /// the top" however long the reader scrolled (T-1701).+    func reportRawScrollPercentage(_ percentage: CGFloat) {+        scrollPercentage = percentage+        if RawScrollHandover.releasedByScroll(token: pendingRestorePercentage,+                                              reported: percentage) {+            pendingRestorePercentage = nil+        }+    }+     // MARK: - Scroll Position      /// Translates a 0–1 percentage to a `visibleBlocks` index and assigns that@@ -450,7 +504,16 @@ final class DocumentLayoutCoordinator {     /// `BlockDOMID`) to `session.scrollPositionID` — the format the web     /// document's `scrollToBlock` resolves with `getElementById` (T-1639).     /// Returns whether an assignment was made — `false` means there are no-    /// visible blocks yet (document still loading).+    /// visible blocks yet (document still loading), or none of them renders.+    ///+    /// The index is advanced past blocks the page does not lay out+    /// (`isRenderedInDocumentBody`), applying the same rendered-ness filter as+    /// `nearestRenderedSection` in `prism-scroll.js`. Without it, `percentage: 0`+    /// on any document with YAML+    /// frontmatter selects `visibleBlocks[0]` — the hidden `.metadata` carrier —+    /// and persists a position `scrollToBlock` cannot land on (T-1701); the same+    /// "persist a block the reader cannot see" defect T-1851 removed from the+    /// page's reporting side.     @discardableResult     private func applyScrollPositionID(percentage: CGFloat,                                        session: DocumentSession) -> Bool {@@ -458,13 +521,42 @@ final class DocumentLayoutCoordinator {         guard !visibleBlocks.isEmpty else { return false }         let targetIndex = Int(CGFloat(visibleBlocks.count - 1) * percentage)         let clampedIndex = max(0, min(targetIndex, visibleBlocks.count - 1))-        let sourceIndex = visibleBlocks[clampedIndex].sourceIndex+        guard let renderedIndex = Self.nearestRenderedIndex(from: clampedIndex,+                                                            in: visibleBlocks) else { return false }+        let sourceIndex = visibleBlocks[renderedIndex].sourceIndex         let mapped = BlockDOMID.map(blocks: session.parsedBlocks)         guard sourceIndex >= 0, sourceIndex < mapped.count else { return false }         session.scrollPositionID = mapped[sourceIndex].domID         return true     } +    /// The nearest index at or after `index` whose block renders in the document+    /// body, falling back to the nearest one before it. `nil` only when nothing+    /// in the document renders (a frontmatter-only file) — the page reports+    /// nothing in that case either, so writing no position is the agreeing+    /// outcome, not a missing fallback.+    ///+    /// Search direction is deliberately the opposite of `nearestRenderedSection`+    /// (`prism-scroll.js` looks *backwards* first), and it cannot matter here.+    /// The JS goes backwards because its excluded case is content under a+    /// collapsed heading, whose closest still-reachable position is the heading+    /// above it. Native never sees that case — `visibleBlocks` is already+    /// collapse-filtered — so the only kind this predicate excludes is+    /// `.metadata`, which `MarkdownBlockParser` only ever emits at index 0. The+    /// backward loop is therefore unreachable today and exists purely as a total+    /// fallback.+    private static func nearestRenderedIndex(from index: Int,+                                             in blocks: [VisibleBlock]) -> Int? {+        for candidate in index..<blocks.count where blocks[candidate].block.isRenderedInDocumentBody {+            return candidate+        }+        for candidate in stride(from: index - 1, through: 0, by: -1)+        where blocks[candidate].block.isRenderedInDocumentBody {+            return candidate+        }+        return nil+    }+     /// Scrolls a SwiftUI scroll surface to `targetId` after a short layout     /// delay. Used by `RawSourceView` (the only remaining ScrollViewProxy     /// surface); the rendered web document scrolls through@@ -475,12 +567,23 @@ final class DocumentLayoutCoordinator {     /// `.scrollPosition($keyboardScroll.scrollPosition)` — that binding is     /// the authoritative scroll driver in iOS 18+ and `proxy.scrollTo`     /// alone may not take effect alongside it.+    ///+    /// `onSettled` runs once, ``scrollSettleDelay`` after the scroll is issued.+    /// It is the terminating edge of `RawSourceView`'s report-suppression+    /// window (T-1701) and mirrors `prism-scroll.js`'s programmatic-scroll+    /// release timer: suppression must end even when the target never+    /// realises, or the raw view stops reporting for the rest of the session.     static func delayedScrollToID(_ targetId: String,                                   scroll: KeyboardScrollController,-                                  proxy: ScrollViewProxy) {+                                  proxy: ScrollViewProxy,+                                  onSettled: (() -> Void)? = nil) {         DispatchQueue.main.asyncAfter(deadline: .now() + scrollRestoreDelay) {             scroll.scrollPosition.scrollTo(id: targetId, anchor: .top)             proxy.scrollTo(targetId, anchor: .top)+            guard let onSettled else { return }+            DispatchQueue.main.asyncAfter(deadline: .now() + scrollSettleDelay) {+                onSettled()+            }         }     } @@ -489,6 +592,11 @@ final class DocumentLayoutCoordinator {     /// target child; without the delay the call silently no-ops.     static let scrollRestoreDelay: TimeInterval = 0.1 +    /// How long a programmatic `scrollTo` is given to land before anything+    /// gated on it gives up waiting. Mirrors the 600 ms release timer+    /// `prism-scroll.js` uses for `programmaticScrollInFlight` (T-1701).+    static let scrollSettleDelay: TimeInterval = 0.6+     // MARK: - Document Reload      /// Reloads document content from disk.
prism/Models/MarkdownBlock.swift Modified +31 / -0
diff --git a/prism/Models/MarkdownBlock.swift b/prism/Models/MarkdownBlock.swiftindex fd3485e..b6247c3 100644--- a/prism/Models/MarkdownBlock.swift+++ b/prism/Models/MarkdownBlock.swift@@ -628,6 +628,37 @@ nonisolated enum MarkdownBlock: Identifiable, Equatable, Sendable {         }     } +    /// Whether this block lays out a section the reader can actually be+    /// scrolled to in the rendered document body.+    ///+    /// Only frontmatter is excluded. `BlockHTMLEmitter` emits `.metadata` as an+    /// inert `<section … hidden>` identity carrier, so its rect is all zeros and+    /// the page's own `bridge.isSectionRendered` predicate skips it — the filter+    /// T-1851/T-1944 added to the JS reporting scan and to `scrollToBlock`.+    /// Native position pickers need the same filter, or they persist an id the+    /// page cannot land on (T-1701).+    ///+    /// A hidden HTML comment is deliberately NOT excluded: only its inner+    /// `.prism-comment` is `display: none`, so the wrapping section is still a+    /// full-width block box with a non-zero rect — `isSectionRendered`+    /// (`rect.width !== 0 || rect.height !== 0`) counts it as rendered, and the+    /// two sides must agree.+    ///+    /// **Caller contract: filter for collapsed sections first.** This mirrors+    /// only the *rect* half of `bridge.isSectionRendered`. The other half —+    /// `data-prism-section-hidden`, which `applySectionVisibility` sets on+    /// content under a collapsed heading — is not derivable from a block alone,+    /// because collapse is session state, not document structure. Callers must+    /// therefore start from a collapse-filtered list; today's only caller+    /// (`DocumentLayoutCoordinator.applyScrollPositionID`) walks+    /// `session.sections.visibleBlocks`, which `SectionCollapseManager` has+    /// already filtered. Reaching for this with `session.parsedBlocks` silently+    /// loses the collapse half and reopens the disagreement class T-1851 closed.+    var isRenderedInDocumentBody: Bool {+        if case .metadata = self { return false }+        return true+    }+     /// Debug helper to show block type name.     var debugTypeName: String {         switch self {
prism/Views/CompactDocumentLayout.swift Modified +1 / -1
diff --git a/prism/Views/CompactDocumentLayout.swift b/prism/Views/CompactDocumentLayout.swiftindex cd7b8c2..2b95073 100644--- a/prism/Views/CompactDocumentLayout.swift+++ b/prism/Views/CompactDocumentLayout.swift@@ -81,7 +81,7 @@ struct CompactDocumentLayout: View {                     viewModel: coordinator.rawSourceViewModel,                     content: session.content,                     pendingRestorePercentage: $coordinator.pendingRestorePercentage,-                    onScrollPercentageChange: { coordinator.scrollPercentage = $0 },+                    onScrollPercentageChange: { coordinator.reportRawScrollPercentage($0) },                     keyboardScroll: coordinator.rawSourceScroll,                     bodyHasFocus: $bodyHasFocus                 )
prism/Views/RegularDocumentLayout.swift Modified +1 / -1
diff --git a/prism/Views/RegularDocumentLayout.swift b/prism/Views/RegularDocumentLayout.swiftindex a99fae6..8877671 100644--- a/prism/Views/RegularDocumentLayout.swift+++ b/prism/Views/RegularDocumentLayout.swift@@ -295,7 +295,7 @@ struct RegularDocumentLayout: View {                     viewModel: coordinator.rawSourceViewModel,                     content: session.content,                     pendingRestorePercentage: $coordinator.pendingRestorePercentage,-                    onScrollPercentageChange: { coordinator.scrollPercentage = $0 },+                    onScrollPercentageChange: { coordinator.reportRawScrollPercentage($0) },                     keyboardScroll: coordinator.rawSourceScroll,                     bodyHasFocus: $bodyHasFocus                 )
prismTests/WebRendering/WebScrollPositionRetentionTests.swift Tests +372 / -0
diff --git a/prismTests/WebRendering/WebScrollPositionRetentionTests.swift b/prismTests/WebRendering/WebScrollPositionRetentionTests.swiftindex 740e9ec..489fbf6 100644--- a/prismTests/WebRendering/WebScrollPositionRetentionTests.swift+++ b/prismTests/WebRendering/WebScrollPositionRetentionTests.swift@@ -24,6 +24,7 @@ //  import Foundation+import SwiftUI import Testing import WebKit @testable import prism@@ -145,6 +146,377 @@ struct WebScrollPositionRetentionTests {         #expect(emitted.html.contains("id=\"\(session.scrollPositionID)\""))     } +    // MARK: - T-1701: a deliberate raw-source top position must survive the toggle++    // Regression for T-1701. Sequence: read mid-document in rendered mode,+    // toggle to raw, scroll raw back to the TOP, toggle rendered again.+    //+    // `pendingRestorePercentage == nil` is the handover receipt: the raw view+    // consumes it only after it has actually restored (its own `percentage > 0`+    // guard), so a nil token means the raw view owns its scroll offset and a+    // reported 0 is the user's doing, not the freshly-mounted ScrollView's+    // first `offset = 0` geometry event.+    //+    // Expected: the rendered restore id is rewritten to the first visible block.+    // Actual before the fix: `toggleRawSource` skipped the assignment on a zero+    // snapshot, leaving the stale mid-document id for `DocumentScrollContent`+    // to replay — rendered mode jumped back to the old block.+    @Test("raw→rendered at the top rewrites the stale mid-document restore id")+    func rawToRenderedAtTopRewritesStalePosition() async {+        let (session, _) = await makeParsedFileSession()+        let coordinator = DocumentLayoutCoordinator()+        let ids = domIDs(for: session)+        let reportedID = ids[2]++        // Reading mid-document in rendered mode.+        let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+        router.handle(.visibleBlock(domID: reportedID, fraction: 0.4))++        // Rendered → raw hands the percentage over to the raw view.+        coordinator.toggleRawSource(session: session)+        #expect(coordinator.pendingRestorePercentage == 0.4)++        // The raw view mounts and restores, consuming the handover…+        #expect(RawScrollHandover.onMount(+            token: coordinator.pendingRestorePercentage, hasLines: true+        ) == .restore(0.4))+        coordinator.pendingRestorePercentage = nil+        // …then the user scrolls raw source back to the top.+        coordinator.reportRawScrollPercentage(0)++        coordinator.toggleRawSource(session: session)++        #expect(coordinator.showRawSource == false)+        #expect(session.scrollPositionID != reportedID)+        // Guarded lookup: an unguarded subscript here would trap and take the+        // whole test host down (see RawSourceViewModel concurrency-test note).+        let firstVisibleSourceIndex = session.sections.visibleBlocks.first?.sourceIndex+        let expectedTopID = firstVisibleSourceIndex.flatMap { index in+            ids.indices.contains(index) ? ids[index] : nil+        }+        #expect(expectedTopID != nil)+        #expect(session.scrollPositionID == expectedTopID)+    }++    // The other half of the same discriminator: an early toggle taken before the+    // rendered page has reported anything must NOT be read as "user is at the+    // top" — the accidental double-tap on the toggle right after opening a+    // document. The handover was never consumed (the raw view had nothing to+    // restore) and the raw view never moved, so a zero snapshot is ambiguous and+    // the persisted position wins. This is what forbids consuming a zero+    // handover on mount completion: at that moment the rendered view may already+    // be sitting at the restored position with its report still suppressed.+    @Test("raw→rendered keeps the restored position when the raw view never restored")+    func rawToRenderedBeforeAnyReportKeepsRestoredPosition() async {+        let (session, _) = await makeParsedFileSession()+        let coordinator = DocumentLayoutCoordinator()+        // Cross-session restore put a mid-document id in place; the page has not+        // laid out yet, so no visibleBlock report has moved scrollPercentage.+        let restoredID = domIDs(for: session)[2]+        session.scrollPositionID = restoredID++        coordinator.toggleRawSource(session: session)+        // Rendered → raw stores a zero handover the raw view leaves untouched+        // (nothing to restore), and the raw view's mount reports offset 0+        // through the same path the layouts wire.+        #expect(coordinator.pendingRestorePercentage == 0)+        #expect(RawScrollHandover.onMount(+            token: coordinator.pendingRestorePercentage, hasLines: true+        ) == .keep)+        coordinator.reportRawScrollPercentage(0)+        #expect(coordinator.pendingRestorePercentage == 0)++        coordinator.toggleRawSource(session: session)++        #expect(coordinator.showRawSource == false)+        #expect(session.scrollPositionID == restoredID)+    }++    // MARK: - T-1701: the handover-consumption rule itself++    // The whole fix rests on *when* the token is cleared, and the token is read+    // by the coordinator, not just by the raw view — so pin the rule directly+    // rather than by poking `pendingRestorePercentage` and trusting a comment.+    @Test("the mount decision keeps a token it will not restore and consumes one it will")+    func handoverMountDecision() {+        // Nothing handed over.+        #expect(RawScrollHandover.onMount(token: nil, hasLines: true) == .keep)+        // A real position: consume and scroll.+        #expect(RawScrollHandover.onMount(token: 0.4, hasLines: true) == .restore(0.4))+        // Mount race: lines are not loaded, so nothing has restored — the token+        // must survive, or the coordinator reads a not-yet-restored raw view as+        // owning its offset (T-1639's symptom).+        #expect(RawScrollHandover.onMount(token: 0.4, hasLines: false) == .keep)+        // A zero handover has nothing to scroll to. The mount must NOT consume+        // it: the raw view sitting at the top proves nothing about the user's+        // intent while the rendered side has not reported (the ~720ms of+        // programmatic suppression + report debounce after a document opens).+        #expect(RawScrollHandover.onMount(token: 0, hasLines: true) == .keep)+    }++    @Test("only a token the mount will never consume is released by a user scroll")+    func handoverScrollRelease() {+        // The zero handover the mount declined: the first offset the user+        // actually produces is what makes the raw view the owner of its+        // position, and only then does a later report of 0 mean "the top".+        #expect(RawScrollHandover.releasedByScroll(token: 0, reported: 0.6))+        // Still at the top: nothing has proved ownership yet.+        #expect(!RawScrollHandover.releasedByScroll(token: 0, reported: 0))+        // A pending non-zero restore is still owed to the raw view — a geometry+        // event must not steal it before `restoreScrollPosition` runs.+        #expect(!RawScrollHandover.releasedByScroll(token: 0.4, reported: 0.6))+        // Already consumed.+        #expect(!RawScrollHandover.releasedByScroll(token: nil, reported: 0.6))+    }++    // The recurring case the token alone left broken: toggle to raw inside the+    // ~720ms window after a document opens (600ms programmatic-scroll+    // suppression + the 120ms report debounce in prism-scroll.js), so the+    // handover is 0 while a mid-document position is persisted. Read in raw,+    // scroll back to the top, toggle — T-1701's own symptom, narrower.+    //+    // Driven through the production report path the layouts wire+    // (`onScrollPercentageChange: { coordinator.reportRawScrollPercentage($0) }`),+    // not by poking the token.+    @Test("a zero handover still honours a later deliberate scroll to the top")+    func zeroHandoverHonoursLaterTopScroll() async {+        let (session, _) = await makeParsedFileSession()+        let coordinator = DocumentLayoutCoordinator()+        let ids = domIDs(for: session)+        let restoredID = ids[2]+        session.scrollPositionID = restoredID++        // Rendered → raw before the page has reported anything.+        coordinator.toggleRawSource(session: session)+        #expect(coordinator.pendingRestorePercentage == 0)++        // The raw view mounts with nothing to restore…+        #expect(RawScrollHandover.onMount(+            token: coordinator.pendingRestorePercentage,+            hasLines: true+        ) == .keep)+        // …the user reads down the raw source…+        coordinator.reportRawScrollPercentage(0.6)+        // …and then deliberately scrolls it back to the top.+        coordinator.reportRawScrollPercentage(0)++        coordinator.toggleRawSource(session: session)++        #expect(coordinator.showRawSource == false)+        #expect(session.scrollPositionID != restoredID)+        let firstVisibleSourceIndex = session.sections.visibleBlocks.first?.sourceIndex+        let expectedTopID = firstVisibleSourceIndex.flatMap { index in+            ids.indices.contains(index) ? ids[index] : nil+        }+        #expect(expectedTopID != nil)+        #expect(session.scrollPositionID == expectedTopID)+    }++    /// The token binding `CompactDocumentLayout` / `RegularDocumentLayout` hand+    /// to `RawSourceView` (`$coordinator.pendingRestorePercentage`), so the+    /// consume below writes through the same channel production does.+    private func handoverBinding(_ coordinator: DocumentLayoutCoordinator) -> Binding<CGFloat?> {+        Binding(get: { coordinator.pendingRestorePercentage },+                set: { coordinator.pendingRestorePercentage = $0 })+    }++    // Consuming the token opens a window in which the token says "the raw view+    // owns its offset" while `scrollPercentage` is still the mount's 0: the+    // delayed `scrollTo` is 100ms out and the corrected percentage only arrives+    // on the geometry event after it lands. `RawScrollHandover.consumeOnMount`+    // closes it by reporting the handed-over percentage at consume time, which+    // is what this pins — a toggle inside the window lands where the reader was,+    // not at the top.+    //+    // Driven through the production consume itself, not a re-implementation of+    // it: deleting either of its two steps (the clear, the seed) turns this red.+    @Test("consuming a handover seeds the shared percentage before the scroll lands")+    func consumingHandoverSeedsSharedPercentage() async {+        let (session, _) = await makeParsedFileSession()+        let coordinator = DocumentLayoutCoordinator()+        let reportedID = domIDs(for: session)[2]++        let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+        router.handle(.visibleBlock(domID: reportedID, fraction: 0.4))+        coordinator.toggleRawSource(session: session)++        // The freshly-mounted raw ScrollView's first geometry event, at+        // `offset = 0`, before the restore runs. It cannot release the token (a+        // restore is still owed) but it does clear the shared channel — this is+        // the state the seed exists to undo, and without it the assertions below+        // would pass on the value the rendered side left behind.+        coordinator.reportRawScrollPercentage(0)+        #expect(coordinator.scrollPercentage == 0)+        #expect(coordinator.pendingRestorePercentage == 0.4)++        // Exactly what `RawSourceView.restoreScrollPosition` calls.+        let percentage = RawScrollHandover.consumeOnMount(+            token: handoverBinding(coordinator),+            hasLines: true,+            report: { coordinator.reportRawScrollPercentage($0) }+        )++        #expect(percentage == 0.4)+        // The clear: the coordinator must read the raw view as owning its offset.+        #expect(coordinator.pendingRestorePercentage == nil)+        // The seed: the shared channel is current the instant the token goes.+        #expect(coordinator.scrollPercentage == 0.4)++        // A toggle now — before the delayed scroll has produced any geometry+        // event — must not read the mount's stale 0.+        coordinator.toggleRawSource(session: session)+        let ids = domIDs(for: session)+        let firstVisibleSourceIndex = session.sections.visibleBlocks.first?.sourceIndex+        let topID = firstVisibleSourceIndex.flatMap { index in+            ids.indices.contains(index) ? ids[index] : nil+        }+        #expect(session.scrollPositionID != topID)+    }++    // MARK: - T-1701: the seed has to survive the restore window++    // The seed alone does not close the window it opens. `lineByLineContent` is+    // a `LazyVStack`: content height grows as rows realise, and every growth+    // re-fires `onScrollGeometryChange` with `offset` still 0, recomputing the+    // percentage as 0 — so the seed is overwritten before the delayed `scrollTo`+    // lands 100ms later. The more rows, the more growth events, so the hole was+    // widest in exactly the large-document case the window is longest for.+    //+    // `RawScrollReportGate` is the raw-side `programmaticScrollInFlight`. Here it+    // is driven exactly as `RawSourceView`'s geometry action drives it, over the+    // real coordinator: with the gate always reporting (the pre-fix behaviour),+    // `scrollPercentage` ends at 0 and the toggle lands at the top.+    @Test("lazy-growth probes during a restore cannot overwrite the seed")+    func lazyGrowthProbesDoNotOverwriteSeed() async {+        let (session, _) = await makeParsedFileSession()+        let coordinator = DocumentLayoutCoordinator()+        let reportedID = domIDs(for: session)[2]++        let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+        router.handle(.visibleBlock(domID: reportedID, fraction: 0.4))+        coordinator.toggleRawSource(session: session)++        // Mount's first geometry event, then the restore seeds over it.+        coordinator.reportRawScrollPercentage(0)+        _ = RawScrollHandover.consumeOnMount(+            token: handoverBinding(coordinator),+            hasLines: true,+            report: { coordinator.reportRawScrollPercentage($0) }+        )+        // `restoreScrollPosition` opens the window immediately after consuming.+        var suppressed = true++        // Rows realise; the offset has not moved because the scroll is still+        // 100ms out.+        for _ in 0..<8 {+            let gate = RawScrollReportGate.onProbe(suppressed: suppressed, offset: 0)+            suppressed = gate.keepsSuppressing+            if gate == .report { coordinator.reportRawScrollPercentage(0) }+        }++        #expect(coordinator.scrollPercentage == 0.4)+        #expect(suppressed, "the window must stay open across every growth event")++        coordinator.toggleRawSource(session: session)+        let ids = domIDs(for: session)+        let firstVisibleSourceIndex = session.sections.visibleBlocks.first?.sourceIndex+        let topID = firstVisibleSourceIndex.flatMap { index in+            ids.indices.contains(index) ? ids[index] : nil+        }+        #expect(session.scrollPositionID != topID)+    }++    // The gate's whole lifecycle, including both ways the window closes. It has+    // to close on its own: a restore whose target never realises must not leave+    // the raw view unable to report for the rest of the session, which is why+    // only a zero offset is ever suppressed and why+    // `delayedScrollToID(onSettled:)` releases on a timer as the backstop.+    @Test("the report gate suppresses only zero offsets, and only while a restore is in flight")+    func reportGateLifecycle() {+        // No restore in flight: everything reports, and nothing opens a window.+        #expect(RawScrollReportGate.onProbe(suppressed: false, offset: 0) == .report)+        #expect(RawScrollReportGate.onProbe(suppressed: false, offset: 240) == .report)+        #expect(!RawScrollReportGate.onProbe(suppressed: false, offset: 0).keepsSuppressing)++        // In flight: the lazy-growth event that used to clobber the seed is+        // dropped, and the window stays open for the next one.+        #expect(RawScrollReportGate.onProbe(suppressed: true, offset: 0) == .suppress)+        #expect(RawScrollReportGate.onProbe(suppressed: true, offset: 0).keepsSuppressing)++        // A non-zero offset is real information — the restore landing, or the+        // reader scrolling. Published, and it closes the window.+        #expect(RawScrollReportGate.onProbe(suppressed: true, offset: 240) == .report)+        #expect(!RawScrollReportGate.onProbe(suppressed: true, offset: 240).keepsSuppressing)+    }++    // A document whose first block is the YAML-frontmatter carrier. The emitter+    // renders `.metadata` as `<section … hidden>`, so it is `visibleBlocks[0]`+    // natively but has an all-zero rect in the page — exactly the block+    // `bridge.isSectionRendered` filters out of the JS reporting scan+    // (T-1851/T-1944).+    private func makeFrontmatterSession() async -> DocumentSession {+        let markdown = """+        ---+        title: Frontmatter Doc+        author: Prism+        ---++        # Heading 1++        Paragraph A.++        ## Heading 2++        Paragraph B.+        """+        let url = URL(fileURLWithPath: "/tmp/t1701-fm-\(UUID().uuidString).md")+        let session = DocumentSession(url: url, content: markdown)+        await session.parseContent()+        return session+    }++    // The native percentage→block picker must agree with the JS one about which+    // sections exist for the reader. Writing the hidden frontmatter carrier's id+    // persists a position `scrollToBlock` cannot land on — the same "persist a+    // block the reader cannot see" defect T-1851 removed from the reporting+    // side. The page only looks right by accident, because T-1944's JS fallback+    // redirects the unresolvable target to the nearest rendered section.+    @Test("raw→rendered at the top skips the hidden frontmatter carrier")+    func rawToRenderedAtTopSkipsFrontmatterCarrier() async {+        let session = await makeFrontmatterSession()+        let coordinator = DocumentLayoutCoordinator()++        // Precondition: frontmatter really is the first visible block natively.+        let first = session.sections.visibleBlocks.first+        let firstIsMetadata: Bool = {+            guard case .metadata = first?.block else { return false }+            return true+        }()+        #expect(firstIsMetadata, "expected the frontmatter carrier to be visibleBlocks[0]")++        // Rendered → raw, then a deliberate scroll to the top of the raw source.+        coordinator.toggleRawSource(session: session)+        coordinator.pendingRestorePercentage = nil+        coordinator.reportRawScrollPercentage(0)++        coordinator.toggleRawSource(session: session)++        let mapped = BlockDOMID.map(blocks: session.parsedBlocks)+        let metadataID = first.flatMap { block in+            mapped.indices.contains(block.sourceIndex) ? mapped[block.sourceIndex].domID : nil+        }+        #expect(metadataID != nil)+        #expect(session.scrollPositionID != metadataID)++        // Strongest form: the id landed on is a section the page really emits+        // (`rawToRenderedWritesResolvableDOMId`'s check, on a document where the+        // resolvable-but-hidden carrier would otherwise pass it).+        let emitted = BlockHTMLEmitter.emit(+            blocks: session.parsedBlocks, footnotes: .empty, settings: RenderSettings()+        )+        #expect(emitted.html.contains("id=\"\(session.scrollPositionID)\""))+    }+     // MARK: - Symptom 2: reopen lands at top      // Pre-cutover the layouts persisted on onDisappear. On the web path the
docs/agent-notes/scroll-persistence.md Docs +110 / -2
diff --git a/docs/agent-notes/scroll-persistence.md b/docs/agent-notes/scroll-persistence.mdindex 3b7515b..3252911 100644--- a/docs/agent-notes/scroll-persistence.md+++ b/docs/agent-notes/scroll-persistence.md@@ -191,8 +191,116 @@ after mounting reports `offset = 0` and would overwrite the shared   and writes that block's occurrence-qualified DOM id (via `BlockDOMID`) to   `session.scrollPositionID`. The remounted `DocumentScrollContent` creates a   fresh controller, reloads, and replays the id after `layoutSettled`.-  Skipped when the snapshot is 0 (an early toggle mid-restore must not-  overwrite the persisted position with the first block).++### A Zero Snapshot Is Ambiguous — `pendingRestorePercentage` Disambiguates It (T-1701)++A raw-mode `scrollPercentage` of 0 has two causes that the number alone cannot+separate:++1. The freshly-mounted raw `ScrollView` fires one `onScrollGeometryChange` with+   `offset = 0` before it restores. Acting on that overwrites a good persisted+   position with the first block.+2. The user deliberately scrolled the raw source to the top and expects the+   rendered view to follow.++T-1639 shipped the blanket `snapshot > 0` guard, which is right for (1) and drops+(2) — the stale mid-document id then got replayed into the remounted WebView and+jumped back to the old block. The discriminator is the handover receipt, not the+percentage: rendered→raw always sets `pendingRestorePercentage`, and the token is+released only when the raw view's offset has become the reader's. `nil` ⟹ it has;+non-`nil` ⟹ it has not, so a zero snapshot is not trusted and the persisted+position wins. `toggleRawSource` applies on+`snapshot > 0 || rawScrollHandoverCompleted`.++**The release rule lives in one place: `RawScrollHandover` (`RawSourceView.swift`),+pure and pinned by tests** — the whole fix rests on *when* the token is cleared,+which is far too load-bearing for a comment forbidding a tidy-up:++- `onMount(token:hasLines:)` — `RawSourceView.restoreScrollPosition` consumes the+  token when it actually restores. It deliberately does **not** consume a token of+  0: there is nothing to scroll to, and a raw view sitting at the top proves+  nothing while the rendered side has not reported.+- `releasedByScroll(token:reported:)` — `DocumentLayoutCoordinator.reportRawScrollPercentage`+  (the layouts route `onScrollPercentageChange` through it) releases a zero token+  on the first non-zero offset the reader produces. A token the mount still owes a+  restore to is never released this way, so a geometry event cannot steal it.++Why the two-step, rather than consuming a zero handover once the raw view has+mounted: both branches are reachable inside the ~720 ms after a document opens+(600 ms programmatic-scroll suppression + the 120 ms report debounce in+`prism-scroll.js`), during which the handover is 0 while a mid-document position is+persisted.++- Toggle to raw and straight back (the accidental double-tap): the rendered view+  may already be *at* the restored position with its report suppressed, so writing+  "top" would cost the reader their place — T-1639's symptom. Consuming on mount+  completion would do exactly that.+- Toggle to raw, read, scroll back to the top, toggle: the reader asked for the+  top and must get it — T-1701's symptom.++Only "has the raw view been scrolled at all this session" separates them, and the+existing token carries that bit for free.++### The Restore Window: Seed It, Then Defend It++Consuming the token opens a window where the token says "the raw view owns its+offset" while `scrollPercentage` is still the mount's 0: `delayedScrollToID` is+100 ms out, and the corrected percentage arrives only on the geometry event *after*+that scroll lands — so the window is 100 ms + main-queue latency + one layout pass,+and that latency is exactly what a large raw document adds. It straddles a human+double-tap.++Two mechanisms close it, and **both are needed** — the seed alone was the round-2+review finding:++1. **Seed** — `RawScrollHandover.consumeOnMount` reports the handed-over+   percentage immediately after clearing the token, so the shared channel is+   current the instant the token goes.+2. **Defend** — `RawScrollReportGate` suppresses probe-driven reports of+   `offset == 0` until the restore lands. Without it the seed does not survive its+   own window: `lineByLineContent` is a `LazyVStack`, so content height grows as+   rows realise, each growth re-fires `onScrollGeometryChange` with `offset` still+   0, and `viewModel.updateScroll` recomputes the percentage as 0 — overwriting+   the seed *before* the delayed `scrollTo` lands. The more rows to realise, the+   more growth events, so the mitigation was weakest in exactly the+   large-document case the window is longest for.++The gate is the raw-side counterpart of `programmaticScrollInFlight` in+`prism-scroll.js`, and closes the same way: only a **zero** offset is ever+suppressed, so the restore landing (or the reader scrolling) both publishes and+ends the window, and `delayedScrollToID(onSettled:)` releases on a timer+(`scrollSettleDelay`, 600 ms, the same figure the JS uses) as the backstop —+reporting the settled percentage once, exactly as the JS release does. A restore+whose target never realises therefore corrects the seed rather than silencing the+raw view for the rest of the session.++The view model still tracks *every* probe; only the report out to the shared+channel is gated, so `keyboardScroll` and `viewModel.scrollPercentage` stay+truthful throughout.++**Test-coverage caveat**: `restoreScrollPosition` is a `private` method on a+`View`, so no test drives it or the geometry action end to end. Both pure units+it calls (`RawScrollHandover.consumeOnMount`, `RawScrollReportGate.onProbe`) are+exercised directly and mutation-checked; what is *not* pinned is the two lines of+wiring in the view that call them. Keep behaviour out of those lines.++### Native Position Pickers Need the Rendered-ness Filter Too++`applyScrollPositionID` advances past blocks the page does not lay out+(`MarkdownBlock.isRenderedInDocumentBody`), applying the same rendered-ness filter+as `nearestRenderedSection` in `prism-scroll.js` (search direction differs and+cannot matter — see the doc comment on `nearestRenderedIndex`). `isRenderedInDocumentBody`+mirrors only the *rect* half of `bridge.isSectionRendered`; the+`data-prism-section-hidden` half is the caller's job, which is why the caller+starts from the collapse-filtered `session.sections.visibleBlocks`. Without the+filter `percentage: 0` selects `visibleBlocks[0]`, which on+any document with YAML frontmatter is the hidden `.metadata` carrier — an id+`scrollToBlock` cannot land on, persisted to `ScrollPositionStore` at the next+close. The page only looked right by accident, via T-1944's JS-side redirect. Note+the asymmetry with hidden HTML comments: those are **not** skipped, because only+the inner `.prism-comment` is `display: none` — the wrapping section keeps a+full-width box, so `bridge.isSectionRendered` (`rect.width !== 0 || rect.height !== 0`)+counts it as rendered and native must agree.  ## Raw-View Scroll Restoration Drives Both Scroll APIs With a Delay 
CHANGELOG.md Docs +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 4c65322..ffd8fcc 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Scrolling the raw source back to the top now takes the rendered view with it (T-1701). Reading part-way down a document, switching to raw source, scrolling that back to the start and switching to the rendered view again returned you to where you had been reading rather than to the top: the app kept the earlier position and replayed it, so the one thing you had just asked for was the one thing it ignored. Where you leave the raw source is now where the rendered view returns to, the top included, and that holds even when you switched over so soon after opening the document that the app had not yet worked out where you were reading. Switching straight back without having moved the raw source at all still returns you to where you were, so a document reopened at your saved place — or an accidental double-tap on the toggle — does not cost you that place. In a file that opens with a block of metadata, returning to the top now records the document's first line rather than that hidden block, so the position is one the app can still return to after you close and reopen the file. - **Page Up** and **Page Down** in the View menu are now offered only when the document has somewhere to scroll (T-1932). On a document shorter than the window — a one-line file, a short note, or a longer one with every section collapsed — both commands were listed as available and did nothing when chosen, because nothing ever told the app whether the rendered document actually overflowed the window: it simply assumed it did, before the document had even been drawn. The page now measures itself and reports back, so the two commands are dimmed on a document that fits and available on one that does not. The answer keeps up as the document changes shape: collapsing or expanding a section, opening or closing a disclosure block, switching a wide table between its layouts, changing the reading font or text size, showing or hiding your notes, and resizing the window all re-check it. A document that keeps shifting while it draws — one full of images or diagrams, each settling at its own pace — reports what it knows within half a second rather than waiting for the last of them, so the commands are not left dimmed on a long document while it finishes loading. **Scroll to Top** and **Scroll to Bottom** are unchanged — they stay available on any open document, as they always have, and simply do nothing on one that already fits. - Notes in the document can now be reached with a keyboard, and VoiceOver announces them properly (T-1725). Since the WebKit rendering cutover the note dot beside a block, and the note bubbles shown under one, looked like buttons but were not: Tab walked straight past them, so there was no way to open a note without a pointer, and Enter or Space did nothing even if you got to one. VoiceOver could reach the dot only to announce an unnamed "button", because the dot is drawn rather than written and had no name of its own. The dot and each bubble are now real buttons — focusable, in reading order, and opened by Enter or Space just as by a tap — and the dot announces how many notes it stands for ("Show 3 notes"), while a bubble reads out the note's author, text, and time followed by what activating it does. The "add a document note" control at the top of the document announced itself as a button while answering only Enter; it now presents as the link it is, so what is announced and what the keyboard does agree, and the banner's collapse control now states which notes it hides. Adding, editing, resolving, or deleting a note anywhere in the document used to throw keyboard focus back to the start of the page, because every note control is redrawn each time; focus now stays on the control you were using, and where adding a note replaces a block's **+** button with its note dot, focus moves onto the dot instead of being dropped — as does the reverse, where deleting the last note on a block takes the dot away and brings the **+** back. Focus is only ever moved while you are actually in the document, so saving a note in a sheet no longer risks pulling you out of the text field you are typing in. Two things the announcements turned out to be describing wrongly are fixed with them. The number a dot announces is now the number of notes opening it shows you: a dot on a list said "Show 2 notes" when both notes belonged to items within the list, and then opened an empty panel, because opening a whole block's notes cannot show a note attached to one of its items. Notes attached to a list item or to a table's header row therefore no longer put a dot on the whole block: a list item's note now marks the item itself, and each dot counts only what opening it shows you, while a header-row note is still shown as a bubble under the table and in the notes panel until it gets a dot of its own. And tapping a note bubble now opens that note's own notes rather than the block's, so a note on a list item opens the item's; the hidden text on a bubble says "Show notes" instead of "Edit note", which was never what activation did and is not offered at all on an imported note. Confirming the announcements with VoiceOver and Voice Control on a device is still worth doing by hand. - A note on a list item now shows against that item (T-1745). Adding a note to the second item of a list put the note itself below the whole list, and turned the first item's **+** button into a filled dot — the mark that says "this item has a note" — so the list claimed the note belonged to an item it did not. The note was always attached correctly: copying or exporting notes named the right item, and reopening the document kept it there. Only the display was wrong. A list item's note now appears directly beneath the item it belongs to, the dot appears beside that item, and every other item keeps its **+**. Tapping the dot opens that item's notes rather than the list's, as does tapping the note. Items of a nested list behave the same way, at their own level: adding a note from a nested item's **+** used to file it against that item's parent, which — now that a note is shown against the item it names — would have put it visibly on the wrong line; it now stays on the nested item. A note attached to the list as a whole — one made by selecting text rather than by using an item's **+** — still shows at the list, and no longer takes the first item's **+** away: it sits in its own column beside the items, so every item remains available to note. Table rows are unaffected: their dot already appeared on the right row, and their notes continue to gather below the table, since a note placed inside a cell would distort the table. One place is not covered, and behaves as it did before: for a list inside a collapsible `<details>` section, an item's **+** adds the note to the section rather than to the item, because the app cannot yet tell those items apart — tracked separately (T-2032).

Things to double-check

The uncovered wiring, stated by the author.

restoreScrollPosition is private on a View, so the two lines that call consumeOnMount and set suppressScrollReports are not driven by any test, and neither is the geometry action that calls RawScrollReportGate.onProbe. Both pure units are covered and mutation-checked. The caveat is written into docs/agent-notes/scroll-persistence.md along with the mitigation ("keep behaviour out of those lines") — the right disclosure, but it does mean the end-to-end raw-mount path is verified by hand only.

Worth one manual pass on device.

The timing constants (0.1 s restore delay, 0.6 s settle) and the LazyVStack realisation behaviour are the parts no unit test observes. The two sequences to try on a large document: (a) read mid-document → raw → scroll to top → rendered (must land at the top); (b) open a document with a saved mid-document position and immediately double-tap the toggle (must stay where you were, not jump to the top).

The invariant has a named expiry.

The receipt reading only holds while raw mode is not restorable state. If a persisted view mode or a deep link that opens in raw is ever added, rawScrollHandoverCompleted silently starts reading "never handed over" as "handed over". The doc comment says so; nothing enforces it. A test asserting showRawSource == false after resetSessionState would at least fail loudly.

CI does not build this.

The three green workflows on 50ac8db are File Checks, Lint/SwiftLint/Stylelint and the per-locale sweep — none runs xcodebuild. The build and test evidence in this review is local: make build-ios, make build-macos, make lint and 59/59 targeted tests. Checks were confirmed to have executed against the current head sha, not a stale one.