The native Add Note capsule now follows the selected text as the document moves — scroll, nested-container scroll, resize, inline-note toggle, section collapse — and withdraws instead of pinning itself to a window edge. PR #347, reviewed against origin/main after PRs #344 / #345 landed.
selectionchange listener; scrolling fires no selectionchange, so the rect went stale and the native placement's clamp turned "scrolled off screen" into "pinned to the window edge".available only, throttled as a leading guard with a trailing fire so it converges after a fling, and registered in the capture phase so self-scrolling tables and code blocks are heard.WebSelectionOverlayGeometry is lifted out of WebDocumentView and given two hysteretic rules — visibility and flip-below — both monotone in their fed-back argument, so the view's @State feedback provably settles in one step.Ready to push
No blockers and nothing major. The fix is well-scoped: one page-side refresh path with a documented throttle discipline, one extracted, fully-swept geometry enum, and reuse of the existing onSectionVisibilityChanged hook rather than new plumbing. Lint is clean (0 violations / 515 files), the macOS build is clean, and 159 targeted tests across the selection, notes, scroll, section and controller suites pass. git merge-tree against the current origin/main is conflict-free, and the one semantic overlap — PR #345 rewrote the body of renderInlineNotes, which is where this branch adds its reflow refresh — still lands the new call at the end of the merged function.
Six suggestions below are follow-ups, not gates. The one worth a ticket is the hard-coded capsule half-extents (finding 2): the new flip decision is now load-bearing on an estimate that Dynamic Type invalidates.
3ce4313 Fix T-1878: Selection Add Note overlay does not follow scrolling e98be5f Address review on T-1878: narrow the refresh, align placement with visibility, cover resize 75f3f06 Address round-2 review on T-1878: cover native-push reflows, band the flip 8ab8a77 T-1878 pre-push review: clarify the refresh guard is not a viewport test When you select text in a Prism document, a small Add Note button appears next to it. Before this change, that button was placed once — at the moment you finished selecting — and then never moved. Scroll the page and the words slid away while the button stayed put. Worse, once your selection had scrolled completely off the screen, the button did not leave with it: it stuck to the edge of the window, still offering to add a note to text you could no longer see.
Now the button follows the text. Scroll by hand, jump with the table of contents, resize the window, rotate the device, show or hide your notes, collapse a section — the button keeps up with the words it belongs to. When the text goes off screen the button goes away too, and comes back when you scroll to it again. Your selection is never touched: the button disappearing means the text is out of sight, not that you have to select again.
A button that points at the wrong text is worse than no button, because tapping it still worked — it would attach your note to whatever you had selected minutes ago and scrolled past. The fix makes the button honest: where it is on screen always tells you which text it will annotate.
Prism renders documents through WebKit with native SwiftUI chrome on top; the selection affordance is one of those native pieces. The page (prism-notes.js, in an isolated content world) posts a selectionCandidate bridge message carrying a state (available / declined / cleared), the resolved block id and source range, and a viewport-relative rect. WebSelectionAffordanceState folds that into observable state and WebDocumentView draws a SwiftUI capsule at the rect.
The bug lived entirely in the frequency of that message: it was produced only from the selectionchange listener. This branch adds a second producer path that re-runs the same producer.
scheduleSelectionRectRefresh ends in a plain call to handleSelectionChange(). There is exactly one function that can produce a candidate, so the refresh can never drift from what a genuine event would have reported, and a selection that vanished meanwhile posts cleared rather than a stale available.if (timer) return; drops every event inside a 16 ms window, and the timer body reads the geometry when it fires. N events cost one refresh, and the last event of the window is the one whose position wins. This is deliberately the opposite of the debounces elsewhere in the renderer, and the comment says so explicitly so nobody "fixes" it.scroll on an element does not bubble, so { capture: true } on window is the only way a selection inside a wide table's own scroll container gets refreshed.bridge.onSectionVisibilityChanged for prism-search.js. The selection rect subscribes to it instead of growing its own notification.WebSelectionOverlayGeometry is a free-standing enum of pure functions; the view supplies the previous decision as an argument and stores it in @State.available candidates (a cross-block drag is common and draws nothing, so refreshing it is pure waste).prism-scroll.js's position reports: a TOC jump moves the selected text too, and the button should follow it there.Repositioning alone would have been mobile but still wrong in two places, and both are closed here.
(1) Clip-aware visibility. Native only knows the viewport. document.css puts wide tables, pre blocks and mermaid output in their own overflow-x: auto containers, so a cell scrolled sideways out of .prism-table-wrap is invisible while still inside the window's x-range. clipRectToAncestors walks from the range's startContainer to body, intersecting the rect with the border box of every ancestor whose computed overflow-x or overflow-y is auto|scroll|hidden|clip. An empty intersection comes out as a zero-sized rect — which the pre-existing rect.width > 0, rect.height > 0 guard rejects. That guard also, as a side effect, kills the display:none 0,0,0,0 rect that used to park the capsule in the top-left corner. Cost is one getComputedStyle + one getBoundingClientRect per ancestor on a shallow tree, and on the scroll path both read a clean style/layout tree (scrolling dirties neither), so the 16 ms cadence is affordable.
(2) Flip-below placement. Clamping alone left a band roughly 64 pt deep at the top of the view where every selection drew the capsule at the same pinned y — the reported defect in miniature. flipsBelow puts the capsule under the selection when the room above runs out. Both rules are hysteretic and, critically, both are monotone in their fed-back argument (the "stay" test is strictly weaker than the "change" test), so f(x, f(x, s)) == f(x, s) and the view's @State feedback settles in one step with no oscillation. That is not asserted by hand-picked cases: overlayDecisionsAreIdempotentAtEveryBoundary and overlayPipelineIsIdempotentAtEveryBoundary sweep all three boundaries at 1 pt granularity × both previous states, and overlayDoesNotFlapUnderAJitter drives a ±0.5 pt oscillation for 12 half-cycles at every sweep point and requires at most one drawn-state transition.
The interesting design work is the enumeration of "things that move the text without a scroll":
resize — same listener, same throttle. Argued in on a strictly stronger basis than scroll: after a stale scroll the next scroll event repairs it, whereas after a rotation nothing necessarily follows.setInlineNotes — the banner is inserted before the first block and shifts the whole document; bubbles push everything after them. Refreshed from the end of renderInlineNotes.setSectionState — refreshed off bridge.onSectionVisibilityChanged. Collapsing the selection's own section needs no special case: the hidden subtree reports a zero rect that isVisible already rejects, so the button withdraws rather than hovering over unrendered text.applyTypography writes CSS variables whose reflow lands asynchronously in the style/layout pass, so the command handler returning is not the moment the text has moved. Repairing it needs prism-theme.js to notify after the variables take effect.removeAllRanges() fires selectionchange asynchronously, so between the user tapping Add note and that event landing, the page still believes an affordance is on screen — and a scroll in that window would re-post available and re-arm the button the user just dismissed. The fix calls bridge.refreshSelectionCandidate() in the same JS invocation. That is a Swift string literal reaching for a JS property behind an existence guard, so a rename on either side degrades silently back into the race rather than throwing. The literal is therefore hoisted to WebDocumentController.clearSelectionScript and pinned by clearSelectionScriptCallsTheBridgeHook, which swaps the real function for a spy, runs the exact constant against a live page, and requires the spy to fire. That is the right shape for any cross-language string coupling in this codebase.
halfWidth 70, halfHeight 18). They were cosmetic when they only fed a clamp; the flip decision now depends on them, and .font(.callout) scales with Dynamic Type. See finding 2.WebSelectionAffordanceState.mode now churns at the refresh cadence, invalidating the whole WebDocumentView.body rather than just the overlay. See finding 4.prism-notes.js
Why it matters. This is the fix. It is also the single riskiest hunk, because it adds a listener that fires on every scroll event in the document and can post ~60 bridge messages a second. Everything that keeps that safe is a one-line guard, so read the guards, not the comment volume.
What to look at. prism-notes.js — scheduleSelectionRectRefresh / hasVisibleAffordance / the scroll + resize registrations
prism-notes.js
Why it matters. The throttle shape is a correctness property, not a performance tuning knob, and it is the opposite of the two debounces elsewhere in the same renderer. A well-meaning consistency refactor would strand the button mid-fling.
What to look at. prism-notes.js — SELECTION_RECT_REFRESH_MS / selectionRectRefreshTimer
prism-notes.js
Why it matters. Without it, the reported defect simply reappears on the horizontal axis: a table cell scrolled sideways out of `.prism-table-wrap` stays inside the viewport's x-range, so native would keep the button over text the reader cannot see.
What to look at. prism-notes.js — CLIPPING_OVERFLOW / clipRectToAncestors, called from handleSelectionChange
WebDocumentView.swift
Why it matters. Placement and visibility must agree, or the fix trades an edge-pinned button for a band near the top of the view where every selection draws the capsule at the same y. Both rules feed their previous decision back through view @State, which is where oscillation bugs live.
What to look at. WebDocumentView.swift — WebSelectionOverlayGeometry.isVisible / flipsBelow / point, and the @State feedback in selectionOverlay
WebDocumentController.swift
Why it matters. It closes a real race (tap Add note, then scroll before selectionchange lands, and the dismissed button re-arms) using a construct that fails silently — a Swift string reaching for a JS property behind an existence guard.
What to look at. WebDocumentController.swift:558-570 and WebSelectionScrollTests.clearSelectionScriptCallsTheBridgeHook
prism-notes.js
Why it matters. Section collapse and the inline-notes toggle both move the document with no DOM event, and both are reachable from the toolbar with zero page interaction — so a live selection survives them and nothing else would ever repair the rect.
What to look at. prism-notes.js — bridge.onSectionVisibilityChanged(scheduleSelectionRectRefresh) and the call at the end of renderInlineNotes
Dismissing the affordance on the first scroll is simpler, and is the wrong answer for a reason that is easy to miss: a scroll fires no selectionchange, so nothing would ever bring the button back for a selection that is still live and still on screen. The user would have to destroy and remake a selection that never went away. Both platforms keep the selection across scrolling (iOS its handles and callout, macOS its highlight), and small incidental scrolls are routine on touch.
Native draws nothing for a cross-block selection (canAddNote is false), so refreshing its rect is ~60 no-op bridge messages a second during a fling — each routed, decoded and folded into MainActor state to change no pixel. A cross-block drag is easy to make by accident, so this is not a rare path. Nothing is lost: eligibility depends on the selection's DOM endpoints and never on the scroll offset, so declined→available can only happen via a genuine selectionchange. Narrowed in the first review round (the original commit armed for both).
prism-scroll.js suppresses its position reports while a native scroll command is in flight. This listener deliberately does not: a TOC jump, a fragment link or a search reveal moves the selected text just as a finger does, and the affordance should follow it there too.
Keeps ONE definition of "where the selection is on screen", requires no new bridge fields, and reuses the existing zero-size rejection in isVisible. The alternative — sending the ancestor boxes to native and intersecting there — would duplicate CSS knowledge on the Swift side.
Added in round 2. Clamping alone left a band roughly 64 pt deep at the top of the view in which every selection drew the capsule at the same pinned y — the defect being fixed, in miniature. The clamp is kept only as a backstop for a degenerate viewport and for a selection taller than the viewport, and both remaining cases are documented as honest pins (the capsule still sits over text filling the screen).
It moves the text with neither an event nor a hook: applyTypography writes CSS variables whose reflow lands asynchronously in the style/layout pass, so the command handler returning is not the moment the text has moved. Repairing it needs prism-theme.js to notify after the variables have taken effect. Stated in the code comment, the agent note and the CHANGELOG rather than left implicit.
One frame at 60 Hz. No measurement is recorded for the choice; the comment argues affordability from the reads being recalc-free during a scroll rather than from a benchmark. Reasonable, but the number itself is a convention rather than a result.
(inferred — not stated by the author.)| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | prism-notes.js renderInlineNotes | The `if (!model) { return; }` guard sits AFTER `clearInlineNotes()` but BEFORE `scheduleSelectionRectRefresh()`. On that path the page has already removed the banner and every bubble — a real reflow — and then returns without refreshing the rect, which is exactly the hole the commit closes on the other branch of the same function. | Not reachable from production: `WebDocumentControllerFactory.noteStatePayloads` collapses a nil payload to the literal `"{}"` precisely so a prior push is cleared, and `{}` is truthy, so turning inline notes off DOES reach the refresh. Only a null or unparseable payload takes the early return. Left as-is (report-only); worth a one-line refresh in the guard next time the function is touched. |
| minor | WebDocumentView / WebSelectionOverlayGeometry | `halfWidth: 70` and `halfHeight: 18` are hard-coded estimates of the capsule's size. They were cosmetic when they only fed a clamp, but `flipsBelow` now derives its threshold from `halfHeight` (flip when rect.y < 44) and `point` derives the flipped anchor from it too. The capsule's label uses `.font(.callout.weight(.medium))`, which scales with Dynamic Type: at accessibility sizes the real capsule is roughly 65 pt tall, so halfHeight under-estimates by ~15 pt. Consequence: in the rect.y band between ~44 and ~74 the capsule does not flip, computes a y above the top edge, and the clamp pins it there with its top hanging off — a narrow re-appearance of the defect this PR removes, on the accessibility path. | Not fixed (report-only, and it is a production change). Suggested follow-up: measure the capsule once with `onGeometryChange` / a `GeometryReader` behind the button and feed the real half-extents into the geometry functions, which already take everything else as arguments. A Transit ticket is the right home for this. |
| minor | WebDocumentView.selectionOverlay | `Group { if visible { … } }` carries `.onChange(of: visible)`, `.onChange(of: flipped)` and `.onDisappear`. SwiftUI applies a modifier on a `Group` to each of the group's children, and when `visible` is false the group has no children — so the state-resetting work rides on `onDisappear` firing for the removed button rather than on the `onChange`. Both readings converge on correct behaviour here (either the onChange fires, or the onDisappear resets both flags), which is why nothing is broken, but the wiring depends on a subtle container semantic and is not covered by a test. | Not fixed (report-only). Attaching the modifiers to a view that always exists — e.g. `Color.clear.overlay { if visible { … } }` — would make the state machine independent of Group's modifier distribution. Worst case today is a 6 pt difference in the appear threshold, self-correcting the next time the affordance clears. |
| minor | WebDocumentView invalidation scope | `WebSelectionAffordanceState.mode` is reassigned on every accepted refresh — up to ~60 times a second while scrolling with a live selection. It is read from `selectionOverlay`, which is part of `WebDocumentView.body`, so each refresh invalidates the WHOLE body: the `WebView(controller.page)` representable is reconstructed and all five of its modifiers reapplied, once per refresh, concurrently with the WebKit scroll that triggered it. | Not fixed (report-only). Extracting `selectionOverlay` into its own small `View` struct taking `selectionAffordance` would confine the invalidation to the overlay and leave the WebView untouched. Cheap, contained, and worth doing before the next perf pass. |
| nit | prism-notes.js naming | `hasVisibleAffordance()` reads as a viewport test, but it is `lastCandidateState === "available"` — and it MUST NOT become a viewport test, because an off-screen selection has to keep reporting for the button to return when the reader scrolls back. The doc comment reinforced the misreading ("geometry ON SCREEN"). | Comment rewritten in commit 8ab8a77 to say what the guard is and to state explicitly that adding an on-screen check there would make the withdrawal permanent. The identifier itself is unchanged; `hasReportedAvailableCandidate` would be better and is a safe rename whenever the file is next touched. |
| nit | Transit T-1878 | The ticket's "Fix ready for review" comment is stale on two points that the later review-round commits changed: it says the refresh is armed for `available`/`declined` (it is now `available` only) and that `resize` was deliberately left out (it is now covered, along with the inline-notes and section-collapse reflows). | Not edited (report-only). Worth a short follow-up comment on the ticket at merge time so the record matches the shipped behaviour. |
Click to expand.
diff --git a/prism/Resources/WebRenderer/prism-notes.js b/prism/Resources/WebRenderer/prism-notes.jsindex f377316..a04fafd 100644--- a/prism/Resources/WebRenderer/prism-notes.js+++ b/prism/Resources/WebRenderer/prism-notes.js@@ -165,6 +165,14 @@ main.insertBefore(host2, main.firstChild); } }+ // This is a NATIVE-PUSH REFLOW: a top banner inserted before the first block+ // shifts the whole document down, and bubbles appended into sections push+ // everything after them — with no `scroll` and no `resize` to repair the+ // selection rect afterwards (T-1878). Toggling inline notes from the toolbar+ // needs no page interaction at all, so a live selection survives it and the+ // affordance would sit at the pre-reflow position until the reader happened to+ // scroll. Refresh-only as everywhere else: no live affordance, no work.+ scheduleSelectionRectRefresh(); } bridge.registerCommand("setInlineNotes", function (payload) {@@ -433,11 +441,59 @@ // Dedup so a stream of selectionchange events for an unchanged selection does not // spam the bridge. var lastCandidateKey = null;+ // The state last reported, so the scroll refresh below can tell "this page has an+ // affordance on screen" (refresh it) from "it does not" (leave it alone).+ var lastCandidateState = null; function clientRectFromDOMRect(rect) { return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; } + // Ancestors that CLIP their content, so the selection can be out of sight inside one+ // of them while still inside the window's viewport.+ var CLIPPING_OVERFLOW = /^(auto|scroll|hidden|clip)$/;++ // Intersect the selection rect with every clipping ancestor's box (T-1878).+ //+ // Native receives a viewport-relative rect and can only compare it against the+ // viewport, but document.css puts wide tables, code blocks and mermaid output in+ // their own `overflow-x: auto` containers (`.prism-table-wrap`, `pre`,+ // `.prism-mermaid-out`). Scroll a wide table sideways and the selected cell leaves+ // the wrapper's box while staying inside the viewport's x-range — so without this,+ // native would keep the button hovering over text the reader cannot see, which is+ // the reported defect on the other axis. Clipping here keeps ONE definition of+ // "where the selection is on screen" and needs no new native concepts: an empty+ // intersection comes out as a zero-sized rect, which `isVisible` already rejects.+ //+ // Cost is a short walk to `body` (this document nests blocks shallowly) with one+ // `getComputedStyle` + one `getBoundingClientRect` per ancestor. Both read clean+ // style/layout during a scroll (scrolling dirties neither), so they do not force a+ // recalc on the refresh path.+ function clipRectToAncestors(node, rect) {+ var element = node.nodeType === 1 ? node : node.parentElement;+ var left = rect.x;+ var top = rect.y;+ var right = rect.x + rect.width;+ var bottom = rect.y + rect.height;+ while (element && element !== document.body) {+ var style = window.getComputedStyle(element);+ if (CLIPPING_OVERFLOW.test(style.overflowX) || CLIPPING_OVERFLOW.test(style.overflowY)) {+ var box = element.getBoundingClientRect();+ left = Math.max(left, box.left);+ top = Math.max(top, box.top);+ right = Math.min(right, box.right);+ bottom = Math.min(bottom, box.bottom);+ }+ element = element.parentElement;+ }+ return {+ x: left,+ y: top,+ width: Math.max(0, right - left),+ height: Math.max(0, bottom - top),+ };+ }+ function postSelectionCandidate(fields) { // Build a stable dedup key from the meaningful fields (rect rounded to the // nearest pixel so sub-pixel jitter does not re-post).@@ -450,6 +506,7 @@ } if (key === lastCandidateKey) { return; } lastCandidateKey = key;+ lastCandidateState = fields.state; bridge.post("selectionCandidate", fields); } @@ -465,7 +522,9 @@ postSelectionCandidate({ state: "cleared" }); return; }- var rect = clientRectFromDOMRect(range.getBoundingClientRect());+ var rect = clipRectToAncestors(+ range.startContainer, clientRectFromDOMRect(range.getBoundingClientRect())+ ); if (resolved.crossBlock) { postSelectionCandidate({ state: "declined", rect: rect }); return;@@ -480,6 +539,131 @@ document.addEventListener("selectionchange", handleSelectionChange, false); + // ---- Scroll-driven rect refresh (T-1878) -----------------------------+ // The rect above is VIEWPORT-relative, and a scroll moves the selected text without+ // changing the selection — so no `selectionchange` fires and the native overlay used+ // to stay at the old screen position (and, because the native placement clamps into+ // the view, stick to an edge instead of leaving with the text).+ //+ // Design choice: CONTINUOUS REPOSITION, not clear-on-scroll. The alternative —+ // dismiss the affordance on the first scroll and make the user reselect — is simpler,+ // but a scroll never fires `selectionchange`, so nothing would ever bring the button+ // back for a selection that is still live and still on screen; the user would have to+ // destroy and remake their selection. The selection itself survives scrolling on both+ // platforms (iOS keeps its handles and callout, macOS keeps the highlight), and small+ // incidental scrolls are routine on touch, so dismissing on scroll would make the+ // affordance feel broken. Native decides visibility from the refreshed rect+ // (WebSelectionOverlayGeometry.isVisible): off-viewport hides the button, and it+ // returns by itself when the text scrolls back, because the refresh keeps posting.+ //+ // REFRESH-ONLY, never re-arm. This re-posts geometry for a candidate this page has+ // already reported and does nothing otherwise, so a scroll cannot resurrect an+ // affordance a native clear removed (the T-1852 navigation clear; native also drops+ // `selectionCandidate` while `!isReady`). It reads scroll events and owns no scroll+ // of its own (T-1918), and it is deliberately NOT suppressed during a programmatic+ // scroll the way prism-scroll.js's position reports are: a TOC jump moves the+ // selected text too, and the affordance should follow it there as well.+ //+ // THROTTLED AS A LEADING GUARD WITH A TRAILING FIRE, which is deliberately the+ // OPPOSITE of the debounces in prism-scroll.js and prism-search.js — do not "fix"+ // it into one. `if (timer) { return; }` lets the first event of a window open the+ // timer and drops the rest, and the tick reads the position when it FIRES, so it+ // always lands after the last event of the window: N events cost one refresh and+ // the button converges on the resting position after a fling. A debounce (restart+ // the timer on every event) would strand the button mid-fling for as long as the+ // finger keeps moving, and a leading-edge throttle would strand it wherever the+ // window's first event happened to be. A timer rather than requestAnimationFrame+ // for the same reason prism-scroll.js uses timers: rAF may never fire for an inert,+ // offscreen document (the live-test harness).+ //+ // CAPTURE PHASE, so a selection inside an element that scrolls on its own (a wide+ // table, a code block) refreshes too — `scroll` on an element does not bubble, so a+ // bubble-phase window listener would never hear it.+ //+ // Cost per fire is `closest()` + a TreeWalker over ONE `[data-prism-run]` element ++ // a getBoundingClientRect, i.e. O(the selected run) and independent of document+ // size (the run index is built once). On the SCROLL path the reads are also free of+ // recalc — scrolling dirties neither style nor layout, so they land on a clean tree —+ // which is what makes a 16 ms refresh affordable there. The reflow triggers below+ // (resize, inline notes, section collapse) do dirty layout by definition, so their+ // one fire per throttle window forces one synchronous recalc; that is the cost of+ // reading geometry after a reflow at all, and it is bounded at one per window+ // because the timer coalesces. Idle cost is one string comparison per event.+ //+ // `resize` shares the listener: it is the same viewport-relative rect going stale+ // from a different trigger (window resize, a split-view drag, a device rotation),+ // and its failure mode is WORSE than the scroll one — after a scroll leaves the+ // rect stale the very next scroll event repairs it, whereas after a rotation+ // nothing necessarily follows and the button stays stranded. The same guard, the+ // same throttle and the same converging fire apply unchanged (the last resize event+ // wins exactly as the last scroll event does).+ //+ // The OTHER reflows with no event of their own are native pushes, and those the+ // page can see: `setInlineNotes` (banner insertion / bubble injection) refreshes+ // from `renderInlineNotes`, and `setSectionState` (collapse / expand) refreshes+ // from the bridge's section-visibility hook registered below. Both are reachable+ // from the toolbar with no page interaction, so a live selection survives them —+ // the same "nothing necessarily follows to repair it" argument that put `resize`+ // on this listener.+ //+ // NOT covered, and the only case left: a typography / Dynamic Type change. It+ // reflows the text without firing `scroll` or `resize`, and unlike the two above it+ // has no hook to hang off — `applyTypography` writes CSS variables and the reflow+ // they cause lands asynchronously in the style/layout pass, so the command handler+ // returning is not the moment the text has moved. Repairing it needs new plumbing+ // (prism-theme.js would have to notify after the variables have taken effect), so+ // it is knowingly left out and the rect stays stale until the next scroll.++ var SELECTION_RECT_REFRESH_MS = 16;+ var selectionRectRefreshTimer = null;++ // Whether this page has already REPORTED an actionable candidate, i.e. whether there+ // is geometry worth re-posting. Deliberately NOT a viewport test: a selection scrolled+ // off screen still reports `available` (with an off-viewport rect, which native's+ // `isVisible` rejects), and it has to keep doing so — that is the only thing that+ // brings the button back when the reader scrolls to the text again. Adding an "is it+ // on screen" check here would make the withdrawal permanent.+ //+ // "available" ONLY, not "declined": native draws nothing for a declined+ // (cross-block) selection — `WebSelectionAffordanceState.canAddNote` is false — so+ // refreshing its rect is ~60 no-op bridge messages a second during a fling, each+ // one routed, decoded and folded into MainActor state to change no pixel. A+ // cross-block selection is an easy accidental drag, so this is not a rare path.+ // Nothing is lost by skipping it: eligibility is a function of the selection's DOM+ // endpoints, never of the scroll offset, so a declined selection cannot become+ // available by scrolling — only by the user changing the selection, which fires a+ // genuine `selectionchange` that reports a fresh rect anyway.+ function hasVisibleAffordance() {+ return lastCandidateState === "available";+ }++ function scheduleSelectionRectRefresh() {+ if (!hasVisibleAffordance()) { return; }+ if (selectionRectRefreshTimer) { return; }+ selectionRectRefreshTimer = setTimeout(function () {+ selectionRectRefreshTimer = null;+ // Re-check: the selection may have been cleared while this was pending.+ if (!hasVisibleAffordance()) { return; }+ // Re-resolving from the live selection (rather than re-posting a translated+ // rect) keeps ONE code path producing candidates, so the refresh cannot drift+ // from what a real selectionchange would report. A selection that vanished+ // meanwhile posts `cleared` — a clear, not an arm.+ handleSelectionChange();+ }, SELECTION_RECT_REFRESH_MS);+ }++ window.addEventListener("scroll", scheduleSelectionRectRefresh, { passive: true, capture: true });+ window.addEventListener("resize", scheduleSelectionRectRefresh, { passive: true });++ // Collapsing or expanding a heading (setSectionState) moves every block after it+ // with no event of its own. The bridge already publishes that moment for geometric+ // readers (T-1944 — prism-search.js re-windows here), so the selection rect joins+ // them rather than growing plumbing of its own. Collapsing the selection's OWN+ // section is handled by the same call: the hidden subtree reports a zero-sized+ // rect, which native's `isVisible` rejects, so the button withdraws instead of+ // hovering over text that is no longer rendered.+ bridge.onSectionVisibilityChanged(scheduleSelectionRectRefresh);+ // A fresh page has no selection, so announce that once at load (T-1852). This is // defence in depth behind the native clear in WebDocumentController: the overlay // is native state that outlives the page it describes, and the OUTGOING page's@@ -508,4 +692,12 @@ // Expose resolution for tests (bridge world only; not reachable from page world). bridge.resolveSelectionRange = resolveSelectionRange;++ // Let native re-derive the candidate synchronously after it changes the selection+ // (WebDocumentController.clearSelection). `removeAllRanges()` fires `selectionchange`+ // asynchronously, and until that lands `lastCandidateState` is still "available" — so+ // a scroll in that window would refresh, re-post `available`, and re-arm the button+ // the user just dismissed by tapping "Add note". Calling this straight after the+ // removal closes the window instead of relying on the rect-dedup to cover it.+ bridge.refreshSelectionCandidate = handleSelectionChange; })();
diff --git a/prism/Views/WebDocumentView.swift b/prism/Views/WebDocumentView.swiftindex c4df3cd..fc76a11 100644--- a/prism/Views/WebDocumentView.swift+++ b/prism/Views/WebDocumentView.swift@@ -28,6 +28,16 @@ struct WebDocumentView: View { /// shared create path on the message router. var onAddNote: ((_ blockID: String, _ range: InboundBridgeMessage.SourceRange) -> Void)? + /// Whether the affordance was showing on the last evaluation, so the visibility+ /// rule can be hysteretic and a selection jittering across the viewport edge does+ /// not blink the button on and off (T-1878).+ @State private var overlayIsVisible = false++ /// Whether the affordance was drawn BELOW the selection on the last evaluation, so+ /// the flip rule can be hysteretic too: without it, a selection jittering across the+ /// "no room above" boundary hops the capsule ~72pt back and forth (T-1878).+ @State private var overlayFlippedBelow = false+ var body: some View { webView .overlay(alignment: .topLeading) { selectionOverlay }@@ -55,14 +65,49 @@ struct WebDocumentView: View { /// The native selection affordance, positioned just above the selection rect. /// Only an "available" (single-block) selection shows the tappable "Add note" /// button; a "declined" (cross-block) selection shows nothing actionable (Req 12.4).+ ///+ /// The rect is viewport-relative and prism-notes.js re-posts it as the document+ /// scrolls (T-1878), so the button tracks the selected text — including the text's+ /// own scroll container, whose clip box the page has already intersected the rect+ /// with. Once the selection leaves the viewport the button is withdrawn entirely+ /// rather than clamped to an edge, and reappears when the text scrolls back. @ViewBuilder private var selectionOverlay: some View { if let selectionAffordance, selectionAffordance.canAddNote, let rect = selectionAffordance.rect, let pending = selectionAffordance.pendingNote { GeometryReader { proxy in- addNoteButton(pending: pending, in: proxy.size)- .position(overlayPoint(for: rect, in: proxy.size))+ // `overlayIsVisible` is the PREVIOUS decision, which is what makes the+ // show/hide thresholds asymmetric (see `isVisible`): appearing needs a+ // few points of the selection on screen, staying only needs any.+ let visible = WebSelectionOverlayGeometry.isVisible(+ rect, in: proxy.size, wasVisible: overlayIsVisible+ )+ // Likewise the PREVIOUS placement decision, feeding the flip's own+ // hysteresis band (see `flipsBelow`).+ let flipped = WebSelectionOverlayGeometry.flipsBelow(+ rect, wasFlippedBelow: overlayFlippedBelow+ )+ Group {+ if visible {+ addNoteButton(pending: pending, in: proxy.size)+ .position(WebSelectionOverlayGeometry.point(+ for: rect, in: proxy.size, flippedBelow: flipped+ ))+ }+ }+ .onChange(of: visible, initial: true) { _, isVisible in+ overlayIsVisible = isVisible+ }+ .onChange(of: flipped, initial: true) { _, isFlipped in+ overlayFlippedBelow = isFlipped+ }+ // A fresh affordance starts from "not shown" and unflipped, so it has to+ // clear both thresholds rather than inheriting the previous one's state.+ .onDisappear {+ overlayIsVisible = false+ overlayFlippedBelow = false+ } } // The overlay must not intercept selection gestures outside the button. .allowsHitTesting(true)@@ -93,18 +138,109 @@ struct WebDocumentView: View { .buttonBorderShape(.capsule) .accessibilityLabel(LocalizedStringKey("web.note.add")) }+}++/// Placement maths for the native selection "Add note" overlay, kept out of the view+/// so it is directly testable (T-1878).+///+/// CSS px == points and the WebView fills the overlay's frame, so the bridge's+/// viewport-relative selection rect maps directly to local points.+enum WebSelectionOverlayGeometry {+ /// Estimated half-extents of the capsule, so it stays fully on screen after clamping.+ private static let halfWidth: CGFloat = 70+ private static let halfHeight: CGFloat = 18+ private static let gap: CGFloat = 8++ /// Vertical overlap the selection must have with the viewport before the button+ /// APPEARS. Staying visible needs only a sliver, so the two thresholds form a small+ /// hysteresis band: a slow scroll that oscillates by a pixel across the edge cannot+ /// blink a stationary button on and off, because re-appearing costs more than+ /// staying (T-1878).+ private static let appearMargin: CGFloat = 6++ /// Whether the selection is on screen, given whether the button is showing already.+ ///+ /// `point(for:in:flippedBelow:)` CLAMPS into the view, which is right while the selection is+ /// visible (it keeps the capsule from hanging off an edge) and wrong the moment it+ /// is not: a selection scrolled above the fold would clamp to the top edge and the+ /// button would sit there advertising text the reader can no longer see, while+ /// still creating a note for it on tap (T-1878). Placement and visibility are kept+ /// in step: `point` flips the capsule BELOW the selection when there is no room+ /// above, so every rect this returns `true` for gets an honest anchor rather than+ /// one pinned to an edge, and the clamp is left only as a degenerate-viewport+ /// backstop.+ ///+ /// Vertical overlap is the hysteretic axis because that is the one the document+ /// scrolls. Horizontally any intersection counts: the page itself never scrolls+ /// sideways, and text that has scrolled out of a container of its own arrives+ /// already clipped to a zero-sized rect (prism-notes.js `clipRectToAncestors`),+ /// which the size guard below rejects outright.+ static func isVisible(+ _ rect: InboundBridgeMessage.ClientRect, in size: CGSize, wasVisible: Bool+ ) -> Bool {+ guard size.width > 0, size.height > 0 else { return false }+ // A degenerate rect anchors nothing: an element that became `display: none`, or+ // a selection clipped entirely out of its own scroll container.+ guard rect.width > 0, rect.height > 0 else { return false }+ guard CGFloat(rect.x) < size.width, CGFloat(rect.x + rect.width) > 0 else { return false }+ let margin = wasVisible ? 0 : appearMargin+ return CGFloat(rect.y) < size.height - margin+ && CGFloat(rect.y + rect.height) > margin+ }++ /// Extra room the selection must gain above it before the capsule flips back UP,+ /// having been placed below. Same shape and same reason as `appearMargin`: without+ /// it the flip is a hard threshold, and crossing it moves the capsule by+ /// `rect.height + 2 * (gap + halfHeight)` — ~72pt for a single line, a bigger jump+ /// than the blink the visibility band exists to prevent (T-1878).+ private static let flipMargin: CGFloat = 6++ /// Whether the capsule goes BELOW the selection, given where it is already.+ ///+ /// Below is for selections too close to the top of the view for the capsule to fit+ /// above them. Hysteretic on the same principle as `isVisible`: flipping down needs+ /// the room above to run out, flipping back up needs it to return with `flipMargin`+ /// to spare, so a scroll oscillating by a pixel across the boundary leaves the+ /// capsule where it is. The rule is monotone in `wasFlippedBelow` (the flipped test+ /// is strictly weaker), so feeding a decision back in settles in one step exactly as+ /// the visibility rule does — no view-state feedback can oscillate.+ ///+ /// Size-independent: only the room above the rect decides this. The clamp in+ /// `point(for:in:flippedBelow:)` is what knows about the viewport.+ static func flipsBelow(+ _ rect: InboundBridgeMessage.ClientRect, wasFlippedBelow: Bool+ ) -> Bool {+ let above = CGFloat(rect.y) - gap - halfHeight+ return above < halfHeight + (wasFlippedBelow ? flipMargin : 0)+ } - /// Positions the affordance just above the selection rect, clamped into the view.- /// CSS px == points and the WebView fills this view's frame, so the bridge's- /// viewport-relative rect maps directly to local points (`.position` is centre-based).- private func overlayPoint(for rect: InboundBridgeMessage.ClientRect, in size: CGSize) -> CGPoint {- // Estimated half-extents so the capsule stays fully on screen after clamping.- let halfWidth: CGFloat = 70- let halfHeight: CGFloat = 18- let gap: CGFloat = 8+ /// Positions the affordance just above the selection rect — or just below it when+ /// `flippedBelow` (`.position` is centre-based).+ ///+ /// The flip is what keeps placement aligned with `isVisible`. Clamping alone left a+ /// band roughly 64pt deep at the top of the view where the anchor was unreachable:+ /// every selection in it drew the button pinned at the same y, a smaller version of+ /// the defect this fix exists to remove. Flipping below covers that band with a+ /// real anchor.+ ///+ /// The clamp survives for the two cases the flip cannot answer, and in both it pins+ /// the capsule to an edge on purpose:+ /// - a viewport too small to hold the capsule at all (`size.height < 2 * halfHeight`),+ /// where every y is out of range and the `max(halfHeight, …)` keeps the range legal;+ /// - a selection TALLER than the viewport, which takes the below-branch (no room+ /// above) and computes a y past the bottom edge — e.g. a 800pt-tall rect at y 0 in+ /// a 800pt view. That pin is honest rather than misleading: the capsule still sits+ /// over the selected text, which fills the screen, so it is not the detached pin+ /// T-1878 removes. `resolveSelectionRange` makes it reachable without a pathological+ /// document — a drag down a tall table's cells stays inside one block.+ static func point(+ for rect: InboundBridgeMessage.ClientRect, in size: CGSize, flippedBelow: Bool+ ) -> CGPoint { // Centre horizontally over the selection's left edge area; sit above its top. let rawX = CGFloat(rect.x) + halfWidth- let rawY = CGFloat(rect.y) - gap - halfHeight+ let rawY = flippedBelow+ ? CGFloat(rect.y + rect.height) + gap + halfHeight+ : CGFloat(rect.y) - gap - halfHeight let clampedX = min(max(rawX, halfWidth), max(halfWidth, size.width - halfWidth)) let clampedY = min(max(rawY, halfHeight), max(halfHeight, size.height - halfHeight)) return CGPoint(x: clampedX, y: clampedY)
diff --git a/prism/ViewModels/WebDocumentController.swift b/prism/ViewModels/WebDocumentController.swiftindex e50b1c0..a6d664b 100644--- a/prism/ViewModels/WebDocumentController.swift+++ b/prism/ViewModels/WebDocumentController.swift@@ -540,12 +540,17 @@ final class WebDocumentController { /// fires, so the affordance does not immediately re-arm on the same selection). /// A direct page-world-independent DOM call; not a bridge command (no state to /// replay) and a no-op if the page is gone.+ ///+ /// It re-derives the candidate in the same call rather than waiting for the+ /// `selectionchange` that `removeAllRanges()` fires asynchronously: until that+ /// lands, prism-notes.js still believes an affordance is on screen, so the+ /// scroll-driven rect refresh (T-1878) could re-post `available` and re-arm the+ /// button the user just dismissed. func clearSelection() { Task { [page] in do { try await page.callJavaScript(- "var s = window.getSelection(); if (s) { s.removeAllRanges(); } return null;",- contentWorld: Self.bridgeWorld+ Self.clearSelectionScript, contentWorld: Self.bridgeWorld ) } catch { Self.logger.error("Clear selection failed: \(error.localizedDescription)")@@ -553,6 +558,19 @@ final class WebDocumentController { } } + /// The script `clearSelection` runs, hoisted to a constant so a test can execute the+ /// EXACT literal against a live page. `refreshSelectionCandidate` is a Swift string+ /// reaching for a JS property behind an existence guard, so a rename on either side+ /// degrades SILENTLY back to the race described above rather than throwing — the pair+ /// is pinned by running this script and observing the call (WebSelectionScrollTests).+ static let clearSelectionScript = """+ var s = window.getSelection();+ if (s) { s.removeAllRanges(); }+ var b = window.__prismBridge;+ if (b && b.refreshSelectionCandidate) { b.refreshSelectionCandidate(); }+ return null;+ """+ // MARK: - Load / reload / recovery /// Loads (or reloads) the document for a given parse revision. Resets the
diff --git a/prismTests/WebRendering/WebSelectionOverlayGeometryTests.swift b/prismTests/WebRendering/WebSelectionOverlayGeometryTests.swiftnew file mode 100644index 0000000..b622d26--- /dev/null+++ b/prismTests/WebRendering/WebSelectionOverlayGeometryTests.swift@@ -0,0 +1,317 @@+//+// WebSelectionOverlayGeometryTests.swift+// prismTests+//+// The native half of the T-1878 fix: the pure placement/visibility maths in+// `WebSelectionOverlayGeometry`, which decides whether the "Add note" capsule is drawn+// for a refreshed selection rect and where. Split out of WebSelectionScrollTests (which+// drives a live page) because these need no WebPage at all — and because both suites+// were heading for the type-body limit.+//+// Two rules here are HYSTERETIC, and both feed their own previous decision back in from+// view state: visibility (`wasVisible`) and the flip below the selection+// (`wasFlippedBelow`). Each therefore has to be idempotent, or the view's state feedback+// oscillates on its own — which is what the sweeps at the bottom of this file pin, at+// all three boundaries and through the composed decide-then-place pipeline the view runs.+//++import CoreGraphics+import Foundation+import Testing+@testable import prism++@MainActor+struct WebSelectionOverlayGeometryTests {++ private static let viewport = CGSize(width: 390, height: 800)++ private func rect(+ y: Double, height: Double = 20, x: Double = 20, width: Double = 80+ ) -> InboundBridgeMessage.ClientRect {+ InboundBridgeMessage.ClientRect(x: x, y: y, width: width, height: height)+ }++ // MARK: - Visibility++ @Test("The overlay hides while the selection rect is outside the viewport, and returns")+ func offViewportSelectionRectHidesOverlay() {+ let viewport = Self.viewport+ let onScreen = rect(y: 300)+ #expect(WebSelectionOverlayGeometry.isVisible(onScreen, in: viewport, wasVisible: false))++ // Scrolled far enough down that the selection is above the fold: the rect's+ // bottom edge is off the top. Clamping alone would pin the button to the top+ // edge of the view, detached from text the reader can no longer see (T-1878).+ #expect(WebSelectionOverlayGeometry.isVisible(rect(y: -120), in: viewport, wasVisible: true) == false)++ // Scrolled back up so the selection is below the fold.+ #expect(WebSelectionOverlayGeometry.isVisible(rect(y: 940), in: viewport, wasVisible: true) == false)++ // Partially visible still counts: the reader can see the text.+ #expect(WebSelectionOverlayGeometry.isVisible(rect(y: -8), in: viewport, wasVisible: false))++ // Scrolled sideways out of its own container: prism-notes.js clips the rect to+ // its scrolling ancestors, so text hidden inside a wide table or a code block+ // arrives as a zero-sized rect and must withdraw the button even though its+ // origin is inside the viewport. Same rule covers a `display: none` element's+ // degenerate 0,0,0,0 rect, which used to park the button at the top-left corner —+ // and, since the section-visibility refresh, a selection whose own section the+ // reader has just collapsed.+ let clippedOut = rect(y: 300, height: 0, x: 120, width: 0)+ #expect(WebSelectionOverlayGeometry.isVisible(clippedOut, in: viewport, wasVisible: true) == false)++ // A zero-size viewport (not laid out yet) shows nothing.+ #expect(WebSelectionOverlayGeometry.isVisible(onScreen, in: .zero, wasVisible: false) == false)+ }++ @Test("Visibility is hysteretic at the viewport edge, so a jittering scroll cannot blink it")+ func overlayVisibilityHasHysteresisAtTheEdge() {+ // The show and hide thresholds are deliberately different. With a single+ // threshold, a slow scroll oscillating by a pixel across it flips the button on+ // and off repeatedly while it is not even moving (T-1878).+ let viewport = Self.viewport+ // 2pt of the selection showing at the top: enough to keep a button that is+ // already up, not enough to raise one that is down.+ let sliverAtTop = rect(y: -18)+ #expect(WebSelectionOverlayGeometry.isVisible(sliverAtTop, in: viewport, wasVisible: true))+ #expect(WebSelectionOverlayGeometry.isVisible(sliverAtTop, in: viewport, wasVisible: false) == false)++ // Same band at the bottom edge.+ let sliverAtBottom = rect(y: 798)+ #expect(WebSelectionOverlayGeometry.isVisible(sliverAtBottom, in: viewport, wasVisible: true))+ #expect(WebSelectionOverlayGeometry.isVisible(sliverAtBottom, in: viewport, wasVisible: false) == false)++ // Past the band in either direction the two agree, so the decision is stable.+ #expect(WebSelectionOverlayGeometry.isVisible(rect(y: -10), in: viewport, wasVisible: true))+ #expect(WebSelectionOverlayGeometry.isVisible(rect(y: -10), in: viewport, wasVisible: false))+ #expect(WebSelectionOverlayGeometry.isVisible(rect(y: -20), in: viewport, wasVisible: true) == false)+ #expect(WebSelectionOverlayGeometry.isVisible(rect(y: -20), in: viewport, wasVisible: false) == false)+ }++ // MARK: - Placement++ @Test("Placement flips below the selection rather than pinning to the top edge")+ func overlayPointFlipsBelowNearTheTopEdge() {+ // Clamping alone left a band at the top of the view where every selection drew+ // the button at the same pinned y — a smaller version of the reported defect.+ // Below the band the capsule sits above the selection; inside it, below (T-1878).+ // NB: compare against explicit CGFloat values. `#expect(cgFloat == 1 + 2)` boxes+ // the integer expression into AnyHashable and can never match a CGFloat.+ let viewport = Self.viewport++ let roomy = rect(y: 300)+ #expect(WebSelectionOverlayGeometry.flipsBelow(roomy, wasFlippedBelow: false) == false)+ let above = WebSelectionOverlayGeometry.point(for: roomy, in: viewport, flippedBelow: false)+ #expect(above.y == CGFloat(300 - 8 - 18))++ // Two selections a few points apart near the top must NOT land on the same y.+ let high = rect(y: 10)+ let higher = rect(y: 2)+ #expect(WebSelectionOverlayGeometry.flipsBelow(high, wasFlippedBelow: false))+ #expect(WebSelectionOverlayGeometry.flipsBelow(higher, wasFlippedBelow: false))+ let highPoint = WebSelectionOverlayGeometry.point(for: high, in: viewport, flippedBelow: true)+ let higherPoint = WebSelectionOverlayGeometry.point(for: higher, in: viewport, flippedBelow: true)+ #expect(+ highPoint.y == CGFloat(10 + 20 + 8 + 18),+ "No room above: the capsule goes below the selection."+ )+ #expect(higherPoint.y == CGFloat(2 + 20 + 8 + 18))+ #expect(highPoint.y != higherPoint.y, "Placement must track the selection, not pin to an edge.")++ // A selection straddling the top edge anchors below its whole rect, not below the+ // visible remainder: the anchor stays attached to the text.+ let straddling = rect(y: -8)+ #expect(WebSelectionOverlayGeometry.flipsBelow(straddling, wasFlippedBelow: false))+ #expect(+ WebSelectionOverlayGeometry.point(for: straddling, in: viewport, flippedBelow: true).y+ == CGFloat(-8 + 20 + 8 + 18)+ )+ }++ @Test("The clamp pins to an edge only for a tiny viewport or a selection taller than it")+ func overlayPointClampsOnlyForTheTwoDegenerateCases() {+ // The clamp is NOT unreachable in the drawn path, which the comment on `point`+ // now says. Both reachable cases pin the capsule to an edge on purpose.+ let viewport = Self.viewport++ // (1) A viewport too small to hold the capsule at all: every y is out of range.+ let tiny = CGSize(width: 40, height: 20)+ let clamped = WebSelectionOverlayGeometry.point(for: rect(y: 300), in: tiny, flippedBelow: false)+ #expect(clamped.y == CGFloat(18))+ #expect(clamped.x == CGFloat(70))++ // (2) A selection TALLER than the viewport — a drag down a tall table's cells+ // stays inside one block, so `resolveSelectionRange` reports it as one available+ // candidate. It is drawn (isVisible says yes), it takes the below-branch (no room+ // above), and its below-y lands past the bottom edge, so the clamp pins it there.+ // That pin sits over selected text filling the screen, so it is not the detached+ // pin T-1878 removes.+ let tall = rect(y: 0, height: 800, width: 300)+ #expect(+ WebSelectionOverlayGeometry.isVisible(tall, in: viewport, wasVisible: false),+ "A selection taller than the viewport is drawn — the clamp is reachable from here."+ )+ #expect(WebSelectionOverlayGeometry.flipsBelow(tall, wasFlippedBelow: false))+ let pinned = WebSelectionOverlayGeometry.point(for: tall, in: viewport, flippedBelow: true)+ #expect(+ pinned.y == CGFloat(800 - 18),+ "The below-y (826) is past the bottom edge, so the capsule pins to it."+ )+ }++ @Test("The flip is hysteretic too, so a jitter across the boundary does not hop the capsule")+ func overlayFlipHasHysteresisAtTheBoundary() {+ // The flip moves the capsule by rect.height + 2*(gap + halfHeight) — ~72pt for a+ // single line — so a hard threshold at "no room above" would hop it further under+ // a 1px jitter than the blink the visibility band exists to prevent (T-1878).+ // Room runs out at rect.y = 44; the band gives it back at 50.+ var flipped = false++ // Well clear of the top: above the selection.+ flipped = WebSelectionOverlayGeometry.flipsBelow(rect(y: 300), wasFlippedBelow: flipped)+ #expect(flipped == false)++ // Crossing the boundary flips it below, once.+ flipped = WebSelectionOverlayGeometry.flipsBelow(rect(y: 43), wasFlippedBelow: flipped)+ #expect(flipped)++ // A jitter back across the same boundary does NOT flip it back — this is the+ // whole point of the band. Without it, 43 → 45 → 43 hops the capsule twice.+ flipped = WebSelectionOverlayGeometry.flipsBelow(rect(y: 45), wasFlippedBelow: flipped)+ #expect(flipped, "Inside the band, the capsule stays where it is.")+ flipped = WebSelectionOverlayGeometry.flipsBelow(rect(y: 49), wasFlippedBelow: flipped)+ #expect(flipped)++ // Real room above (past the band) moves it back.+ flipped = WebSelectionOverlayGeometry.flipsBelow(rect(y: 50), wasFlippedBelow: flipped)+ #expect(flipped == false)++ // And a selection that has never been flipped needs the room to actually run out,+ // not merely to be inside the band.+ #expect(WebSelectionOverlayGeometry.flipsBelow(rect(y: 45), wasFlippedBelow: false) == false)+ #expect(WebSelectionOverlayGeometry.flipsBelow(rect(y: 43), wasFlippedBelow: false))+ }++ // MARK: - Stability sweeps++ /// One evaluation of the view's decide-then-place pipeline, in the order+ /// `WebDocumentView.selectionOverlay` runs it.+ private struct Decision: Equatable {+ var visible: Bool+ var flippedBelow: Bool+ var point: CGPoint+ }++ private func decide(+ _ rect: InboundBridgeMessage.ClientRect, in size: CGSize,+ wasVisible: Bool, wasFlippedBelow: Bool+ ) -> Decision {+ let flipped = WebSelectionOverlayGeometry.flipsBelow(rect, wasFlippedBelow: wasFlippedBelow)+ return Decision(+ visible: WebSelectionOverlayGeometry.isVisible(rect, in: size, wasVisible: wasVisible),+ flippedBelow: flipped,+ point: WebSelectionOverlayGeometry.point(for: rect, in: size, flippedBelow: flipped)+ )+ }++ /// The three boundaries a scrolling selection crosses in an 800pt viewport: the top+ /// visibility edge, the flip boundary, and the bottom visibility edge.+ private static let boundarySweep: [Double] =+ Array(stride(from: -30.0, through: 30.0, by: 1.0))+ + Array(stride(from: 20.0, through: 70.0, by: 1.0))+ + Array(stride(from: 770.0, through: 830.0, by: 1.0))++ @Test("Both hysteretic rules settle in one step at every boundary")+ func overlayDecisionsAreIdempotentAtEveryBoundary() {+ // Feeding a decision back as its own previous state must never change it —+ // that is what stops the view's @State feedback (overlayIsVisible /+ // overlayFlippedBelow) from oscillating with no input change at all. It holds by+ // monotonicity for each rule (the "stay" test is strictly weaker than the+ // "change" test), and this sweep pins it at all three boundaries rather than only+ // the top one.+ let viewport = Self.viewport+ for y in Self.boundarySweep {+ for wasVisible in [true, false] {+ let once = WebSelectionOverlayGeometry.isVisible(+ rect(y: y), in: viewport, wasVisible: wasVisible+ )+ let twice = WebSelectionOverlayGeometry.isVisible(+ rect(y: y), in: viewport, wasVisible: once+ )+ #expect(once == twice, "Visibility must settle in one step (y=\(y), was=\(wasVisible)).")+ }+ for wasFlipped in [true, false] {+ let once = WebSelectionOverlayGeometry.flipsBelow(+ rect(y: y), wasFlippedBelow: wasFlipped+ )+ let twice = WebSelectionOverlayGeometry.flipsBelow(+ rect(y: y), wasFlippedBelow: once+ )+ #expect(once == twice, "The flip must settle in one step (y=\(y), was=\(wasFlipped)).")+ }+ }+ }++ @Test("The composed decide-then-place pipeline settles in one step, position included")+ func overlayPipelineIsIdempotentAtEveryBoundary() {+ // The rules are independent, but the view runs them together and the POSITION is+ // what the reader sees — so sweep the composition, not just the two predicates.+ let viewport = Self.viewport+ for y in Self.boundarySweep {+ for wasVisible in [true, false] {+ for wasFlipped in [true, false] {+ let first = decide(+ rect(y: y), in: viewport, wasVisible: wasVisible, wasFlippedBelow: wasFlipped+ )+ let second = decide(+ rect(y: y), in: viewport,+ wasVisible: first.visible, wasFlippedBelow: first.flippedBelow+ )+ #expect(+ first == second,+ "Overlay state must settle in one step (y=\(y), was=\(wasVisible)/\(wasFlipped))."+ )+ }+ }+ }+ }++ @Test("A jitter across any boundary costs one transition, not one per half-cycle")+ func overlayDoesNotFlapUnderAJitter() {+ // What a hysteresis band can and cannot do: it cannot stop the FIRST crossing+ // from moving something — that crossing is the reader genuinely reaching the+ // boundary — but it must stop the second and every one after. Oscillate a+ // selection half a point either side of each boundary, carry the state forward+ // the way the view does, and count how often the drawn state actually changes.+ // With a band that is at most once; with a hard threshold it is once per+ // half-cycle, which is the blink (visibility) and the ~72pt hop (placement) the+ // bands exist to prevent.+ //+ // The POSITION is deliberately not asserted to be constant: while visible and+ // unflipped it tracks the selection point for point, which is the whole feature.+ let viewport = Self.viewport+ for y in Self.boundarySweep {+ var visible = false+ var flipped = false+ var transitions = 0+ var previous: Decision?+ for step in 0..<12 {+ let sample = rect(y: y + (step.isMultiple(of: 2) ? -0.5 : 0.5))+ let decision = decide(+ sample, in: viewport, wasVisible: visible, wasFlippedBelow: flipped+ )+ visible = decision.visible+ flipped = decision.flippedBelow+ if let previous,+ previous.visible != decision.visible || previous.flippedBelow != decision.flippedBelow {+ transitions += 1+ }+ previous = decision+ }+ #expect(+ transitions <= 1,+ "A ±0.5pt oscillation at y=\(y) changed the overlay \(transitions) times."+ )+ }+ }+}
diff --git a/prismTests/WebRendering/WebSelectionScrollTests.swift b/prismTests/WebRendering/WebSelectionScrollTests.swiftnew file mode 100644index 0000000..fdddfa4--- /dev/null+++ b/prismTests/WebRendering/WebSelectionScrollTests.swift@@ -0,0 +1,516 @@+//+// WebSelectionScrollTests.swift+// prismTests+//+// The native "Add note" affordance is positioned from a VIEWPORT-relative rect that+// prism-notes.js posts with each `selectionCandidate` (T-1878). Scrolling moves the+// selected text without changing the selection, so no `selectionchange` fires — the+// rect went stale and the button stuck to a window edge instead of leaving with the+// text. These tests cover the page-side refresh against a live page: what triggers it+// (scroll, including inside a nested scroll container; resize; the two native pushes+// that reflow the document — inline notes and section collapse), what does not (a+// declined selection), and that it never arms an affordance of its own.+//+// The native placement/visibility maths lives in WebSelectionOverlayGeometryTests.+//++import Foundation+import Testing+import WebKit+@testable import prism++@MainActor+struct WebSelectionScrollTests {++ private static let notesScripts = ["prism-scroll", "prism-theme", "prism-media", "prism-notes"]++ private func domID(_ block: MarkdownBlock, index: Int = 0) -> String {+ "b-\(block.id)-\(index)"+ }++ // MARK: - Helpers+ //+ // The chosen design is CONTINUOUS REPOSITION — the page re-posts the live+ // selection's rect as the document scrolls, and the native overlay hides while+ // that rect is outside the viewport. Clear-on-scroll was rejected: a scroll never+ // fires `selectionchange`, so nothing would ever bring the button back for a+ // selection that is still live, and the selection itself survives scrolling on+ // both platforms.++ /// A tall document so the harness page has somewhere to scroll.+ private func tallBlocks(leading: MarkdownBlock, count: Int = 40) -> [MarkdownBlock] {+ var blocks: [MarkdownBlock] = [leading]+ for i in 0..<count {+ blocks.append(.paragraph(markdown: "Filler paragraph number \(i) with enough text to take vertical space."))+ }+ return blocks+ }++ /// Selects `length` UTF-16 units from `start` in mapped run `runIndex` of `domID`+ /// and fires the selectionchange the real gesture would.+ private func selectInBlock(+ _ harness: WebDocumentLiveHarness, domID: String, start: Int, length: Int, runIndex: Int = 0+ ) async throws {+ _ = try await harness.page.callJavaScript(+ """+ var run = document.querySelectorAll('#\(domID) [data-prism-run]')[\(runIndex)];+ var node = run.firstChild;+ var range = document.createRange();+ range.setStart(node, \(start));+ range.setEnd(node, \(start + length));+ var sel = window.getSelection();+ sel.removeAllRanges();+ sel.addRange(range);+ document.dispatchEvent(new Event('selectionchange'));+ return null;+ """,+ contentWorld: harness.bridgeWorld+ )+ }++ /// Selects across two blocks (a "declined" cross-block candidate) and fires the+ /// selectionchange the real gesture would.+ private func selectAcrossBlocks(+ _ harness: WebDocumentLiveHarness, from: String, to: String+ ) async throws {+ _ = try await harness.page.callJavaScript(+ """+ var a = document.querySelector('#\(from) [data-prism-run]').firstChild;+ var b = document.querySelector('#\(to) [data-prism-run]').firstChild;+ var range = document.createRange();+ range.setStart(a, 0);+ range.setEnd(b, 3);+ var sel = window.getSelection();+ sel.removeAllRanges();+ sel.addRange(range);+ document.dispatchEvent(new Event('selectionchange'));+ return null;+ """,+ contentWorld: harness.bridgeWorld+ )+ }++ private func rectField(_ message: [String: Any], _ key: String) -> Double? {+ ((message["rect"] as? [String: Any])?[key] as? NSNumber)?.doubleValue+ }++ private func rectY(_ message: [String: Any]) -> Double? { rectField(message, "y") }++ @Test("Scrolling re-posts the live selection's rect so the affordance follows the text")+ func selectionRectFollowsScroll() async throws {+ let para = MarkdownBlock.paragraph(markdown: "The quick brown fox jumps over the lazy dog")+ let harness = try await WebDocumentLiveHarness.make(+ blocks: tallBlocks(leading: para), featureScripts: Self.notesScripts+ )+ try await selectInBlock(harness, domID: domID(para), start: 4, length: 5)+ let armed = try #require(+ await harness.waitForSelectionCandidate(state: "available"),+ "A single-block selection must be reported as 'available'."+ )+ let armedY = try #require(rectY(armed), "The armed candidate must carry a rect.")++ // Scroll WITHOUT touching the selection — no selectionchange fires.+ let delta = 400.0+ _ = try await harness.page.callJavaScript(+ "window.scrollTo(0, \(Int(delta))); window.dispatchEvent(new Event('scroll')); return null;",+ contentWorld: harness.bridgeWorld+ )++ // The rect must move UP by roughly the scroll delta, and stay the same+ // selection: a refresh re-reports geometry, it does not re-resolve to a+ // different block or range.+ let found: [String: Any]? = try await harness.waitForMessage(+ type: "selectionCandidate",+ where: { message in+ guard message["state"] as? String == "available",+ let y = self.rectY(message) else { return false }+ return y < armedY - delta / 2+ }+ )+ let seen = harness.messages(type: "selectionCandidate")+ .map { ($0["state"] as? String ?? "?") + "@" + String(self.rectY($0) ?? .nan) }+ let moved = try #require(+ found,+ """+ Scrolling must re-post the live selection's rect (armed at y=\(armedY), \+ scrolled by \(delta)). Candidates seen: \(seen).+ """+ )+ #expect(moved["blockID"] as? String == domID(para))+ let range = moved["range"] as? [String: Any]+ #expect((range?["start"] as? NSNumber)?.intValue == 4)+ #expect((range?["length"] as? NSNumber)?.intValue == 5)+ }++ @Test("Scrolling with no selection never arms the affordance")+ func scrollWithoutSelectionDoesNotArm() async throws {+ // The refresh is REFRESH-ONLY: it re-posts geometry for a candidate the page has+ // already reported, and never invents one. A scroll after the affordance has been+ // cleared (including the clear a navigation performs natively, T-1852) must not+ // resurrect it.+ let para = MarkdownBlock.paragraph(markdown: "The quick brown fox")+ let harness = try await WebDocumentLiveHarness.make(+ blocks: tallBlocks(leading: para), featureScripts: Self.notesScripts+ )+ _ = try #require(await harness.waitForSelectionCandidate(state: "cleared"))+ _ = try await harness.page.callJavaScript(+ "window.scrollTo(0, 400); window.dispatchEvent(new Event('scroll')); return null;",+ contentWorld: harness.bridgeWorld+ )+ _ = try await harness.settledMessageCount(type: "selectionCandidate")+ let states = harness.messages(type: "selectionCandidate").compactMap { $0["state"] as? String }+ #expect(+ states == ["cleared"],+ "A scroll with no live selection must post nothing. Saw \(states)."+ )+ }++ @Test("A declined (cross-block) selection is not refreshed on scroll — nothing draws it")+ func declinedSelectionIsNotRefreshedOnScroll() async throws {+ // Native shows NOTHING for a declined candidate (`canAddNote` is false), so+ // refreshing its rect during a fling is ~60 bridge messages a second that change+ // no pixel. A cross-block selection is an easy accidental drag, so the refresh is+ // armed for "available" only. Nothing is lost: a declined selection cannot become+ // available by scrolling — only by the user changing the selection, which fires a+ // genuine selectionchange (T-1878).+ let first = MarkdownBlock.paragraph(markdown: "first block of the document")+ let second = MarkdownBlock.paragraph(markdown: "second block of the document")+ let harness = try await WebDocumentLiveHarness.make(+ blocks: [first] + tallBlocks(leading: second), featureScripts: Self.notesScripts+ )+ try await selectAcrossBlocks(harness, from: domID(first), to: domID(second))+ _ = try #require(+ await harness.waitForSelectionCandidate(state: "declined"),+ "A cross-block selection must be reported as 'declined'."+ )+ let baseline = try await harness.settledMessageCount(type: "selectionCandidate")++ _ = try await harness.page.callJavaScript(+ "window.scrollTo(0, 400); window.dispatchEvent(new Event('scroll')); return null;",+ contentWorld: harness.bridgeWorld+ )+ let after = try await harness.settledMessageCount(type: "selectionCandidate")+ let states = harness.messages(type: "selectionCandidate").compactMap { $0["state"] as? String }+ #expect(+ after == baseline,+ "Scrolling a declined selection must post nothing. Saw \(states)."+ )+ }++ /// A table whose cells are mapped runs, wrapped by the stylesheet in a+ /// `.prism-table-wrap` scroll container — the shape the CHANGELOG promises works.+ private func scrollableTable() -> MarkdownBlock {+ .table(+ headers: ["Name", "Role"],+ rows: [["Ada", "Engineer"], ["Bob", "Writer"]],+ alignments: [.leading, .leading]+ )+ }++ @Test("A scroll inside a nested scroll container refreshes the rect (capture phase)")+ func nestedScrollContainerRefreshesRect() async throws {+ // `scroll` does not bubble, so a bubble-phase window listener never hears a wide+ // table or a code block scrolling within itself. Dispatching the event AT the+ // container is the only way to tell the two registrations apart: with+ // `capture: false` no refresh happens at all (T-1878).+ let table = scrollableTable()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: tallBlocks(leading: table), featureScripts: Self.notesScripts+ )+ // Runs in emission order: Name, Role, Ada, Engineer, Bob, Writer.+ try await selectInBlock(harness, domID: domID(table), start: 0, length: 6, runIndex: 5)+ let armed = try #require(+ await harness.waitForSelectionCandidate(state: "available"),+ "A selection inside one table cell must be reported as 'available'."+ )+ let armedY = try #require(rectY(armed), "The armed candidate must carry a rect.")++ // Move the selected text WITHOUT scrolling the window or touching the selection,+ // then announce it with a non-bubbling `scroll` dispatched at the table's own+ // scroll container.+ let shift = 200.0+ _ = try await harness.page.callJavaScript(+ """+ document.getElementById('\(domID(table))').style.marginTop = '\(Int(shift))px';+ var wrap = document.querySelector('#\(domID(table)) .prism-table-wrap');+ wrap.dispatchEvent(new Event('scroll'));+ return null;+ """,+ contentWorld: harness.bridgeWorld+ )++ let moved = try #require(+ await harness.waitForMessage(type: "selectionCandidate", where: { message in+ guard message["state"] as? String == "available",+ let y = self.rectY(message) else { return false }+ return y > armedY + shift / 2+ }),+ """+ A scroll inside a nested container must refresh the rect (armed at \+ y=\(armedY), text moved by \(shift)). Candidates seen: \+ \(harness.messages(type: "selectionCandidate").map { self.rectY($0) ?? .nan }).+ """+ )+ #expect(moved["blockID"] as? String == domID(table))+ }++ @Test("Text scrolled out of its own container withdraws the overlay (clipped rect)")+ func selectionClippedOutOfItsScrollContainerHidesOverlay() async throws {+ // Native only knows the viewport, so a cell that has scrolled out of a wide+ // table's wrapper while staying inside the window's x-range would keep the button+ // hovering over text the reader cannot see — the reported defect on the other+ // axis. prism-notes.js intersects the rect with its clipping ancestors, and an+ // empty intersection arrives as a zero-sized rect that `isVisible` rejects.+ let table = scrollableTable()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: tallBlocks(leading: table), featureScripts: Self.notesScripts+ )+ try await selectInBlock(harness, domID: domID(table), start: 0, length: 6, runIndex: 5)+ let armed = try #require(await harness.waitForSelectionCandidate(state: "available"))+ #expect((rectField(armed, "width") ?? 0) > 0, "The armed rect must have real extent.")+ #expect((rectField(armed, "height") ?? 0) > 0)++ // Collapse the scroll container so the selected cell falls outside its box —+ // the same relationship a sideways scroll of a wide table produces.+ _ = try await harness.page.callJavaScript(+ """+ var wrap = document.querySelector('#\(domID(table)) .prism-table-wrap');+ wrap.style.height = '1px';+ wrap.style.overflow = 'hidden';+ window.dispatchEvent(new Event('scroll'));+ return null;+ """,+ contentWorld: harness.bridgeWorld+ )++ let clipped = try #require(+ await harness.waitForMessage(type: "selectionCandidate", where: { message in+ (self.rectField(message, "width") ?? 1) == 0+ || (self.rectField(message, "height") ?? 1) == 0+ }),+ """+ A selection clipped out of its own scroll container must post an empty rect. \+ Candidates seen: \(harness.messages(type: "selectionCandidate").map {+ "\($0["state"] as? String ?? "?")@\(self.rectField($0, "height") ?? .nan)"+ }).+ """+ )+ let rect = InboundBridgeMessage.ClientRect(+ x: rectField(clipped, "x") ?? 0, y: rectField(clipped, "y") ?? 0,+ width: rectField(clipped, "width") ?? 0, height: rectField(clipped, "height") ?? 0+ )+ #expect(+ WebSelectionOverlayGeometry.isVisible(+ rect, in: CGSize(width: 390, height: 800), wasVisible: true+ ) == false,+ "An empty clipped rect must withdraw the overlay."+ )+ }++ @Test("A window resize refreshes the rect, which nothing else would repair")+ func resizeRefreshesSelectionRect() async throws {+ // Resize staleness is worse than scroll staleness: after a scroll the very next+ // scroll event repairs the rect, whereas after a rotation or a split-view drag+ // nothing necessarily follows and the button stays stranded (T-1878).+ let para = MarkdownBlock.paragraph(markdown: "The quick brown fox jumps over the lazy dog")+ let harness = try await WebDocumentLiveHarness.make(+ blocks: tallBlocks(leading: para), featureScripts: Self.notesScripts+ )+ try await selectInBlock(harness, domID: domID(para), start: 4, length: 5)+ let armed = try #require(await harness.waitForSelectionCandidate(state: "available"))+ let armedY = try #require(rectY(armed))++ // Reflow the selected text the way a resize would, then fire only `resize` —+ // no scroll event at all, so the scroll listener cannot cover for it.+ let shift = 200.0+ _ = try await harness.page.callJavaScript(+ """+ document.getElementById('\(domID(para))').style.marginTop = '\(Int(shift))px';+ window.dispatchEvent(new Event('resize'));+ return null;+ """,+ contentWorld: harness.bridgeWorld+ )++ let moved = try #require(+ await harness.waitForMessage(type: "selectionCandidate", where: { message in+ guard message["state"] as? String == "available",+ let y = self.rectY(message) else { return false }+ return y > armedY + shift / 2+ }),+ """+ A resize must refresh the rect (armed at y=\(armedY), text moved by \(shift)). \+ Candidates seen: \(harness.messages(type: "selectionCandidate").map { self.rectY($0) ?? .nan }).+ """+ )+ #expect(moved["blockID"] as? String == domID(para))+ }++ // MARK: - Native-push reflows (no scroll, no resize)++ @Test("Showing inline notes reflows the page and refreshes the rect")+ func inlineNotesReflowRefreshesSelectionRect() async throws {+ // `setInlineNotes` inserts the banner before the first block, shifting the whole+ // document down, and appends bubbles into sections. It fires neither `scroll` nor+ // `resize`, and toggling notes from the toolbar needs no page interaction — so the+ // selection survives and nothing else would ever repair the rect (T-1878).+ let para = MarkdownBlock.paragraph(markdown: "The quick brown fox jumps over the lazy dog")+ let harness = try await WebDocumentLiveHarness.make(+ blocks: tallBlocks(leading: para), featureScripts: Self.notesScripts+ )+ try await selectInBlock(harness, domID: domID(para), start: 4, length: 5)+ let armed = try #require(await harness.waitForSelectionCandidate(state: "available"))+ let armedY = try #require(rectY(armed))++ let banner = "<div data-prism-chrome style=\\\"height:200px\\\">2 notes</div>"+ try await harness.send(.setInlineNotes(json: "{\"banner\":{\"html\":\"\(banner)\",\"placement\":\"top\"}}"))++ let moved = try #require(+ await harness.waitForMessage(type: "selectionCandidate", where: { message in+ guard message["state"] as? String == "available",+ let y = self.rectY(message) else { return false }+ return y > armedY + 100+ }),+ """+ The banner insertion must refresh the rect (armed at y=\(armedY)). Candidates \+ seen: \(harness.messages(type: "selectionCandidate").map { self.rectY($0) ?? .nan }).+ """+ )+ #expect(moved["blockID"] as? String == domID(para))+ }++ /// The harness scheme handler cannot serve the linked stylesheet, so the bundled+ /// document.css is injected directly — the section-collapse tests need the real+ /// cascade, since `[data-prism-section-hidden]` is what actually removes the blocks+ /// from layout (mirrors `WebHiddenSectionGuardTests.makeStyledHarness`).+ private static func makeStyledHarness(blocks: [MarkdownBlock]) async throws -> WebDocumentLiveHarness {+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks, featureScripts: notesScripts+ )+ 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+ }++ @Test("Collapsing a section above the selection refreshes the rect")+ func sectionCollapseRefreshesSelectionRect() async throws {+ // Collapsing a heading removes every block under it from layout, pulling+ // everything after it up the page — with no `scroll` and no `resize`. The bridge+ // already publishes that moment for geometric readers (T-1944), so the selection+ // rect rides the same hook rather than growing plumbing of its own.+ var blocks: [MarkdownBlock] = [.heading(level: 2, text: "Alpha")]+ for index in 0..<12 {+ blocks.append(.paragraph(markdown:+ "Filler paragraph \(index) inside the collapsible span, long enough to "+ + "take real vertical space when it is laid out."+ ))+ }+ let headingIndex = 0+ let target = MarkdownBlock.paragraph(markdown: "The quick brown fox jumps over the lazy dog")+ blocks.append(.heading(level: 2, text: "Beta"))+ blocks.append(target)+ // The DOM id's trailing integer is the per-content-hash OCCURRENCE (BlockDOMID),+ // not the block's index — `target` is unique, so it is 0. The composite section+ // id setSectionState matches on IS index-based (`{hash}-{sourceIndex}`).++ let harness = try await Self.makeStyledHarness(blocks: blocks)+ try await selectInBlock(+ harness, domID: domID(target), start: 4, length: 5+ )+ let armed = try #require(await harness.waitForSelectionCandidate(state: "available"))+ let armedY = try #require(rectY(armed))++ try await harness.send(.setSectionState(collapsedIDs: ["\(blocks[headingIndex].id)-\(headingIndex)"]))++ let moved = try #require(+ await harness.waitForMessage(type: "selectionCandidate", where: { message in+ guard message["state"] as? String == "available",+ let y = self.rectY(message) else { return false }+ return y < armedY - 100+ }),+ """+ Collapsing a section above the selection must refresh the rect (armed at \+ y=\(armedY)). Candidates seen: \+ \(harness.messages(type: "selectionCandidate").map { self.rectY($0) ?? .nan }).+ """+ )+ #expect(moved["blockID"] as? String == domID(target))+ }++ // MARK: - The clearSelection Swift↔JS surface++ @Test("The controller's clearSelection script calls the bridge hook it names")+ func clearSelectionScriptCallsTheBridgeHook() async throws {+ // `WebDocumentController.clearSelectionScript` is a Swift string literal reaching+ // for a JS property (`refreshSelectionCandidate`, assigned at the foot of+ // prism-notes.js) behind an existence guard — so a rename on EITHER side makes the+ // call a silent no-op and quietly restores the race it closes. Swap the real+ // function for a spy, run the exact literal the controller runs, and require the+ // spy to have been called (T-1878).+ let para = MarkdownBlock.paragraph(markdown: "The quick brown fox jumps over the lazy dog")+ let harness = try await WebDocumentLiveHarness.make(+ blocks: tallBlocks(leading: para), featureScripts: Self.notesScripts+ )+ _ = try #require(await harness.waitForSelectionCandidate(state: "cleared"))++ #expect(+ try await harness.evalBool(+ "return typeof window.__prismBridge.refreshSelectionCandidate === 'function';"+ ),+ "prism-notes.js must expose refreshSelectionCandidate on the bridge object."+ )++ _ = try await harness.page.callJavaScript(+ """+ window.__prismSpyCalled = false;+ window.__prismBridge.refreshSelectionCandidate = function () { window.__prismSpyCalled = true; };+ return null;+ """,+ contentWorld: harness.bridgeWorld+ )+ _ = try await harness.page.callJavaScript(+ WebDocumentController.clearSelectionScript, contentWorld: harness.bridgeWorld+ )+ #expect(+ try await harness.evalBool("return window.__prismSpyCalled === true;"),+ "clearSelectionScript must call bridge.refreshSelectionCandidate."+ )+ }++ @Test("clearSelection drops the affordance immediately, and a scroll does not bring it back")+ func clearSelectionDropsTheAffordanceSynchronously() async throws {+ // `removeAllRanges()` fires `selectionchange` asynchronously; until it lands the+ // page still believes an affordance is on screen. Re-deriving the candidate in the+ // same call posts `cleared` there and then, so the refresh guard is already down.+ let para = MarkdownBlock.paragraph(markdown: "The quick brown fox jumps over the lazy dog")+ let harness = try await WebDocumentLiveHarness.make(+ blocks: tallBlocks(leading: para), featureScripts: Self.notesScripts+ )+ try await selectInBlock(harness, domID: domID(para), start: 4, length: 5)+ _ = try #require(await harness.waitForSelectionCandidate(state: "available"))++ _ = try await harness.page.callJavaScript(+ WebDocumentController.clearSelectionScript, contentWorld: harness.bridgeWorld+ )+ _ = try await harness.page.callJavaScript(+ "window.scrollTo(0, 400); window.dispatchEvent(new Event('scroll')); return null;",+ contentWorld: harness.bridgeWorld+ )++ _ = try await harness.settledMessageCount(type: "selectionCandidate")+ let states = harness.messages(type: "selectionCandidate").compactMap { $0["state"] as? String }+ #expect(+ states.last == "cleared",+ "The affordance must stay cleared after a scroll. Saw \(states)."+ )+ }+}
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 3070fb9..82c6432 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - The **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.+- The **Add Note** button that appears when you select text now follows that text as you scroll (T-1878). Scrolling does not change your selection, so nothing ever told the button to move: it stayed at the screen position it first appeared at while the words it belonged to slid away, and once they had left the screen entirely it did not leave with them — it stuck to the edge of the window, still offering to add a note to text that was no longer in front of you. The button now tracks the selected text as the document moves, whether you scroll by hand or jump using the table of contents, a link, or a search result, and it steps aside while the selection is off screen, coming back when you scroll to it again. Text inside something that scrolls on its own, such as a wide table or a code block, is followed too, and the button steps aside when that text scrolls out of sight sideways within its own container as well. It also keeps up when the window changes size around it — resizing the window, dragging a split view, or rotating the device — and when the page moves under it without you touching it at all: showing or hiding your notes in the document, and collapsing or expanding a section, both shift the text the button belongs to, and none of those would have put it right afterwards. Collapsing the section your selection is inside takes the button away with it. Where the selection sits too close to the top of the screen for the button to fit above it, the button now appears just below it rather than resting against the edge, and it stays on whichever side it is on while you scroll gently across that point instead of hopping back and forth. Your selection is left alone throughout — the button going away means the text has scrolled out of sight, not that you have to select it again. One case is not covered: changing the reading font or text size reflows the document without scrolling it, so the button waits for your next scroll to catch up. - Opening a document that writes characters as HTML entities or backslash escapes inside emphasis, bold, or link text is no longer slow enough to matter (T-1966). Text written `*A*` shows an `A`, but the file spells it `A` — so while working out which part of the file each word on screen came from, which is what lets you select text and attach a note to it, the app searched the rest of the paragraph for an `A`, found none, and then searched the same stretch again for the next word, and again for the one after. A paragraph of 3,200 such words took 12.4 seconds to render; it now takes 57 milliseconds, and the cost grows in step with the length of the document rather than with its square. Nothing about the result changes — the same text, the same footnote badges, in the same order, with notes anchoring exactly where they did before, which was checked by rendering four thousand generated samples before and after and comparing every character and every anchor position. Because a document can be opened from a URL, a file written this way could previously have kept a device busy for a long time on someone else's behalf. The three examples originally reported for this fault had already stopped being slow through earlier fixes, and are now pinned by growth guards so they cannot come back. One deliberately-constructed shape is not covered: where every repetition writes a *different* word the file spells some other way, the cost still grows with the square of the document's length, as this fault did. That is tracked separately (T-2034). - Adding a note to text in a paragraph that also mentions a footnote reference inside an image, a link address, an image tooltip, or raw HTML now works, as long as that reference is written out plainly (T-1992). In a paragraph like `![[^1]](cat.png) choose [^1] after` — or the same with the reference written inside a link address such as `[link](http://example.com/[^1])`, inside an image's tooltip text such as ``, or inside raw HTML — the badge still appeared in the right place, but the app matched it to the reference-shaped text inside the image, address or tooltip rather than the real one. Selecting the words in between and choosing **Add Note** was then declined, or saved a note quoting the wrong text and pointing at the image or link syntax, which the note carried into relocation and inline-note export. Stepping search onto such a footnote could also mark the wrong badge. Text that merely looks like a reference in those positions is now accounted for, so the words either side of a badge map to what you actually selected. Two spellings are not covered yet and still behave as they did before: an image whose alt text mixes the reference with formatting, as in `![*a*[^1]](cat.png) choose [^1] after`, and a link address that writes a character as an HTML entity, as in `[link](x&/[^1]) choose [^1] after`. In both the app cannot line the text up with your document and deliberately leaves it alone, so a selection over the words before the badge is still declined — tracked under T-2033. This is separate from the earlier fix for selecting after a badge (T-1876); footnotes inside list items and table cells are still tracked separately. - Adding a note to text selected inside a table cell or a list item now quotes the text you actually selected (T-1941). Selecting a word in the second cell of a row, in any row after the first, or in any list item after the first quoted text from the start of the table or list instead — and saving stored a wrong source range, which the note then carried into relocation and inline-note export. Only the very first cell and the very first list item behaved correctly. The rendered document's text-to-source map now records every cell and every item at its real position within the block's text, so a note anchors where you put it. A few places where the map used to record an anchor that could only ever be wrong now record none at all: a nested list's items, a list nested inside a quote or another list item, a list inside a collapsible `<details>` section, the summary of a `<details>` nested inside another, and the rare quote the parser cannot break into parts. Selecting text in one of those and reaching for **Add note** now declines quietly instead of quoting text from elsewhere in the block — the block's own **+** button still adds a note, as does the **+** beside each item of a nested list. Anchoring a selection in those places is tracked separately (T-2032). This was the same fault as the footnote-selection fix below (T-1876) on a different path; every place in the renderer that draws part of a block must now state where that part sits, so the next one cannot repeat it.
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex ae5d42d..af79d9a 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -166,6 +166,7 @@ source rather than shapes seen today. - **iOS WKWebView selection**: long-press triggers native text selection (not `contextmenu`), and the native selection callout renders above all web content → use native SwiftUI overlays / visible tap targets, not in-page pills or long-press gestures. - **Note flow is native-as-truth**: JS posts `selectionCandidate` / `noteIndicatorTapped` / `inlineNoteTapped` / `blockContextRequested` / `linkActivated` → `WebDocumentMessageRouter` → `DocumentLayoutCoordinator` state → SwiftUI sheets/popovers. `NotesManager` owns the notes; the web view only renders + reports. In-page affordances that open native UI route via `linkActivated` + a `prism://…` URL (footnote, `document-note/add`, `image-access/grant`); the Swift route literals live in `PrismLinkRoute`. - **The native selection affordance is PAGE-scoped state living natively, so it must be cleared on the navigation path, not by a message** (T-1852). `WebSelectionAffordanceState` is written only from inbound `selectionCandidate`, so before the fix a reload left the "Add Note" button floating at the outgoing page's rect with its block id + range. The obvious fix — have the outgoing page post `cleared` — CANNOT work and looks like it should: `load` assigns the new `parseRevision` *before* navigating, so `BridgeMessageRouter`'s exact-generation match drops anything the old page says afterwards. The clear lives in `WebDocumentController.resetForNavigation()`, the one choke point `load` and `handleProcessTermination` share (so it covers re-parse reloads, the same-revision iOS folder-access retry, and WebContent recovery). It is deliberately NOT in `WebDocumentStateSnapshot`: that snapshot is *replayable* native truth, and a selection is the user pointing at one rendered page — replaying it after a crash recovery is the same bug wearing a different hat. A clear alone is not enough, because clearing does not stop the OUTGOING page from re-arming: on a same-revision reload (the iOS folder-access retry) the old page stays interactive and generation-matched for the whole load, so a selection made *during* the load was accepted and the stale overlay came straight back until the new page's scripts ran. `WebDocumentController.handle` therefore also drops `selectionCandidate` while `!isReady` — `resetForNavigation` lowers `isReady` synchronously and only the INCOMING page's `ready` raises it, so clear + gate close the window rather than narrowing it. Nothing legitimate is lost: `ready` is posted from a timer prism-bridge.js registers before prism-notes.js runs (script order in `WebDocumentControllerFactory.userScripts()`, all `.atDocumentEnd`, equal-delay timers fire in registration order), so it always precedes the fresh page's own `cleared`. Defence in depth: each fresh page posts that initial `cleared` from `prism-notes.js`, deferred one timer turn (same reason as bridge.js's `ready` — the handler channel is not live at injection) and guarded on `lastCandidateKey === null`, i.e. "seed the dedup key if nothing has reported yet" — unconditional, it wipes a candidate that resolved first. It passes the allowlist because it is the INCOMING page's post, stamped with the per-serve generation island `WebDocumentControllerFactory.emitHTML` injects from the controller's live values. Consequence for live tests: "the first `selectionCandidate`" is no longer "the candidate under test" — use `WebDocumentLiveHarness.waitForSelectionCandidate(state:)`; and `make(injectedScriptSource:)` injects a script between bridge.js and the feature scripts, the only deterministic way to reach the window before notes.js's deferred timer.+- **The selection affordance rect is viewport-relative, so it is a function of the selection AND the scroll offset** (T-1878). `prism-notes.js` used to compute it only inside the `selectionchange` listener, and a scroll fires no `selectionchange` — so the rect went stale and, because `WebSelectionOverlayGeometry.point` clamps into the view, the button stuck to a window edge instead of leaving with the text. The fix is CONTINUOUS REPOSITION: a throttled scroll listener in prism-notes.js re-runs `handleSelectionChange`, and the native overlay hides while the refreshed rect misses the viewport (`WebSelectionOverlayGeometry.isVisible`, extracted out of `WebDocumentView` to be testable). Clear-on-scroll was rejected and is the tempting wrong answer: nothing would ever re-arm the affordance, because a scroll fires no `selectionchange` — the user would have to destroy and remake a selection that never stopped being live. The listener is REFRESH-ONLY (it returns unless the page has already reported `available`, re-checked inside the timer), which is what keeps it compatible with T-1852 above: a scroll cannot resurrect what `resetForNavigation` cleared, and the `!isReady` gate still owns the navigation window. `available` ONLY, never `declined`: native draws nothing for a cross-block selection (`canAddNote` is false), so arming the refresh for it costs ~60 no-op bridge messages/sec during a fling — and nothing is lost, because eligibility is a function of the selection's DOM endpoints and never of the scroll offset, so declined→available always goes through a genuine `selectionchange`. It uses a timer, not rAF (rAF may never fire for the inert offscreen harness page), and the capture phase, so a selection inside a self-scrolling wide table or code block refreshes too. The throttle is a LEADING GUARD WITH A TRAILING FIRE — deliberately the opposite of the debounces in prism-scroll.js/prism-search.js: a debounce would strand the button mid-fling, a leading-edge throttle would strand it wherever the window's first event landed; reading the position at fire time is what makes it converge. It reads scroll events and owns no scroll (T-1918), and is deliberately NOT suppressed during a programmatic scroll — a TOC/search jump moves the selected text as well. `resize` shares the listener (same guard, same throttle) because its failure mode is WORSE than the scroll one: after a scroll the next scroll event repairs the rect, whereas after a rotation nothing necessarily follows. The same argument pulls in the two NATIVE-PUSH reflows that fire no DOM event at all and are reachable from the toolbar with no page interaction (so the selection survives them): `setInlineNotes` refreshes from the end of `renderInlineNotes` (the top banner is inserted before the first block and shifts the whole document), and `setSectionState` refreshes off `bridge.onSectionVisibilityChanged` — the hook T-1944 already added for prism-search.js, reused rather than duplicated. Collapsing the selection's OWN section needs no special case: the hidden subtree reports a zero rect, which `isVisible` already rejects. Still not covered, and fair: a typography / Dynamic Type reflow moves the text with no event of its own AND no hook to hang off — `applyTypography` writes CSS variables whose reflow lands asynchronously, so the command handler returning is not the moment the text has moved; repairing it needs prism-theme.js to notify after the variables have taken effect. Two halves make the reposition honest rather than merely mobile. (1) The posted rect is INTERSECTED with every clipping ancestor (`clipRectToAncestors`): native only knows the viewport, so without it a cell scrolled sideways out of a `.prism-table-wrap` still looked visible; an empty intersection arrives as a zero-sized rect, which `isVisible` rejects — that also kills the `display:none` 0,0,0,0 rect that used to park the button at the top-left corner. (2) `WebSelectionOverlayGeometry.point` FLIPS the capsule below the selection when there is no room above, because the clamp otherwise left a ~64pt band at the top of the view where every selection drew the button at the same pinned y — the reported defect in miniature — and BOTH decisions are hysteretic: `isVisible` (appearing needs ~6pt of vertical overlap, staying needs any) so a scroll jittering by a pixel across the edge cannot blink a stationary button, and `flipsBelow` (flipping down needs the room above to run out at rect.y 44, flipping back up needs it back with 6pt to spare) because the flip moves the capsule ~72pt and a hard threshold there would hop it further than the blink the first band prevents. Both states are the view's (`@State overlayIsVisible` / `overlayFlippedBelow`, fed back as the `wasVisible` / `wasFlippedBelow` arguments); both rules are monotone in that argument and therefore idempotent, so the feedback settles in one step — swept at all three boundaries, predicates and composed pipeline, in WebSelectionOverlayGeometryTests. The clamp survives and IS reachable in the drawn path, in two cases only: a viewport too small to hold the capsule, and a selection taller than the viewport (one drag down a tall table's cells), which pins to the bottom edge over text filling the screen — not the detached pin T-1878 removes. Finally, `WebDocumentController.clearSelection` calls `bridge.refreshSelectionCandidate()` in the same JS call as `removeAllRanges()`: `selectionchange` is async, so until it landed the page still believed an affordance was on screen and a scroll in that window re-armed the button the user had just dismissed by tapping it. That call is a Swift string literal reaching for a JS property behind an existence guard, so a rename on either side degrades SILENTLY — the literal is therefore hoisted to `WebDocumentController.clearSelectionScript` and pinned by a test that swaps the real function for a spy, runs the exact literal against a live page, and requires the spy to fire. - **`<button>` UA font-size trap (cost me 4 device rounds)**: a `<button>`'s default font-size is ~13.3px, NOT the content's 17px. Any `em` offset or `::before` chip size on a button-based affordance (`.prism-add-note`, `.prism-notes-toggle`) computes against 13.3px, so it silently mismatches sibling `<span>`s (e.g. the note dot at 17px). Fix: put `font-size: 1em` on the button so its em math matches the surrounding content. Symptom was the `+` never aligning with the note dot no matter the offset. - **Headless Chrome is a reliable CSS-geometry probe** when you can't see the device: `"/Applications/Google Chrome.app/.../Google Chrome" --headless=new --disable-gpu --dump-dom "file://probe.html"` runs the page's JS; have the JS write `getBoundingClientRect()` results into a `<pre id=out>` and read it from the dumped DOM. Gotchas: `print()`/console are swallowed (write to the DOM or `document.title`); `top` is `window.top` (read-only global) so don't `var top = …`; inline the real `document.css` into the probe. - **`make test-locales` runs the FULL unit suite ×4 locales** (en/en-AU/en-GB/en-US) — it is NOT a quick catalog check, and it wedges the test daemon on a contended machine (saw a 12-min hang). The catalog validation (`Tools/validate-localisation.py`) actually runs as a **build phase** during ANY build, so a clean build at zero warnings already validates the catalog — don't run test-locales just to check it.
git merge-tree --write-tree origin/main HEAD produces a tree with no conflict markers. The one file both sides changed substantively is prism-notes.js: PR #345 (T-1745) rewrote the body of renderInlineNotes to host bubbles per list item, and this branch appends scheduleSelectionRectRefresh() to the end of the same function. The merged file was inspected directly — the call still sits at the end of the rewritten function, after both the bubble loop and the banner insertion. CHANGELOG.md and docs/agent-notes/webview-rendering-status.md both take clean adjacent-line merges.
When the capsule flips below the selection it enters the region where iOS also likes to put its own edit menu (Copy / Look Up / Share) when there is no room above — which is the same condition that triggers the flip. Worth one look on a device with a selection in the first line or two of a document: if they overlap, the fix is a larger gap on the flipped branch, not a change to the flip rule.
prism-scroll.js registers two, prism-search.js one, and this branch adds a fourth (capture phase). Each is individually cheap and the idle cost here is a single string comparison, but the renderer now has four independent throttling/debouncing disciplines keyed off the same event. If a fifth ever appears, that is the moment to add a bridge.onScroll hook alongside the existing onSectionVisibilityChanged / onExplicitNavigation hooks rather than a fifth listener.
The trailing-fire throttle is argued from first principles and pinned by unit-level sweeps, but "the button converges on the resting position after a fling" is a claim about a 16 ms cadence competing with WebKit's own scroll, and no test measures it. One manual fling with a live selection on an iPhone is the cheap confirmation.