prism branch T-1932/bugfix-page-commands-short-documents commits 2 files 9 touched lines +724 / -26 targeted tests 152 passed / 0 failed CI 4/4 green on head

Pre-push review: T-1932 — real scrollability reporting for the page commands

PR #349 replaces the canScroll = true assumption attachWebBridge made at attach time with a measured, generation-tagged scrollabilityChanged report from the rendered page. Reviewed against origin/main @ bfaf8e5.

At a glance

  • Root cause is addressed at the right layer. The web path has no SwiftUI scroll geometry at all, so canScroll could only ever be a guess natively. It is now measured where the measurement exists — in the page — and crosses the bridge as one boolean.
  • The two-flag contract is preserved. attachWebBridge keeps hasContent = true and drops only canScroll, so Scroll to Top / Bottom stay enabled as documented silent no-ops (keyboard-scrolling Decision 14).
  • The starvation bound is measured, not guessed. A pure trailing debounce deferred the first report 2.7s under a 2.4s trigger burst; the 500ms max-wait brings it to 0.52s. Since canScroll now starts false, that window is the one where the commands would be wrongly disabled — worse than the pre-fix bug, so bounding it was the right call.
  • Malformed payloads are dropped, not defaulted. Defaulting either way would fabricate a geometry report native cannot otherwise obtain — the correct policy for this message.
  • Watch item, not a defect: the JS dedupe is one-directional and only valid while every native canScroll reset pairs with a fresh page. I verified that holds today; sibling PR #348 (raw-toggle receipt token) is the one that could break it.

Verdict

Ready to push

The fix is correct, narrowly scoped, and unusually well covered. Every seam the original T-1719 wiring failure could recur at — controller contract, bridge allowlist, message router, production assembly, live page geometry — has a test, and each of the three page-side mechanisms (ResizeObserver, bounded debounce, dedupe) is mutation-checked so removing it fails exactly one test and no others. Verified locally: 152 targeted tests pass across five suites, SwiftLint reports 0 violations, the iOS build succeeds, and all four CI checks genuinely executed and passed on the head SHA. No blocking or major findings. The items below are nits, invariants worth watching against sibling PRs, and one pre-existing warning unrelated to this change.

Review findings

6 raised · 0 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism's View menu has Page Up and Page Down commands. They should only be available when the document is actually longer than the window — there is nothing to page through on a one-line note. Before this change they were always available and simply did nothing when you picked them on a short document.

Why It Matters

The reason is a gap between two halves of the app. Prism draws documents using a web view (the same engine as Safari), but the code that decides which menu items to enable lives on the native side. The native side used to get its answer from SwiftUI's own scrolling machinery — but on the web path there is no such machinery, so it received nothing. Rather than admit it did not know, the code assumed "yes, it scrolls", before the document had even been drawn.

Key Concepts

  • Only the page can measure itself. The fix has the web page compare how tall its content is against how tall the window is, and send that single yes/no answer back to the native side.
  • Documents change shape. Collapsing a section, changing the reading font, showing notes, or resizing the window can flip the answer, so the page re-checks on all of those.
  • Do not spam. Rapid changes are batched together (a "debounce"), and only a genuine change of answer is sent.
  • Do not stall either. A document full of images keeps changing shape while it loads, which could delay the answer indefinitely — so there is a half-second deadline after which it reports what it knows.

Architecture

KeyboardScrollController exposes two observable flags that drive menu enablement: hasContent (gates Scroll to Top / Bottom) and canScroll (gates Page Up / Down and the arrow keys). On the SwiftUI path both are derived from onScrollGeometryChange. On the WebKit path (T-1542 cutover) that callback never fires, so T-1719's attachWebBridge asserted canScroll = true to keep the menu working — the shortcut this ticket removes.

The replacement runs the boolean over the existing native↔JS bridge as a new inbound message:

  • Page side (prism-scroll.js): documentElement.scrollHeight - clientHeight > 1, posted as scrollabilityChanged { scrollable }.
  • Contract (WebBridgeContract, BridgeMessageRouter): a new InboundMessageType case, validated against the enumerated allowlist and an exact generation match, with a missing or ill-typed flag dropped rather than defaulted.
  • Routing (WebDocumentMessageRouter): folded into coordinator.renderedScroll.applyWebScrollability(_:) — the same controller DocumentReaderView reads as active when raw source is off.

Patterns

Triggers are chosen to be generic rather than enumerated: a ResizeObserver on documentElement and body catches every layout-affecting change at once — section collapse/expand, <details>, table display modes, typography and Dynamic Type reflow, note-bubble injection, image and diagram settling. The explicit window resize listener and the onSectionVisibilityChanged hook are belt and braces (a purely vertical window resize need not change either observed box), and a 1ms load timer covers a document whose layout never changes again.

Trade-offs

The debounce is a trailing debounce with a maximum wait, not the leading-guard throttle used by the T-1878 selection-rect refresh. The reasoning is explicit in the code: a throttle fires once per fixed window for the whole storm, and each fire here costs a synchronous layout read for a boolean that almost never changes — so coalescing as long as possible, with a deadline, is the better shape for this signal. Correspondingly, the dedupe posts only on transitions.

One deliberate asymmetry: the dedupe records what the page posted, not what native holds. That is safe only while every native reset of canScroll is paired with a page reload, which the code documents in both the JS and the agent note.

Deep dive

The failure class here is a wiring failure, not a component failure — the same class as T-1719 and T-1680 — so the test file deliberately spans five seams: the controller contract, the audited bridge allowlist (accept / malformed / stale-generation), the message router, the production assembly (WebDocumentStateSynchronizer.makeAssembly, the exact entry point DocumentScrollContent mounts), and the live page measuring real geometry under the bundled stylesheet.

Three page-side mechanisms carry the design, and each is mutation-checked to fail exactly one test:

  • ResizeObserverresizeObserverCatchesContentHeightChange grows the document from native with a plain appended element, which fires no section-visibility hook, no window resize, and no load timer. Without it every other test in the file still passes, which is precisely why it needed its own.
  • Bounded debouncereportArrivesWithinMaxWaitDuringTriggerBurst. The starvation is real: a media-heavy document fires the observer once per image/diagram that settles, and a run spaced under 120ms re-arms a pure trailing timer indefinitely. Measured 2.7s unbounded vs 0.52s bounded. Because canScroll now starts false, that window is the inverse of T-1932 rather than a mere latency cost.
  • Transition dedupereportIsDeduped, which guards against a vacuous pass twice over: it asserts the collapse actually shrank the content and that the shrunken document still overflows, so "nothing re-posted" cannot be true for the wrong reason.

Harness edge cases the tests encode

Two traps are now documented in docs/agent-notes/keyboard-scrolling.md and worth generalising. First, WebKit throttles page timers in a non-visible page: a 40ms setInterval in the offscreen harness delivers roughly six ticks then about one per second, so a page-side interval cannot produce a burst fast enough to starve a 120ms debounce — the burst has to be driven from native, one callJavaScript per tick. Second, on a zero-height viewport every non-empty document measures as scrollable, so the tall-document test asserts documentElement.clientHeight > 0 before asserting the verdict; without that anchor it would stop distinguishing itself from the short-document test.

Invariants and lifecycle

applyWebScrollability no-ops without an attached web backend, which closes two holes at once: a late report cannot re-enable what detachWebBridge just disabled on a raw-source toggle or document close, and it can never touch rawSourceScroll, whose geometry feed is genuine.

Attach strictly precedes load in production: the assembly task sets webController only after attachWebBridge, and the load task is keyed on hasController, so the first report can never arrive at a detached controller. The one-directional dedupe rests on every native reset pairing with a fresh page — verified: both layouts select the rendered body through an if/else, so the raw-source toggle destroys DocumentScrollContent's @State and rebuilds the assembly, and both the parse-revision reload and WebContent-termination recovery re-evaluate the script. Across a reload native holds the previous verdict briefly by design — stale rather than flapping.

Testability split

handleKeyPress now forwards to handleKey(_:modifiers:reduceMotion:). The justification is sound and is stated in the code: KeyPress has no public initialiser, so the SwiftUI entry point cannot be driven from a test at all, and a test that "covers key handling" by calling pageDown() directly bypasses the canScroll gates — which is the entire subject of the ticket. The entry point stays a one-line forwarder, so there remains one routing table rather than two.

Important changes — detailed

KeyboardScrollController: attach no longer asserts scrollability

prism/Services/KeyboardScrollController.swift

Why it matters. This single line is the bug. Setting canScroll = true at attach time — before the page had loaded — left Page Up/Down enabled on every document, silently doing nothing on short ones. The fix flips it to false and adds applyWebScrollability as the only way it becomes true on the web path.

What to look at. prism/Services/KeyboardScrollController.swift:106-133 (attachWebBridge, applyWebScrollability)

Takeaway. When a backend genuinely cannot answer a question, do not have it guess an answer that happens to keep the UI lively. Split the flags so the part you do know (hasContent) stays authoritative and the part you do not (canScroll) waits for the layer that can measure it.
Rationale. The two-flag split from keyboard-scrolling Decision 14 already existed for exactly this distinction; T-1719 collapsed it on the web path to keep the menu working before a reporting channel existed. This restores it.

prism-scroll.js: measurement, generic triggers, bounded debounce

prism/Resources/WebRenderer/prism-scroll.js

Why it matters. The whole measurement design lives here: what is measured (documentElement scrollHeight vs clientHeight with a 1px sub-pixel tolerance), what re-triggers it (ResizeObserver on root+body, window resize, section-visibility hook, load timer), and how it is rate-limited (120ms trailing debounce bounded at 500ms, transition-only dedupe).

What to look at. prism/Resources/WebRenderer/prism-scroll.js:162-247, 333-345

Takeaway. Prefer a generic trigger over an enumerated one. A ResizeObserver on the root boxes catches collapse, <details>, table modes, typography reflow, note injection and media settling in one mechanism — an enumerated list would need extending every time a new layout-affecting feature lands.
Rationale. Explicitly compared in-code against the leading-guard-with-trailing-fire throttle used by the T-1878 selection-rect refresh, and rejected for this signal: each fire costs a synchronous layout read for a boolean that almost never changes, so maximal coalescing with a deadline is the better trade.

The debounce's 500ms maximum wait

prism/Resources/WebRenderer/prism-scroll.js

Why it matters. The most consequential part of the review round. A pure trailing debounce re-arms on every trigger with no deadline, and a media-heavy document fires the observer once per image or diagram that settles. Because canScroll now starts false, that starvation window is one where the commands are wrongly DISABLED on a long document — strictly worse than the bug being fixed, not merely late.

What to look at. prism/Resources/WebRenderer/prism-scroll.js:225-236 (reportScrollability), tested at WebScrollabilityReportingTests.swift:388-440

Takeaway. When you invert a default from optimistic to pessimistic, re-audit every path that delays the real answer — latency that was previously invisible becomes a visible wrong state.
Rationale. Measured against the same 2.4s trigger burst: 2.7s unbounded, 0.52s bounded. The number is in both the code comment and the agent note, so the bound is justified by evidence rather than taste.

Bridge contract: allowlist case with drop-not-default decoding

prism/ViewModels/BridgeMessageRouter.swift

Why it matters. Every inbound bridge message is validated against an enumerated allowlist plus an exact generation match. The new case follows that, and pointedly refuses to default a missing or ill-typed flag — defaulting either direction would fabricate a geometry report native has no other way to obtain.

What to look at. prism/ViewModels/BridgeMessageRouter.swift:215-220; WebBridgeContract.swift:95-171

Takeaway. For a message that is the sole source of truth for some state, a malformed payload must be dropped rather than coerced. A default here is indistinguishable from a real measurement at the consumer.
Rationale. Stated in the code comment; consistent with how scrollDirectionChanged and perfSample already handle missing fields in the same switch.

handleKeyPress split into a testable handleKey routing table

prism/Services/KeyboardScrollController.swift

Why it matters. KeyPress has no public initialiser, so the SwiftUI entry point cannot be invoked from a test. Without the split, the only way to 'cover key handling' is to call pageDown() directly — which bypasses the canScroll gates entirely, i.e. covers everything except the thing this ticket changed.

What to look at. prism/Services/KeyboardScrollController.swift:233-297

Takeaway. When a framework type is untestable by construction, extract the logic behind it into a function taking the fields you actually read, and keep the framework entry point a one-line forwarder — one routing table, two callers.
Rationale. Introduced in the review-fixes commit after the first round noted the original test asserted nothing about key routing.

Test coverage spanning all five wiring seams

prismTests/WebRendering/WebScrollabilityReportingTests.swift

Why it matters. T-1719's failure was that every component was individually correct while the production assembly never connected them. productionAssemblyRoutesScrollability mounts the same makeAssembly entry point DocumentScrollContent uses and feeds a raw message body in at the controller, exactly as the live WKScriptMessageHandler does.

What to look at. prismTests/WebRendering/WebScrollabilityReportingTests.swift:200-260 and the live-geometry section from 262 onward

Takeaway. Against a wiring-failure class, a production-assembly test is worth more than any number of component tests. Test the entry point the app actually mounts, not a hand-built equivalent.
Rationale. The file header states the reasoning and names the red-phase test (attachDoesNotAssumeScrollable), so the TDD sequencing is auditable after the fact.

Key decisions

canScroll starts false on attach; hasContent stays true.

Preserves keyboard-scrolling Decision 14's two-flag contract on the web path. Scroll to Top / Bottom remain enabled for any open document as documented silent no-ops; only the page and arrow commands wait for a measurement.

Trailing debounce with a maximum wait, not a throttle.

Rejected the leading-guard-with-trailing-fire shape used by the T-1878 selection-rect refresh. That fires once per fixed window for the whole storm; here each fire forces a synchronous layout read for a boolean that almost never changes, and the dedupe would discard nearly all of them. Coalescing as long as possible with a 500ms deadline dominates for this signal.

Generic ResizeObserver triggers over an enumerated trigger list.

One observer on documentElement + body covers section collapse/expand, <details>, table display modes, typography and Dynamic Type reflow, note injection and media settling. window resize and onSectionVisibilityChanged remain as belt and braces because a purely vertical window resize need not change either observed box.

One-directional dedupe (page records what it posted, not what native holds).

Valid only while every native reset of canScroll pairs with a page reload. Verified to hold: the raw-source toggle is an if/else in both layouts so DocumentScrollContent's @State is destroyed and the assembly rebuilt; parse-revision reloads and WebContent-termination recovery both re-evaluate the script. The consequence of breaking it is spelled out on the variable itself — native stuck on false while the page dedupes its identical verdict away.

applyWebScrollability no-ops without an attached web backend.

Closes two holes with one guard: a late report cannot re-enable what detachWebBridge disabled, and it can never reach rawSourceScroll, which has a genuine SwiftUI geometry feed of its own.

The 1ms load timer carries the initial report alongside reportVisibleBlock.

The ResizeObserver delivers an initial observation too, but only where ResizeObserver exists, and the menu must reach correct enablement even for a document whose layout never changes again after load. A timer (not requestAnimationFrame) so it also fires for an inert offscreen document in tests — the same reasoning the existing visible-block report already used.

Design captured in docs/agent-notes rather than a specs/bugfixes report.

The mechanism, the two live-harness traps (WebKit page-timer throttling; the zero-height-viewport vacuous pass) and the dedupe invariant are written into docs/agent-notes/keyboard-scrolling.md beside the existing web-path notes, which is where a future session would look. Matches the lighter convention recent bugfix PRs on main follow.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
infoprism-scroll.js — duplicated scroll-range expressionisDocumentScrollable() recomputes documentElement.scrollHeight - clientHeight, which documentScrollFraction() already computes 150 lines above. If the scroller ever moves to an inner container, the two must be changed together.Not changed — this review is report-only, and both readers are three lines. Worth a shared scrollableRange() helper next time either is touched.
infoDedupe invariant vs sibling PR #348The one-directional dedupe holds only while every native canScroll reset pairs with a fresh page. Verified true today (raw-source toggle destroys the @State via an if/else in both layouts; reloads re-evaluate the script). Sibling PR #348 changes the raw-toggle path; if it ever preserves the web view across the toggle, this silently becomes 'menu stuck disabled for the session'.No code change needed. The invariant is documented on the variable in prism-scroll.js and in the agent note; flagged here so #348's review checks it.
infokeyHandlingFollowsReport — production reachabilityhandleKeyPress is called only from RawSourceView; on the web path key presses go to WebKit and canScroll gates only the View-menu items (DocumentReaderView picks renderedScroll as active). So the test drives a configuration — web bridge attached, handleKey called — that does not occur in production, and its Req 1.9/3.7 framing slightly overclaims web-path key coverage.No change recommended. It is still a genuine test of the routing table's gates, the split that enables it is well justified, and menu enablement (the actual T-1932 surface) is covered by the assembly and router tests.
infoSession-change ordering: reset (detach) vs assembly (attach)If resetSessionState()'s detach ever landed after the assembly task's attach, the fresh page's report would be dropped by the webCommands != nil guard and the page commands would stay disabled for the whole document. In practice .onChange runs before .task(id:) restarts, and the same ordering already governed hasContent before this change — a reversal would have disabled Top/Bottom too, an obvious bug that is not reported.Pre-existing ordering assumption, not introduced here. Listed as a double-check item only.
infoResizeObserver absence is a silent degradationGuarded by typeof ResizeObserver === 'function'. Where absent, the only remaining trigger for content that grows after load (images, mermaid) is window resize or section visibility, so a media-heavy long document could stay reported unscrollable.Theoretical on iOS 26 / macOS 26 WebKit, where ResizeObserver always exists; the guard is defensive. Left as is.
minorPre-existing iOS build warnings (unrelated)make build-ios succeeds but emits 44 instances of "main actor-isolated conformance of 'ImageDimension' to 'Equatable' cannot be used in nonisolated context; this is an error in the Swift 6 language mode", originating in prism/Models/MarkdownBlock.swift — a file this PR does not touch.Not introduced by this branch and out of scope for it. Flagged because the project standard is zero-warning builds and this will become a hard error under Swift 6; worth its own ticket.

Per-file diffs

Click to expand.

prism/Services/KeyboardScrollController.swift Modified +57 / -24
diff --git a/prism/Services/KeyboardScrollController.swift b/prism/Services/KeyboardScrollController.swiftindex 9da0832..cbd86eb 100644--- a/prism/Services/KeyboardScrollController.swift+++ b/prism/Services/KeyboardScrollController.swift@@ -103,15 +103,32 @@ final class KeyboardScrollController {     /// `hasContent`/`canScroll`.     @ObservationIgnored private var webCommands: WebCommands? -    /// Routes this controller's commands to the rendered web document and-    /// marks the controller scrollable (`hasContent`/`canScroll`) so menu-    /// items and key handling enable without SwiftUI scroll geometry-    /// (T-1719; page/edge scrolling is a silent no-op on short content, the-    /// same contract the menu items rely on).+    /// Routes this controller's commands to the rendered web document (T-1719).+    ///+    /// `hasContent` is true immediately — a rendered document always has content,+    /// so Scroll to Top / Bottom enable at once (they are silent no-ops on short+    /// content, the documented `hasContent` vs `canScroll` split). `canScroll` is+    /// NOT assumed: only the page knows whether its content overflows the+    /// viewport, and it reports that through `applyWebScrollability` once layout+    /// settles and again on every layout-affecting change (T-1932). Asserting it+    /// here left Page Up/Down enabled on a one-line document, silently doing+    /// nothing.     func attachWebBridge(_ commands: WebCommands) {         webCommands = commands         hasContent = true-        canScroll = true+        canScroll = false+    }++    /// Applies the rendered document's own scrollability report (T-1932).+    ///+    /// Ignored when no web backend is attached: the raw-source path is driven by+    /// SwiftUI scroll geometry, and a late report from a page whose view has just+    /// unmounted must not re-enable the commands `detachWebBridge` disabled.+    func applyWebScrollability(_ scrollable: Bool) {+        guard webCommands != nil else { return }+        if canScroll != scrollable {+            canScroll = scrollable+        }     }      /// Detaches the web backend (session change / controller teardown),@@ -214,52 +231,68 @@ final class KeyboardScrollController {     /// for any key not in the table. Top/bottom commands otherwise proceed     /// unconditionally because they are silent no-ops on short content.     func handleKeyPress(_ keyPress: KeyPress, reduceMotion: Bool) -> KeyPress.Result {+        handleKey(keyPress.key, modifiers: keyPress.modifiers, reduceMotion: reduceMotion)+            ? .handled+            : .ignored+    }++    /// The routing table itself, taking the two fields `handleKeyPress` reads.+    /// `true` == `.handled`.+    ///+    /// Split out purely so it can be driven from a test: `KeyPress` has no+    /// public initialiser, so the SwiftUI entry point above cannot be called+    /// directly and a test that "covers key handling" by calling `pageDown()`+    /// is not covering the `canScroll` gates at all (T-1932 review). The entry+    /// point stays a one-line forwarder so there is one routing table, not two.+    func handleKey(+        _ key: KeyEquivalent, modifiers: EventModifiers, reduceMotion: Bool+    ) -> Bool {         // Modal presented: do not consume the key. Returning `.ignored`         // lets SwiftUI route it to the actually focused responder (e.g.         // the popover or sheet) instead of swallowing the event here.-        guard !suspended else { return .ignored }-        let mods = keyPress.modifiers-        switch keyPress.key {+        guard !suspended else { return false }+        let mods = modifiers+        switch key {         case .upArrow:             if mods.contains(.command) {                 scrollToTop(reduceMotion: reduceMotion)-                return .handled+                return true             }-            guard canScroll else { return .ignored }+            guard canScroll else { return false }             arrowUp(reduceMotion: reduceMotion)-            return .handled+            return true         case .downArrow:             if mods.contains(.command) {                 scrollToBottom(reduceMotion: reduceMotion)-                return .handled+                return true             }-            guard canScroll else { return .ignored }+            guard canScroll else { return false }             arrowDown(reduceMotion: reduceMotion)-            return .handled+            return true         case .pageUp:-            guard canScroll else { return .ignored }+            guard canScroll else { return false }             pageUp(reduceMotion: reduceMotion)-            return .handled+            return true         case .pageDown:-            guard canScroll else { return .ignored }+            guard canScroll else { return false }             pageDown(reduceMotion: reduceMotion)-            return .handled+            return true         case .home:             scrollToTop(reduceMotion: reduceMotion)-            return .handled+            return true         case .end:             scrollToBottom(reduceMotion: reduceMotion)-            return .handled+            return true         case .space:-            guard canScroll else { return .ignored }+            guard canScroll else { return false }             if mods.contains(.shift) {                 pageUp(reduceMotion: reduceMotion)             } else {                 pageDown(reduceMotion: reduceMotion)             }-            return .handled+            return true         default:-            return .ignored+            return false         }     } }
prism/Resources/WebRenderer/prism-scroll.js Modified +95 / -1
diff --git a/prism/Resources/WebRenderer/prism-scroll.js b/prism/Resources/WebRenderer/prism-scroll.jsindex 1dba1b0..98466d7 100644--- a/prism/Resources/WebRenderer/prism-scroll.js+++ b/prism/Resources/WebRenderer/prism-scroll.js@@ -159,6 +159,92 @@      window.addEventListener("scroll", reportScrollDirection, { passive: true }); +    // ---- scrollabilityChanged (menu enablement, T-1932) -------------------+    // Native decides whether View > Page Up/Down are enabled, but only the page+    // can measure whether the rendered document overflows its viewport: on the+    // web path KeyboardScrollController has no SwiftUI scroll geometry at all.+    // Report the boolean — deduped (only transitions post) and debounced to the+    // visibleBlock cadence with a maximum wait, so a reflow storm collapses to+    // one message without deferring it past the deadline (see below).+    //+    // Triggers: the first settled layout, plus every layout-affecting change.+    // A ResizeObserver on the root and body covers the whole class generically —+    // section collapse/expand, <details> open/close, table display-mode changes,+    // typography and Dynamic Type reflow, note bubble/banner injection, image and+    // diagram rendering all change content height and fire it. The explicit+    // window-resize listener and section-visibility hook are belt and braces: a+    // purely vertical window resize need not change either observed box, and the+    // section hook fires on the same tick the collapse applies.++    var SCROLLABILITY_EPSILON = 1;+    // DEBOUNCE WITH A MAXIMUM WAIT. The trailing debounce coalesces a reflow+    // storm into one measurement taken after layout settles — the ordering this+    // report needs — but a pure trailing debounce re-arms on every trigger and+    // has no deadline. A media-heavy document fires the ResizeObserver once per+    // image/diagram that finishes laying out, and a run of completions spaced+    // under the debounce starves the timer for the whole burst. Since canScroll+    // now starts FALSE, that starvation is the window in which Page Up/Down are+    // wrongly DISABLED on a long document — so it is bounded: the trailing timer+    // is armed for the debounce OR the time left until the maximum wait,+    // whichever is shorter, so the report lands no later than MAX_WAIT after the+    // first pending trigger no matter how many arrive.+    //+    // Deliberately NOT the leading-guard-with-trailing-fire throttle used by the+    // selection-rect refresh in prism-notes.js (T-1878). That shape fires once+    // per fixed window for the whole storm; here each fire forces a synchronous+    // layout read for a boolean that almost never changes, and the dedupe would+    // discard nearly all of them. Coalescing as long as possible, with a+    // deadline, is the right trade for this signal.+    var SCROLLABILITY_DEBOUNCE_MS = 120;+    var SCROLLABILITY_MAX_WAIT_MS = 500;+    // What this page has POSTED, not what native currently holds — the dedupe is+    // ONE-DIRECTIONAL, and only valid while native never clears `canScroll`+    // without a reload. Native's own resets (attachWebBridge, detachWebBridge,+    // resetForNewSession) each pair with a fresh page today: the assembly is+    // rebuilt on mount, and WebContent-termination recovery reloads — and a+    // reload re-evaluates this script, resetting this back to null so the next+    // measurement always posts. If a reset path is ever added that leaves the+    // page loaded, native would sit on `false` while the page dedupes its+    // identical verdict away and the menu would stay disabled for the rest of+    // the session — the inverse of T-1932. Add a JS-side reset then.+    var lastReportedScrollable = null;+    var scrollabilityTimer = null;+    var scrollabilityPendingSince = null;++    function isDocumentScrollable() {+        var doc = document.documentElement;+        // Sub-pixel layout rounding can leave scrollHeight a fraction above+        // clientHeight on a document that cannot actually scroll.+        return (doc.scrollHeight - doc.clientHeight) > SCROLLABILITY_EPSILON;+    }++    function flushScrollability() {+        if (scrollabilityTimer) { clearTimeout(scrollabilityTimer); }+        scrollabilityTimer = null;+        scrollabilityPendingSince = null;+        var scrollable = isDocumentScrollable();+        if (scrollable === lastReportedScrollable) { return; }+        lastReportedScrollable = scrollable;+        bridge.post("scrollabilityChanged", { scrollable: scrollable });+    }++    function reportScrollability() {+        var now = Date.now();+        if (scrollabilityPendingSince === null) { scrollabilityPendingSince = now; }+        var remaining = SCROLLABILITY_MAX_WAIT_MS - (now - scrollabilityPendingSince);+        var wait = Math.max(0, Math.min(SCROLLABILITY_DEBOUNCE_MS, remaining));+        if (scrollabilityTimer) { clearTimeout(scrollabilityTimer); }+        scrollabilityTimer = setTimeout(flushScrollability, wait);+    }++    if (typeof ResizeObserver === "function") {+        var scrollabilityObserver = new ResizeObserver(reportScrollability);+        scrollabilityObserver.observe(document.documentElement);+        if (document.body) { scrollabilityObserver.observe(document.body); }+    }+    window.addEventListener("resize", reportScrollability);+    bridge.onSectionVisibilityChanged(reportScrollability);+     // ---- Native → JS scroll commands -------------------------------------      // The nearest laid-out section to a display:none target (T-1944):@@ -247,5 +333,13 @@     // Report the initial position once layout has settled, so native's restore can     // hand off the rendered fraction to the raw toggle (Req 2.2). A timer (not rAF)     // is used so it fires for an inert, offscreen document in tests too.-    setTimeout(function () { reportVisibleBlock(); }, 1);+    //+    // The initial scrollability report rides the same timer (T-1932): the+    // ResizeObserver above delivers an initial observation too, but only where+    // ResizeObserver exists, and the menu must reach the right enablement even for+    // a document whose layout never changes again after load.+    setTimeout(function () {+        reportVisibleBlock();+        reportScrollability();+    }, 1); })();
prism/ViewModels/WebBridgeContract.swift Modified +10 / -0
diff --git a/prism/ViewModels/WebBridgeContract.swift b/prism/ViewModels/WebBridgeContract.swiftindex 62b7896..f0860b0 100644--- a/prism/ViewModels/WebBridgeContract.swift+++ b/prism/ViewModels/WebBridgeContract.swift@@ -92,6 +92,15 @@ enum InboundBridgeMessage: Equatable, Sendable {     /// threshold crossings (never per scroll frame), suppressed during     /// programmatic scrolls; drives the compact layout's hide-on-scroll toolbar.     case scrollDirectionChanged(direction: ScrollDirection, offsetY: Double)+    /// `scrollabilityChanged` — whether the rendered document currently overflows+    /// its viewport (T-1932). Only the page knows this: the native+    /// `KeyboardScrollController` on the web path has no SwiftUI scroll geometry,+    /// so without this report Page Up/Down stay enabled on a one-line document and+    /// silently do nothing. Posted debounced and deduped — on the first settled+    /// layout and on every layout-affecting change (collapse/expand, details,+    /// table mode, typography/Dynamic Type reflow, note injection, media load,+    /// window resize) — never per scroll frame.+    case scrollabilityChanged(scrollable: Bool)     /// `perfSample` — max frame gap (ms) + LoAF entry count (Req 9.2).     case perfSample(maxFrameGapMS: Double, longAnimationFrames: Int)     /// `diagFailure` — category (Req 11.5).@@ -159,6 +168,7 @@ enum InboundMessageType: String, CaseIterable, Sendable {     case imageActivated     case tableModeToggled     case scrollDirectionChanged+    case scrollabilityChanged     case perfSample     case diagFailure }
prism/ViewModels/BridgeMessageRouter.swift Modified +6 / -0
diff --git a/prism/ViewModels/BridgeMessageRouter.swift b/prism/ViewModels/BridgeMessageRouter.swiftindex 9bedf78..4731b8d 100644--- a/prism/ViewModels/BridgeMessageRouter.swift+++ b/prism/ViewModels/BridgeMessageRouter.swift@@ -212,6 +212,12 @@ struct BridgeMessageRouter {                   let direction = ScrollDirection(rawValue: directionString),                   let offsetY = double(dict["offsetY"]) else { return nil }             return .scrollDirectionChanged(direction: direction, offsetY: offsetY)+        case .scrollabilityChanged:+            // A missing/ill-typed flag is dropped rather than defaulted (T-1932):+            // defaulting either way would fake a geometry report native cannot+            // otherwise obtain on the web path.+            guard let scrollable = dict["scrollable"] as? Bool else { return nil }+            return .scrollabilityChanged(scrollable: scrollable)         case .perfSample:             guard let gap = double(dict["maxFrameGapMS"]) else { return nil }             return .perfSample(maxFrameGapMS: gap, longAnimationFrames: int(dict["longAnimationFrames"]) ?? 0)
prism/ViewModels/WebDocumentMessageRouter.swift Modified +12 / -0
diff --git a/prism/ViewModels/WebDocumentMessageRouter.swift b/prism/ViewModels/WebDocumentMessageRouter.swiftindex c0690b4..b16417c 100644--- a/prism/ViewModels/WebDocumentMessageRouter.swift+++ b/prism/ViewModels/WebDocumentMessageRouter.swift@@ -88,6 +88,18 @@ struct WebDocumentMessageRouter {             // the coordinator applies the pre-cutover threshold rules.             coordinator.applyScrollDirection(direction, offsetY: offsetY) +        case .scrollabilityChanged(let scrollable):+            // Page/arrow menu enablement on the web path (T-1932). Only the page+            // can measure whether the rendered document overflows its viewport —+            // `KeyboardScrollController` has no SwiftUI scroll geometry here, so+            // before this report the View-menu page commands were enabled on a+            // one-line document and silently did nothing. `hasContent` is+            // deliberately untouched: Top/Bottom stay enabled as silent no-ops.+            // The rendered body's controller is the one `DocumentScrollContent`+            // attaches the web backend to; the raw-source controller keeps its own+            // geometry feed (the controller guards on an attached backend).+            coordinator.renderedScroll.applyWebScrollability(scrollable)+         case .copyContent(let blockID, let kind):             copyContent(blockID: blockID, kind: kind) 
prismTests/WebRendering/WebScrollabilityReportingTests.swift Added +513 / -0
diff --git a/prismTests/WebRendering/WebScrollabilityReportingTests.swift b/prismTests/WebRendering/WebScrollabilityReportingTests.swiftnew file mode 100644index 0000000..fd521e0--- /dev/null+++ b/prismTests/WebRendering/WebScrollabilityReportingTests.swift@@ -0,0 +1,513 @@+//+//  WebScrollabilityReportingTests.swift+//  prismTests+//+//  T-1932 regression tests: View > Page Up / Page Down must reflect whether the+//  RENDERED document actually overflows its viewport.+//+//  T-1719 attached the web scroll backend by asserting `canScroll = true` at+//  attach time — before the page had even loaded — so the page commands were+//  permanently enabled and silently did nothing on a document shorter than the+//  viewport. Enablement is now driven by a `scrollabilityChanged` bridge report+//  from the page, keeping `hasContent` true independently (short documents keep+//  Scroll to Top / Bottom enabled as silent no-ops — the explicit contract in+//  specs/keyboard-scrolling/design.md and docs/agent-notes/keyboard-scrolling.md).+//+//  Coverage spans all four seams, because the failure was a WIRING failure, not a+//  component failure: the controller contract, the audited bridge allowlist, the+//  message router, the PRODUCTION assembly (`makeAssembly`, the same entry point+//  DocumentScrollContent mounts), and the live page that measures the geometry.+//+//  Pre-fix, `attachDoesNotAssumeScrollable` fails (T-1932 red phase); the rest+//  describe API that did not exist.+//++import Foundation+import SwiftUI+import Testing+import WebKit+@testable import prism++@MainActor+struct WebScrollabilityReportingTests {++    @MainActor+    private final class CommandRecorder {+        var pageUp = 0+        var pageDown = 0+        var top = 0+        var bottom = 0++        var commands: KeyboardScrollController.WebCommands {+            KeyboardScrollController.WebCommands(+                pageUp: { self.pageUp += 1 },+                pageDown: { self.pageDown += 1 },+                scrollToTop: { self.top += 1 },+                scrollToBottom: { self.bottom += 1 }+            )+        }+    }++    // MARK: - Controller contract++    @Test("Attaching the web bridge does not assume the document is scrollable")+    func attachDoesNotAssumeScrollable() {+        let controller = KeyboardScrollController()+        let recorder = CommandRecorder()++        controller.attachWebBridge(recorder.commands)++        #expect(+            controller.hasContent,+            "a rendered document always has content — Top/Bottom stay enabled (Req 6.6/6.7)"+        )+        #expect(+            !controller.canScroll,+            "page/arrow enablement must wait for the page's own geometry report (T-1932)"+        )+    }++    @Test("The page's scrollability report drives canScroll in both directions")+    func scrollabilityReportDrivesCanScroll() {+        let controller = KeyboardScrollController()+        let recorder = CommandRecorder()+        controller.attachWebBridge(recorder.commands)++        controller.applyWebScrollability(true)+        #expect(controller.canScroll, "a long document enables the page commands")+        #expect(controller.hasContent)++        // A layout-affecting change (collapsing every section, shrinking Dynamic+        // Type) can make a previously scrollable document fit its viewport.+        controller.applyWebScrollability(false)+        #expect(!controller.canScroll, "the document no longer overflows — disable the page commands")+        #expect(controller.hasContent, "hasContent is independent of scrollability")+    }++    // Drives the ACTUAL routing table (`handleKey`, which `handleKeyPress`+    // forwards to verbatim) rather than calling `pageDown()`/`scrollToBottom()`+    // directly — those are not `canScroll`-gated, so the earlier version of this+    // test asserted nothing about key handling at all. `KeyPress` has no public+    // initialiser, which is why the routing table takes its two fields.+    @Test("Key handling follows the reported scrollability")+    func keyHandlingFollowsReport() {+        let controller = KeyboardScrollController()+        let recorder = CommandRecorder()+        controller.attachWebBridge(recorder.commands)++        // Unreported (the window this ticket is about): page keys must not be+        // consumed, and must reach no command.+        #expect(+            !controller.handleKey(.pageDown, modifiers: [], reduceMotion: true),+            "before the page reports, Page Down is .ignored (Req 1.9/3.7)"+        )+        #expect(!controller.handleKey(.pageUp, modifiers: [], reduceMotion: true))+        #expect(!controller.handleKey(.space, modifiers: [], reduceMotion: true))+        #expect(recorder.pageDown == 0)+        #expect(recorder.pageUp == 0)++        // Top/Bottom are gated on hasContent, not canScroll: still handled, as+        // silent no-ops (specs/keyboard-scrolling/design.md).+        #expect(controller.handleKey(.end, modifiers: [], reduceMotion: true))+        #expect(recorder.bottom == 1, "Scroll to Bottom remains a permitted silent no-op")++        // Reported scrollable: the same keys now route.+        controller.applyWebScrollability(true)+        #expect(controller.canScroll)+        #expect(controller.handleKey(.pageDown, modifiers: [], reduceMotion: true))+        #expect(recorder.pageDown == 1)+        #expect(controller.handleKey(.space, modifiers: .shift, reduceMotion: true))+        #expect(recorder.pageUp == 1, "Shift-Space pages up")++        // And back: a document that stopped overflowing re-closes the gate.+        controller.applyWebScrollability(false)+        #expect(!controller.canScroll)+        #expect(!controller.handleKey(.pageDown, modifiers: [], reduceMotion: true))+        #expect(recorder.pageDown == 1, "no further command reached the page")+    }++    @Test("A scrollability report is ignored without an attached web backend")+    func reportIgnoredWithoutBridge() {+        // The raw-source controller is driven by SwiftUI scroll geometry; a+        // report must never enable it.+        let controller = KeyboardScrollController()+        controller.applyWebScrollability(true)+        #expect(!controller.canScroll)++        // And a late report from a page whose view just unmounted must not+        // re-enable what detachWebBridge disabled.+        let recorder = CommandRecorder()+        controller.attachWebBridge(recorder.commands)+        controller.applyWebScrollability(true)+        controller.detachWebBridge()+        controller.applyWebScrollability(true)+        #expect(!controller.canScroll)+    }++    @Test("resetForNewSession clears a reported scrollability")+    func resetClearsReportedScrollability() {+        let controller = KeyboardScrollController()+        let recorder = CommandRecorder()+        controller.attachWebBridge(recorder.commands)+        controller.applyWebScrollability(true)++        controller.resetForNewSession()+        #expect(!controller.canScroll, "a new (possibly short) document must not inherit the old verdict")+        #expect(!controller.hasContent)+    }++    // MARK: - Bridge contract (allowlist + generation)++    private func makeWebController() -> WebDocumentController {+        WebDocumentController(+            sessionID: "t1932",+            parseRevision: 1,+            schemeHandler: PrismDocSchemeHandler()+        )+    }++    @Test("scrollabilityChanged decodes through the audited bridge allowlist")+    func scrollabilityChangedDecodes() {+        let controller = makeWebController()+        let body: [String: Any] = [+            "type": "scrollabilityChanged",+            "generation": controller.currentGeneration.argumentValue,+            "scrollable": false,+        ]+        #expect(controller.receive(messageBody: body) == .accepted(.scrollabilityChanged(scrollable: false)))+    }++    @Test("A scrollabilityChanged without a boolean flag is dropped, not defaulted")+    func scrollabilityChangedMalformedDropped() {+        let controller = makeWebController()+        let body: [String: Any] = [+            "type": "scrollabilityChanged",+            "generation": controller.currentGeneration.argumentValue,+            "scrollable": "yes",+        ]+        #expect(controller.receive(messageBody: body) == .dropped(.malformedPayload(.scrollabilityChanged)))+    }++    @Test("A stale-generation scrollabilityChanged is dropped")+    func scrollabilityChangedStaleGenerationDropped() {+        let controller = makeWebController()+        let stale = BridgeGeneration(sessionID: "t1932", parseRevision: 0, processGeneration: 0)+        let body: [String: Any] = [+            "type": "scrollabilityChanged",+            "generation": stale.argumentValue,+            "scrollable": true,+        ]+        #expect(controller.receive(messageBody: body) == .dropped(.staleGeneration))+    }++    // MARK: - Router → coordinator routing++    @Test("scrollabilityChanged drives the rendered body's scroll controller")+    func routerDrivesRenderedScrollController() async {+        let session = DocumentSession(+            url: URL(fileURLWithPath: "/tmp/t1932-router.md"),+            content: "# Title\n\nBody."+        )+        await session.parseContent()+        let coordinator = DocumentLayoutCoordinator()+        let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+        let recorder = CommandRecorder()+        coordinator.renderedScroll.attachWebBridge(recorder.commands)++        router.handle(.scrollabilityChanged(scrollable: true))+        #expect(coordinator.renderedScroll.canScroll)++        router.handle(.scrollabilityChanged(scrollable: false))+        #expect(!coordinator.renderedScroll.canScroll)+        #expect(coordinator.renderedScroll.hasContent, "Top/Bottom stay enabled on a short document")+        #expect(!coordinator.rawSourceScroll.canScroll, "the raw-source controller is untouched")+    }++    // MARK: - Production assembly wiring++    // The T-1719 regression class: every component can be correct while the+    // production assembly never connects them. This mounts the SAME entry point+    // DocumentScrollContent uses and feeds a raw message body in at the+    // controller, the way the live WKScriptMessageHandler does.+    @Test("The production assembly routes a page scrollability report to menu enablement")+    func productionAssemblyRoutesScrollability() async {+        let session = DocumentSession(+            url: URL(fileURLWithPath: "/tmp/t1932-assembly.md"),+            content: "# Short\n\nOne line."+        )+        await session.parseContent()+        let coordinator = DocumentLayoutCoordinator()+        let made = WebDocumentStateSynchronizer.makeAssembly(+            session: session,+            settings: AppSettings(),+            coordinator: coordinator,+            notesManager: NotesManager()+        )+        let recorder = CommandRecorder()+        coordinator.renderedScroll.attachWebBridge(recorder.commands)++        made.controller.receive(messageBody: [+            "type": "scrollabilityChanged",+            "generation": made.controller.currentGeneration.argumentValue,+            "scrollable": true,+        ])+        #expect(coordinator.renderedScroll.canScroll, "the report must reach the controller in production wiring")++        made.controller.receive(messageBody: [+            "type": "scrollabilityChanged",+            "generation": made.controller.currentGeneration.argumentValue,+            "scrollable": false,+        ])+        #expect(!coordinator.renderedScroll.canScroll)+        #expect(coordinator.renderedScroll.hasContent)+    }++    // MARK: - Live page geometry++    private static func tallBlocks(count: Int = 80) -> [MarkdownBlock] {+        var blocks: [MarkdownBlock] = [.heading(level: 1, text: "Top")]+        for index in 0..<count {+            blocks.append(.paragraph(markdown: "Paragraph number \(index) with enough text to take vertical space."))+        }+        return blocks+    }++    /// The bundled stylesheet, injected so the real cascade (including the+    /// collapsed-section `display: none` rule) governs layout.+    private static func makeStyledHarness(blocks: [MarkdownBlock]) async throws -> WebDocumentLiveHarness {+        let harness = try await WebDocumentLiveHarness.make(blocks: blocks)+        let cssURL = try #require(+            Bundle.main.url(forResource: "document", withExtension: "css"),+            "bundled document.css must be present in the test host"+        )+        let css = try String(contentsOf: cssURL, encoding: .utf8)+        _ = try await harness.page.callJavaScript(+            "var s = document.createElement('style'); s.textContent = css;"+                + " document.head.appendChild(s); return null;",+            arguments: ["css": css],+            contentWorld: harness.bridgeWorld+        )+        return harness+    }++    /// Waits for a `scrollabilityChanged` report beyond `after` and returns its flag.+    private func waitForScrollability(+        _ harness: WebDocumentLiveHarness, after count: Int = 0+    ) async throws -> Bool? {+        for _ in 0..<60 {+            let reports = harness.messages(type: "scrollabilityChanged")+            if reports.count > count { return reports.last?["scrollable"] as? Bool }+            try await Task.sleep(for: .milliseconds(50))+        }+        return nil+    }++    /// `"scrollHeight|clientHeight"`, so a geometry surprise fails legibly.+    private func geometry(_ harness: WebDocumentLiveHarness) async throws -> String {+        try await harness.evalString(+            "var d = document.documentElement;"+                + " return String(d.scrollHeight) + '|' + String(d.clientHeight);"+        ) ?? "?"+    }++    /// `documentElement.clientHeight` — the viewport half of the measured pair.+    private func viewportHeight(_ harness: WebDocumentLiveHarness) async throws -> Double {+        Double(try await harness.evalString("return String(document.documentElement.clientHeight);") ?? "0") ?? 0+    }++    /// `documentElement.scrollHeight` — the content half of the measured pair.+    private func contentHeight(_ harness: WebDocumentLiveHarness) async throws -> Double {+        Double(try await harness.evalString("return String(document.documentElement.scrollHeight);") ?? "0") ?? 0+    }++    /// Grows the document past any plausible viewport by appending a tall filler.+    ///+    /// Deliberately a plain element appended from native: it fires NO trigger of+    /// its own — no section-visibility hook, no window resize, no load timer —+    /// so only the `ResizeObserver` can see it.+    private func growContent(_ harness: WebDocumentLiveHarness, id: String = "probe") async throws {+        _ = try await harness.page.callJavaScript(+            "var filler = document.createElement('div');"+                + " filler.id = id; filler.style.height = '6000px';"+                + " document.body.appendChild(filler); return null;",+            arguments: ["id": id],+            contentWorld: harness.bridgeWorld+        )+    }++    @Test("A document shorter than the viewport reports itself unscrollable")+    func shortDocumentReportsUnscrollable() async throws {+        let harness = try await Self.makeStyledHarness(blocks: [.paragraph(markdown: "One line.")])+        let reported = try await waitForScrollability(harness)+        let measured = try await geometry(harness)+        #expect(+            reported == false,+            "short document reported \(String(describing: reported)); geometry (scrollHeight|clientHeight) \(measured)"+        )+    }++    @Test("A document taller than the viewport reports itself scrollable")+    func tallDocumentReportsScrollable() async throws {+        let harness = try await Self.makeStyledHarness(blocks: Self.tallBlocks())+        let reported = try await waitForScrollability(harness)+        let measured = try await geometry(harness)+        // Anchor the viewport assumption FIRST: on a zero-height harness+        // viewport any non-empty document is "scrollable", so the assertion+        // below would pass for the wrong reason and stop distinguishing this+        // test from the short-document one.+        #expect(+            try await viewportHeight(harness) > 0,+            "the offscreen harness must have a real viewport; geometry (scrollHeight|clientHeight) \(measured)"+        )+        #expect(+            reported == true,+            "tall document reported \(String(describing: reported)); geometry (scrollHeight|clientHeight) \(measured)"+        )+    }++    // The trigger the whole design rests on, and the one nothing else covers:+    // the load timer, the section-visibility hook and `window resize` all have+    // their own tests, so a `ResizeObserver` that silently stopped observing+    // would leave every other test in this file passing. Changing the content+    // height from native fires none of the other three.+    @Test("A content-height change with no other trigger re-reports (ResizeObserver)")+    func resizeObserverCatchesContentHeightChange() async throws {+        let harness = try await Self.makeStyledHarness(blocks: [.paragraph(markdown: "One line.")])+        #expect(try await waitForScrollability(harness) == false, "the short document must start unscrollable")+        // The load timer has fired; from here only the ResizeObserver is live.+        let seen = try await harness.settledMessageCount(type: "scrollabilityChanged")++        try await growContent(harness)++        let reported = try await waitForScrollability(harness, after: seen)+        let measured = try await geometry(harness)+        #expect(+            reported == true,+            "a content-height change must re-report; got \(String(describing: reported)); geometry (scrollHeight|clientHeight) \(measured)"+        )+    }++    // The debounce is bounded, so a document under continuous reflow pressure+    // cannot defer its FIRST report for the length of the burst. Without the+    // maximum wait every trigger re-arms the timer, and the report below does+    // not arrive until the burst ends — measured at 2.7s against this same+    // burst with the bound removed, versus 0.52s with it. That gap is the window+    // in which Page Up/Down are wrongly DISABLED on a long document, which is+    // what makes it worse than the pre-fix behaviour rather than merely late.+    //+    // The burst is driven from NATIVE, one `callJavaScript` per tick, because a+    // page-side `setInterval` cannot produce it: WebKit throttles timers in a+    // non-visible page, so a 40ms interval in the offscreen harness delivers+    // roughly six ticks and then about one per second — slower than the debounce+    // and therefore unable to starve it. Native calls are not throttled.+    @Test("A sustained trigger burst still reports within the debounce's maximum wait")+    func reportArrivesWithinMaxWaitDuringTriggerBurst() async throws {+        let harness = try await Self.makeStyledHarness(blocks: [.paragraph(markdown: "One line.")])+        #expect(try await waitForScrollability(harness) == false, "the short document must start unscrollable")+        let seen = harness.messages(type: "scrollabilityChanged").count++        try await growContent(harness, id: "burst")++        // Each tick nudges the height (observer) and fires a resize (listener),+        // so the test bounds the DEBOUNCE rather than any one trigger path.+        let start = ContinuousClock.now+        let burstLimit = Duration.milliseconds(2400)+        var ticks = 0+        var arrival: Duration?+        while start.duration(to: .now) < burstLimit {+            ticks += 1+            _ = try await harness.page.callJavaScript(+                "var f = document.getElementById('burst');"+                    + " if (f) { f.style.height = (6000 + tick) + 'px'; }"+                    + " window.dispatchEvent(new Event('resize')); return null;",+                arguments: ["tick": ticks],+                contentWorld: harness.bridgeWorld+            )+            if harness.messages(type: "scrollabilityChanged").count > seen {+                arrival = start.duration(to: .now)+                break+            }+            try await Task.sleep(for: .milliseconds(10))+        }++        let reported = try await waitForScrollability(harness, after: seen)+        let elapsed = arrival ?? start.duration(to: .now)+        #expect(reported == true, "the burst must still produce the transition report")+        #expect(+            ticks >= 3,+            "the burst must actually keep the debounce under pressure (only \(ticks) triggers in \(elapsed))"+        )+        #expect(+            elapsed < .milliseconds(1200),+            "report took \(elapsed) after \(ticks) triggers — it must fire on the maximum wait, not wait out the burst"+        )+    }++    @Test("The report is deduped — a real trigger with an unchanged verdict posts nothing")+    func reportIsDeduped() async throws {+        // Two headings' worth of content, so collapsing one genuinely changes+        // the layout (and fires the trigger) while the document stays taller+        // than the viewport — the verdict is unchanged, so nothing may post.+        var blocks: [MarkdownBlock] = []+        for section in 0..<2 {+            blocks.append(.heading(level: 1, text: "Section \(section)"))+            for index in 0..<80 {+                blocks.append(.paragraph(markdown: "Section \(section) paragraph \(index) with running text."))+            }+        }+        let harness = try await Self.makeStyledHarness(blocks: blocks)+        #expect(try await waitForScrollability(harness) == true, "the tall document must start scrollable")+        let settled = try await harness.settledMessageCount(type: "scrollabilityChanged")+        let before = try await contentHeight(harness)++        try await harness.send(.setSectionState(collapsedIDs: ["\(blocks[0].id)-0"]))++        let after = try await harness.settledMessageCount(type: "scrollabilityChanged")+        let shrunk = try await contentHeight(harness)+        // Guard against a vacuous pass: if the collapse did nothing, no trigger+        // fired and "no re-post" would be true for the wrong reason.+        #expect(shrunk < before, "the collapse must actually change the layout (\(before) → \(shrunk))")+        #expect(+            try await viewportHeight(harness) < shrunk,+            "the collapsed document must still overflow — the verdict has to be UNCHANGED"+        )+        #expect(after == settled, "an unchanged verdict must not re-post (\(settled) → \(after))")+    }++    @Test("Scrolling is not a trigger")+    func scrollAloneIsNotATrigger() async throws {+        let harness = try await Self.makeStyledHarness(blocks: Self.tallBlocks())+        _ = try await waitForScrollability(harness)+        let settled = try await harness.settledMessageCount(type: "scrollabilityChanged")++        _ = try await harness.page.callJavaScript(+            "window.scrollTo({ top: 400, behavior: 'auto' }); return null;",+            contentWorld: harness.bridgeWorld+        )+        let after = try await harness.settledMessageCount(type: "scrollabilityChanged")+        #expect(after == settled, "scrolling changes no geometry — the report must not re-post")+    }++    // The layout-affecting trigger the ticket calls out explicitly: collapsing+    // the only heading hides every following section, and the document that no+    // longer overflows must re-report (T-1944 section-visibility hook).+    @Test("Collapsing every section re-reports the document as unscrollable")+    func collapseReReportsUnscrollable() async throws {+        var blocks: [MarkdownBlock] = [.heading(level: 1, text: "Document")]+        for index in 0..<80 {+            blocks.append(.paragraph(markdown: "Paragraph \(index) with running text for height."))+        }+        let harness = try await Self.makeStyledHarness(blocks: blocks)+        #expect(try await waitForScrollability(harness) == true, "the tall document must start scrollable")+        let seen = harness.messages(type: "scrollabilityChanged").count++        try await harness.send(.setSectionState(collapsedIDs: ["\(blocks[0].id)-0"]))++        let reported = try await waitForScrollability(harness, after: seen)+        let measured = try await geometry(harness)+        #expect(+            reported == false,+            "collapsed to a single heading, reported \(String(describing: reported)); geometry (scrollHeight|clientHeight) \(measured)"+        )+    }+}
prismTests/WebRendering/WebScrollIntegrationContractTests.swift Modified +5 / -1
diff --git a/prismTests/WebRendering/WebScrollIntegrationContractTests.swift b/prismTests/WebRendering/WebScrollIntegrationContractTests.swiftindex 3486ebc..6f8c473 100644--- a/prismTests/WebRendering/WebScrollIntegrationContractTests.swift+++ b/prismTests/WebRendering/WebScrollIntegrationContractTests.swift@@ -134,7 +134,7 @@ struct WebScrollIntegrationContractTests {         }     } -    @Test("Attaching the web bridge enables scrolling and routes commands to it")+    @Test("The web bridge routes commands once the page has reported the document scrollable")     func webBridgeRoutesKeyboardCommands() {         let controller = KeyboardScrollController()         let recorder = CommandRecorder()@@ -142,6 +142,9 @@ struct WebScrollIntegrationContractTests {         #expect(!controller.canScroll)         controller.attachWebBridge(recorder.commands)         #expect(controller.hasContent, "menu Top/Bottom enable from hasContent (Req 6.6/6.7)")+        // T-1932: attach no longer ASSERTS scrollability — the page reports it+        // once it has measured its own geometry (WebScrollabilityReportingTests).+        controller.applyWebScrollability(true)         #expect(controller.canScroll, "page/arrow commands gate on canScroll")          controller.pageDown(reduceMotion: true)@@ -172,6 +175,7 @@ struct WebScrollIntegrationContractTests {         let controller = KeyboardScrollController()         let recorder = CommandRecorder()         controller.attachWebBridge(recorder.commands)+        controller.applyWebScrollability(true)         #expect(controller.canScroll)          controller.detachWebBridge()
docs/agent-notes/keyboard-scrolling.md Modified +25 / -0
diff --git a/docs/agent-notes/keyboard-scrolling.md b/docs/agent-notes/keyboard-scrolling.mdindex 8fff457..e92e73b 100644--- a/docs/agent-notes/keyboard-scrolling.md+++ b/docs/agent-notes/keyboard-scrolling.md@@ -41,6 +41,31 @@ Two flags gate menu enablement separately:  A short document has `hasContent == true && canScroll == false` — top/bottom commands are silent no-ops, page/arrow commands return `.ignored`. +## Where canScroll comes from on the web path (T-1932)++The rendered body has no SwiftUI scroll geometry at all — `scrollPosition` is bound to nothing and `onScrollGeometryChange` never fires — so `contentHeight` stays 0 there. T-1719 papered over that by having `attachWebBridge` set `canScroll = true` unconditionally, before the page had even loaded, which left Page Up/Down enabled on a one-line document doing nothing.++The page now reports it. `prism-scroll.js` posts `scrollabilityChanged { scrollable }` (allowlisted, generation-tagged like every other inbound message); `WebDocumentMessageRouter` folds it into `coordinator.renderedScroll.applyWebScrollability(_:)`. Rules that matter:++- `attachWebBridge` sets `hasContent = true`, `canScroll = false`. The split is the point: Top/Bottom stay enabled as silent no-ops, page/arrow wait for measured truth.+- `applyWebScrollability` is a **no-op when no web backend is attached**, so a late report cannot re-enable a controller `detachWebBridge` (raw-source toggle, document close) just disabled, and cannot touch `rawSourceScroll`.+- JS side: `scrollHeight - clientHeight > 1` (sub-pixel tolerance), debounced 120ms to the `visibleBlock` cadence and **deduped** — only transitions post, so a scroll or a reflow storm posts nothing/once.+- Triggers are generic: a `ResizeObserver` on `documentElement` + `body` catches every layout-affecting change (section collapse/expand, `<details>`, table display mode, typography/Dynamic Type reflow, note bubble injection, image/mermaid render). `window resize` and `bridge.onSectionVisibilityChanged` are belt and braces — a purely vertical window resize need not change either observed box. A timer post at load covers a document whose layout never changes again.+- The debounce has a **500ms maximum wait**. A pure trailing debounce re-arms on every trigger with no deadline, and a media-heavy document fires the observer once per image/diagram that finishes laying out — a run spaced under 120ms starves the timer for the whole burst. Since `canScroll` now starts `false`, that starvation is the window in which Page Up/Down are wrongly *disabled*. The trailing timer is armed for `min(debounce, time left until max wait)`, so the report lands no later than 500ms after the first pending trigger. Measured against a 2.4s trigger burst: **2.7s unbounded, 0.52s bounded**. Deliberately not the leading-guard-with-trailing-fire throttle used by the T-1878 selection-rect refresh — that shape fires once per window for the whole storm, and each fire here costs a synchronous layout read for a boolean that almost never changes.+- The dedupe is **one-directional**: JS records what it POSTED, not what native holds. It is only valid while native never clears `canScroll` without a reload. Every reset path pairs with a fresh page today (the assembly is rebuilt on mount, `handleProcessTermination` reloads), and a reload re-evaluates the script so the next measurement always posts. A reset that left the page loaded would leave native on `false` while the page dedupes its identical verdict away — the menu stuck disabled for the session, the inverse of T-1932. Also stated on the variable in `prism-scroll.js`.+- Reload resets the JS-side dedupe (fresh script evaluation), so every load re-reports; `canScroll` is briefly stale across the reload window by design rather than flapping.++**Testing the page side.** Two traps in the offscreen live harness:++- **Page timers are throttled.** WebKit throttles `setTimeout`/`setInterval` in a non-visible page: a 40ms `setInterval` in `WebDocumentLiveHarness` delivers roughly six ticks and then about one per second. A page-side interval therefore cannot produce a trigger burst fast enough to starve a 120ms debounce. Drive bursts from native (one `callJavaScript` per tick) instead — native calls are not throttled.+- **Assert the viewport is real.** On a zero-height viewport every non-empty document measures as scrollable, so a tall-document test would pass for the wrong reason. `WebScrollabilityReportingTests` asserts `documentElement.clientHeight > 0` first.++Mutation-checked: disabling the `ResizeObserver` branch fails only `resizeObserverCatchesContentHeightChange`; removing the maximum wait fails only `reportArrivesWithinMaxWaitDuringTriggerBurst`; removing the dedupe fails only `reportIsDeduped`.++**Driving the routing table from a test.** `handleKeyPress` forwards to `handleKey(_:modifiers:reduceMotion:)`, which holds the table. The split exists because `KeyPress` has no public initialiser, so the SwiftUI entry point cannot be called from a test at all — and a test that "covers key handling" by calling `pageDown()` directly is not touching the `canScroll` gates, which is exactly what this ticket is about.++**T-1965 groundwork:** the reflow trigger set above is exactly the signal a scroll-anchoring fix needs (re-anchor the reading position when Dynamic Type / typography reflows the document). Nothing here re-anchors — only the boolean crosses the bridge — but the "detect any reflow, debounced, in prism-scroll.js" mechanism is in place to hang it on.+ ## Repeat coalescing  `performMutation` records the last command timestamp. Within `animationDuration` (0.2s), follow-up commands wrap with `withAnimation(nil)` instead of `.easeInOut` to avoid stacked animations. `wasLastAnimationCoalesced` exposes this for tests. Reduce-motion commands skip animation entirely and never coalesce.
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 2ba12e8..4c65322 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 +- **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). - The **Add Note** button that appears when you select text now goes away when the document reloads (T-1852). If a file changed on disk — or a URL document was refreshed — while you had text selected, the selection vanished with the old page but the button stayed floating where it was. Tapping it then opened the note editor quoting text you were no longer looking at, or, if the reload had moved the content around, text from somewhere else entirely. The button is now dismissed the moment a reload starts, including the reload after granting folder access to images and the one that follows a rendering-process restart, and stays away for the rest of the reload: text you drag over while the document is still loading no longer brings the old button back. Each freshly loaded page then confirms for itself that it has no selection.

Things to double-check

Sibling PR #350 (WebContent-termination recovery).

Interacts by design rather than by conflict: the fresh page re-posts via the load timer while native holds the previous verdict across the recovery window — the documented one-directional dedupe behaviour. No file-level conflict with main (mergeStateStatus: CLEAN), and no shared file with #350's diff.

Sibling PR #348 (raw-toggle receipt token).

The one sibling with a real interaction surface. This PR's dedupe invariant depends on the raw-source toggle unmounting DocumentScrollContent and rebuilding the assembly. If #348 changes that to preserve the web view, add a JS-side dedupe reset at the same time.

First-load window is now briefly pessimistic.

Between page load and the first report (~121ms, up to 500ms under reflow pressure) Page Up/Down are disabled where they were previously enabled-but-broken. Intended, and bounded by the max wait — but it is the one user-visible behaviour change beyond the fix itself, and worth a look on a large real document.

Reload window holds the previous verdict.

Across a parse-revision reload (file changed on disk, URL refresh) native keeps the old canScroll until the new page reports. Deliberate — stale rather than flapping — and harmless in both directions (a stale true makes the command a no-op; a stale false resolves within 500ms).