prism branch T-2059/bugfix-inline-note-focus-return commits 3 files 8 touched (2 docs, 5 prod, 1 test) lines +334 / -12 tests 28/28 pass (reproduced) lint 0 violations

Pre-push review #2: T-2059 inline-note focus return

Second review pass on PR #388. Both blockers from round one — the un-expiring pendingFocusKey and the suppressAddNote path that still dropped focus — are resolved and independently verified. What remains is one unproven production premise and two coverage/comment nits.

At a glance

  • Round-one blocker 1 (pending-key expiry) — fixed & verified. bridge.onExplicitNavigation clears pendingFocusKey, matching revealPending (prism-search.js) and reflowAnchor (prism-scroll.js). Traced all five fire sites: none is reachable from a note save.
  • Round-one blocker 2 (suppressAddNote drops focus) — fixed & verified. The +-to-dot handoff now routes through the shared focusOrDefer, falling back to the successor's own focus key because the + carries none. Covered by a new backgrounded live test.
  • The search gate does not stick. active:false reaches JS on every one of the five search-close paths; no early return on an empty query anywhere in the push chain.
  • Finding 1 (verify before closing the ticket): no automated test can produce the real window focus event — the tests dispatch a synthetic one and stub documentHasFocus. Nothing else in the repo listens for window focus/blur, and restoreBodyFocusIfIdle is inert on the rendered path. Manual check on macOS and iPad needed.
  • Finding 2 (minor): focusOrDefer's comment denies a second failure mode that the same commit introduced — a resolvable-but-unfocusable target now takes the defer branch.
  • Finding 3 (minor): the native→JS active wiring has no pin. Delete isActive: at the factory and the suite stays green — the exact failure class CLAUDE.md records for T-1943.
  • Verified locally: make lint 0 violations, make verify-test-isolation pass, make build-macos pass, WebNoteAccessibilityTests 28/28 pass in both test-plan configurations.
  • Known: the branch conflicts with main on CHANGELOG.md (content conflict, that file only). The merge queue resolves it.

Verdict

Ready to push

The two round-one blockers are genuinely fixed, and I re-derived both fixes from the source rather than taking the commit message's word for it. Search-active never sticks: encode emits active in the JSON root unconditionally, outside the states-building guard, and applyState assigns activeState before its empty-query bail — so closing search delivers {"active":false,"blocks":{},"query":""} down all five close paths, and both degenerate cases (malformed payload, encode fallback string) fail open. The note's own save cannot cancel its own restore: only five sites fire onExplicitNavigation, all downstream of a scroll that actually executes; noteNavigationTarget is written from exactly two note-row tap gestures, never from create/save/edit/reply/delete; and a save cannot bump parseRevision, so it cannot reach the load-path restore either.

The activeElement verification is exact for the elements it guards — there is no shadow DOM anywhere in the note chrome, and every focus target is a native <button> or <a>. The isolated/page-world boundary is untouched: searchIsActive is one more property on window.__prismBridge, which is injected only into WebDocumentController.bridgeWorld.

Push it. But do not close T-2059 on green tests alone — see finding 1: the window focus event that consumes the deferred key is asserted, never demonstrated, and nothing in the app hands focus back to the web view on its own.

Review findings

5 raised · 0 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism shows your markdown inside a web view, and notes you attach to the text are drawn as little buttons in the margin. If you use a keyboard to move around, you stand on one of those buttons; when a note is saved, Prism redraws all of that chrome, which destroys the button you were standing on. Prism already had machinery to remember which button you were on and put you back on it afterwards.

The bug: when you edit a note, a native panel slides over the document. While it is up, the document is not the thing you are typing into, and Prism deliberately refuses to grab your focus back — that would yank you out of the panel mid-sentence. But the refusal threw the return address away. So when the panel closed, there was nothing left to go back to, and the next Tab restarted from the top of the document.

What it does now

A refused restore is now remembered instead of discarded, in a single slot called pendingFocusKey. When the document genuinely becomes the thing you are typing into again, that remembered address is used and focus goes back where it was.

Key concepts

  • Focus — which single control on screen receives your keystrokes.
  • Deferral — "I cannot do this now, so I will hold on to it and do it when I can", instead of "I cannot do this now, so forget it".
  • Staleness — a remembered address can go out of date. If you jump somewhere else in the document in the meantime, the old address is thrown away on purpose, so it cannot drag you back later.

Architecture

Three collaborating pieces, all of them existing seams rather than new machinery:

  1. prism-notes.js gains a single-slot pendingFocusKey and a shared writer, focusOrDefer(element, key). Both refusal sites now go through it: restoreFocusKey (every setNoteIndicators/setInlineNotes push) and suppressAddNote's +-to-dot handoff. The slot is consumed by a window focus listener and invalidated by bridge.onExplicitNavigation.
  2. focusControl now returns document.activeElement === element rather than true-if-callable. Without this, a suppressed + (display:none, still in the DOM) would report a successful focus and the caller would decline to defer.
  3. A new active boolean rides the existing setSearchState payload from SearchCoordinator.isSearchActiveSearchStateFeeder.encodeprism-search.js, exposed as bridge.searchIsActive() so the focus listener can refuse while the reader is in search.

Patterns reused

Nothing here is invented. The deferred-intent-plus-invalidation shape is the third instance of a pattern already in the codebase: revealPending in prism-search.js and reflowAnchor in prism-scroll.js both hold a position intent and both clear it on bridge.onExplicitNavigation. Extending window.__prismBridge with a cross-script accessor is likewise established (documentHasFocus, resolveSelectionRange, searchActiveState, the perf* family). WebSearchStateKey gains a field so SwiftUI's .onChange re-pushes on a search open/close that changes no query.

Trade-offs

  • Single slot, last write wins rather than a queue. Correct for a "most recent explicit focus target" — an older return address should never outlive a newer one.
  • Default parameters (isActive: Bool = false) on encode and makeSearchStateJSON spare the existing call sites. A forgotten argument reads as "search not active", which fails open (restore allowed) rather than permanently refusing — the right direction to fail in.
  • An optional bridge seam (typeof bridge.searchIsActive === "function") so a script set without prism-search.js degrades rather than throwing.

Why the two round-one blockers are actually closed

Search-active cannot stick. The risk was an empty-query short circuit swallowing the active:false push. It does not exist: SearchStateFeeder.buildStates's guard !query.isEmpty only empties states; searchStateJSON still calls encode, and encode puts active in the root next to blocks, not inside it. WebDocumentControllerFactory.pushSearchState has no guard at all, and WebDocumentController.send never drops a setSearchState (only isTransientPageScroll commands are droppable) and folds it into latestSnapshot for crash replay. On the JS side applyState assigns activeState = state before the !state.query return, so a clearing push overwrites the stale active:true object rather than leaving it. All five native close paths write the flag: SearchCoordinator.clearSearch() (escape/Done, raw-source toggle, both compact sites) plus CompactDocumentLayout's sheet onDismiss, which flips isSearchActive without clearing the query — and that still moves the key, because isSearchActive is a synthesised Equatable member. The unmount case is covered by .task(id:) re-pushing current truth.

A note save cannot fire onExplicitNavigation. Five fire sites, all in JS: scrollToBlock/scrollToEdge/scrollByPage (prism-scroll.js, and scrollToBlock only when the target resolves and the scroll executes) and the two in-page search reveals. scrollToBlock has exactly two native origins — WebDocumentStateSynchronizer.scrollToTarget consuming pendingAnchorScroll (TOC/fragment) or noteNavigationTarget, and restoreScroll at the two load sites plus the crash replay. noteNavigationTarget is written at two sites, both .onTapGesture on a note row; never on create, save, edit, reply, resolve or delete. The save path terminates in NotesManager: it writes neither one-shot and calls no controller method. It also cannot reach the load-path restore — parseRevision is bumped in exactly one place, at the end of a parse, and note state is pushed live rather than by re-emitting the document. Corroboration the author does not cite: while any note sheet is up, coordinatorOwnsModalPresentation sets renderedScroll.suspended = true, so the keyboard/menu producers of scrollToEdge/scrollByPage are structurally gated off during the whole deferral window.

Edge cases examined

  • Shadow DOM / inner elements. The concern that document.activeElement reports a shadow host rather than the focused inner element does not apply: NoteHTMLBuilder and prism-notes.js use no attachShadow, no delegatesFocus, and no tabindex; every focus target is a native <button> or <a href>, for which activeElement identity is exact.
  • World boundary. Unchanged. bridge.searchIsActive is a property on window.__prismBridge, and prism-search.js and prism-notes.js are both injected into WebDocumentController.bridgeWorld; only mermaid and highlight go to WKContentWorld.page, and they cannot see the isolated-world object. Reading the seam lazily inside the listener also makes script injection order irrelevant.
  • Event capture. The listener registers on window with false. focus does not bubble, so only the window's own focus event reaches it — a true here would fire the handler on every control focus in the document. Load-bearing and uncommented.
  • Ordering. Invalidation and the focus event are mutually unordered only when a real navigation coincides with a refocus. Either order is benign: invalidate-first drops the address (intended), focus-first restores and then the page scrolls, and a programmatic scroll does not move focus.
  • Race with reload. pendingFocusKey is a per-page-load var, so a reparse or a WebContent-crash reload discards it with the JS context. The invalidation is moot in the crash-replay path for the same reason.

The residual

Every layer of the mechanism is verified except the one at the boundary: that a real WKWebView regaining first-responder status after a native sheet dismisses fires focus on window, with document.hasFocus() already true inside the handler. The off-screen live harness is never key, so it cannot produce that event — the tests dispatch new Event('focus') and stub the hasFocus gate. This is not a defect; it is an unproven premise, and it is the premise the entire fix rests on.

Important changes — detailed

prism-notes.js: pendingFocusKey + focusOrDefer, the shared refusal writer

prism/Resources/WebRenderer/prism-notes.js

Why it matters. The whole fix. A restore refused because the document is backgrounded is now held rather than dropped, and BOTH refusal sites write through one helper — which is what closes round one's second blocker, since suppressAddNote previously called focusControl directly and so had nothing to defer.

What to look at. prism-notes.js:101-152 (pendingFocusKey, focusOrDefer), :265-274 (suppressAddNote)

Takeaway. When a guard refuses an action, ask whether the refusal should discard the intent or park it. A guard that answers "not now" and a guard that answers "never" look identical at the call site and are almost never the same thing — the give-away here is that the original code path had no way to express the difference.
Rationale. Routing both sites through one writer is what makes the single-slot overwrite rule statable at all; two independent writers would each need their own copy of the replace-don't-queue reasoning. The fallback to the successor's OWN data-prism-focus-key exists because the add-note "+" carries none, so the handoff had literally nothing to defer before this.

focusControl now verifies document.activeElement === element

prism/Resources/WebRenderer/prism-notes.js

Why it matters. Without it, a suppressed "+" (display:none but still in the DOM) reports a successful focus, and focusOrDefer's caller declines to defer on a focus that never moved. Exact for these targets: the note chrome has no shadow DOM and every control is a native <button>/<a>.

What to look at. prism-notes.js:88-100

Takeaway. ".focus() did not throw" is not "focus moved". For a DOM API whose no-op and success are both silent, read the state back rather than trusting the call — the same discipline as checking a write landed rather than that the write call returned.
Rationale. The stated reason is the suppressed "+". See finding 2 — the check also introduces a second, undocumented refusal path.

bridge.onExplicitNavigation invalidates the pending key

prism/Resources/WebRenderer/prism-notes.js

Why it matters. Round one's first blocker: an un-expiring return address could fire on an unrelated later refocus and yank a keyboard user into note chrome they had navigated away from. I traced all five fire sites and every native producer — no note save, create, edit, reply or delete can reach any of them.

What to look at. prism-notes.js:117-129

Takeaway. Deferred position/focus intent needs an invalidation authority, not a timer. This codebase now has three instances of the same shape (revealPending, reflowAnchor, pendingFocusKey) all keyed on one signal — which is why the third one costs a single line.
Rationale. Precedence rule from T-1775 / search Decision 8: an explicit navigation is the newest position authority, so intent captured before it is dropped rather than replayed on top of it.

A new `active` boolean threaded from SearchCoordinator to prism-notes.js

prism/Services/SearchStateFeeder.swift

Why it matters. Four files move for one boolean, and the one thing that would make it dangerous — the flag sticking at true after search closes — does not happen: encode emits `active` in the JSON root outside the states guard, and applyState assigns activeState before its empty-query bail.

What to look at. SearchStateFeeder.swift:151-208; WebDocumentControllerFactory.swift:494-501; DocumentScrollContent.swift:470-500; prism-search.js:462-470

Takeaway. `active` deliberately does not mean `!query.isEmpty`: a just-opened, not-yet-typed-into search bar is active with no query. Deriving the boolean on the JS side from the query would have missed exactly that state — worth noticing whenever a flag looks derivable from data already in the payload.
Rationale. Mirrors native's restoreBodyFocusIfIdle, which refuses the analogous native focus handoff while session.search.isSearchActive. The rationale holds even though that particular function turns out to be inert on the rendered path (see Double-check).

WebSearchStateKey gains isSearchActive so the push actually re-fires

prism/Views/DocumentScrollContent.swift

Why it matters. The delivery half of the item above. Search opening or closing changes no query and no match count, so without this field the key compares equal and .onChange never fires — the flag would be computed and never sent. Correctly excluded from isNavigation, so it cannot manufacture a spurious reveal scroll.

What to look at. DocumentScrollContent.swift:470-500, 527-533

Takeaway. A value added to a payload is only half a feature; the other half is whatever decides the payload is worth re-sending. An equality key over a struct is the seam where a new field silently fails to be delivered.
Rationale. Documented in the diff: activating/deactivating search never scrolls by itself, so the field must move the key without moving the navigation classification.

Five new live tests over a real WebPage

prismTests/WebRendering/WebNoteAccessibilityTests.swift

Why it matters. +204 lines for a ~90-line fix, covering deferral-then-restore, explicit-navigation invalidation, the search refusal and its later delivery, single-slot overwrite, and the backgrounded suppressAddNote handoff. 28/28 pass; I re-ran them rather than taking the reported bundle on trust.

What to look at. WebNoteAccessibilityTests.swift:371-536, 633-670

Takeaway. Each test states in its own comment which production sequence it stands in for, and where it is a simulation rather than that sequence — pendingFocusKeyAssignmentOverwritesAnyPriorValue says outright that it drives the branch directly instead of reproducing the two-block push. That honesty is what lets a reviewer size the residual instead of guessing at it.
Rationale. The harness's WebPage is never key, so document.hasFocus() is false there for the same reason it is false behind a sheet — which is exactly what makes the harness able to prove the guard. The flip side is finding 1.

Key decisions

Single slot with last-write-wins, not a queue.

A later deferred key is a more recent explicit focus target than an earlier one; queueing would let a superseded return address fire on some future refocus it has nothing to do with. Stated in the pendingFocusKey comment and pinned by pendingFocusKeyAssignmentOverwritesAnyPriorValue.

Invalidate on explicit navigation rather than on a timer.

Round one asked for expiry. The answer is the codebase's existing precedence rule (T-1775 / search Decision 8) rather than a TTL: revealPending and reflowAnchor already clear on bridge.onExplicitNavigation, so the third deferred-intent slot uses the same authority. A TTL would have been a new concept and would still have had to answer "what if the user navigates inside the window".

Refuse-and-keep during search, rather than refuse-and-discard.

Search being open is a temporary condition, not a newer position authority, so the key is held. Consequence, accepted: while search stays open the key can outlive its context for a long time, and there is no re-fire when search closes — only the next window focus event delivers it.

The gate lives on a native-fed flag, not on the JS query string.

active is threaded from SearchCoordinator.isSearchActive instead of derived as !!query in prism-search.js, because a just-opened search bar is active with an empty query. Costs four files for one boolean; the alternative silently misses the state most likely to matter (the reader has just hit Cmd+F and typed nothing).

Verify focus by reading activeElement back.

focusControl returns document.activeElement === element. Chosen over pre-checking the element's visibility (which would need a layout read and would not cover every non-focusable case) and over trusting .focus() (which is a silent no-op on a display:none element).

Default the new parameters to false.

isActive: Bool = false on both encode and makeSearchStateJSON, so existing call sites need no change. The direction of the default is the safe one — a forgotten argument reads as "search not active" and allows the restore, rather than refusing it forever. The encode fallback string and a malformed JS payload fail the same way.

(inferred — not stated by the author.)
Stub the bridge seam in the search test rather than loading prism-search.js.

Stated in the test's own comment: the bridge function is prism-notes.js's whole contract with search state, and stubbing it follows the existing documentHasFocus/grantDocumentFocus pattern. The cost is finding 3 — nothing then pins the native→JS half of that contract.

Review findings

SeverityAreaFindingResolution
majorprism-notes.js:168-190 — production window `focus` eventThe entire fix rests on a real WKWebView firing `focus` on `window` (with `document.hasFocus()` already true inside the handler) when a native note sheet dismisses. Nothing demonstrates that it does. The new tests dispatch a synthetic `window.dispatchEvent(new Event('focus'))` and stub the gate via `grantDocumentFocus`, so BOTH halves of "the document regains focus" are simulated. Three things sharpen the concern rather than settle it: (a) no other script in prism/Resources/WebRenderer listens for window focus or blur, so there is no existing production evidence the event arrives; (b) `restoreBodyFocusIfIdle` — the native function this fix cites as its mirror — sets `bodyHasFocus = true`, and the ONLY `.focused($bodyHasFocus)` in the codebase is in RawSourceView.swift:145, so on the rendered WebKit path it has no target and is inert; nothing native hands first-responder status back to the web view on sheet dismissal; (c) the platforms differ — AppKit often restores a window's previous first responder after a sheet ends, UIKit generally does not. If the event does not arrive on a platform, the deferred key simply never fires and behaviour equals today's, so this is a risk of NOT fixing the bug rather than of breaking anything. It is the reason not to close T-2059 on a green suite.Not fixable in an automated test on this harness (an off-screen WebPage is never key). Before closing the ticket, manually verify on macOS and on iPad with a hardware keyboard: Tab onto a note dot, open the note, save, and confirm the next Tab continues from the dot rather than the top of the document. If iOS does not deliver it, the native fallback already exists in shape — give the rendered path its own `.focused` target so `restoreBodyFocusIfIdle` is not inert, or push an explicit "document refocused" signal over the bridge when `coordinatorOwnsModalPresentation` goes false.
minorprism-notes.js:139-147 — focusOrDefer comment vs. the new checkThe comment asserts: "focusControl only fails here because the document itself is not focused ... `element` is non-null and focusable, so the guard is the sole other branch." The `document.activeElement === element` check added in the SAME commit falsifies it — a resolvable but unfocusable target now also returns false. That is reachable: a note indicator inside a collapsed section is `display:none` (this is exactly the condition `bridge.isSectionRendered` exists for in prism-scroll.js), so `restoreFocusKey` can resolve it, `.focus()` no-ops, and the key is parked even though the document was focused all along. The parked key then survives until an explicit navigation or the next window `focus`, at which point it may land focus somewhere the reader has no reason to be. Low severity — narrow to reach, and the outcome is a stray focus ring, not a scroll (`preventScroll: true`) — but the comment currently tells the next reader the branch cannot happen.Editorial: either correct the comment to name both refusal modes, or distinguish them — have `focusControl` report why it failed, and defer only on the document-unfocused reason. The second is the behaviour the comment already describes.
minorNative→JS `active` wiring — no test pinThe search-gate test stubs `window.__prismBridge.searchIsActive` directly, and no Swift test asserts that `WebSearchStateKey` compares unequal on `isSearchActive` alone. Consequence: deleting `isActive:` at WebDocumentControllerFactory.swift:500, or `isSearchActive` from the key's init, or moving `activeState = state` below the `!state.query` return in applyState, leaves the entire suite green while the round-one blocker becomes real again. This is precisely the failure class CLAUDE.md records for T-1943 ("a direct-invocation test cannot see missing wiring"), where the answer was a live pin over a real WebPage.Two cheap pins close it: (1) a unit test that `WebSearchStateKey(..., isSearchActive: true) != (..., isSearchActive: false)` and that `isNavigation` is false between them; (2) a live test that loads prism-search.js and sends a real `.setSearchState(json: SearchStateFeeder.encode(query: "", states: [], isActive: false))` after one with `isActive: true`, asserting `bridge.searchIsActive()` flipped — driving the seam instead of overwriting it.
nitprism-search.js:462-470 vs :501`bridge.searchActiveState()` already exists (test seam, returns the same `activeState` object). `searchIsActive()` re-reaches for the variable rather than deriving from the existing accessor. Two accessors over one piece of state can drift.Optional: derive one from the other — or leave it; the direct read is marginally clearer and the file is small. Not worth a commit on its own.
nitprism-notes.js:183 — listener capture flagThe `false` third argument is load-bearing and uncommented: `focus` does not bubble, but it does propagate in the capture phase, so a `true` here would run the handler on every control focus in the document — consuming the pending key at the first keystroke.A half-line comment would stop a future tidy-up from "simplifying" the explicit `false` away.

Per-file diffs

Click to expand.

prism/Resources/WebRenderer/prism-notes.js Modified +89 / -4
diff --git a/prism/Resources/WebRenderer/prism-notes.js b/prism/Resources/WebRenderer/prism-notes.jsindex 9c730a29..4bfef396 100644--- a/prism/Resources/WebRenderer/prism-notes.js+++ b/prism/Resources/WebRenderer/prism-notes.js@@ -92,7 +92,63 @@         // preventScroll: the control is being restored to where the user already was, so         // the default "bring it into view" would only be able to move them off it.         element.focus({ preventScroll: true });-        return true;+        // Report whether focus actually MOVED, not merely whether .focus() was callable:+        // a suppressed add-note "+" is display:none but stays in the DOM (T-2059), so+        // .focus() on it is a no-op that must not be read as success by a caller deciding+        // whether to defer.+        return document.activeElement === element;+    }++    // The focus key a restore could not apply because the document was not focused (a+    // native note sheet/popover was up) at the time. Consumed the moment the document+    // actually regains focus (T-2059) — see the `window` `focus` listener below.+    // Assigning a new value always REPLACES whatever was pending: a later deferred key+    // is a more recent explicit focus target than an earlier one, so the earlier return+    // address must not outlive it and fire on some future refocus it has nothing to do+    // with — the only sound behaviour for a single-slot "most recent target" is to+    // replace, never to queue.+    //+    // Two call sites can set this, both through `focusOrDefer` and both gated by+    // `focusControl`'s own "document unfocused" condition: `restoreFocusKey`, on every+    // setNoteIndicators/setInlineNotes push, and `suppressAddNote`'s "+"-to-dot handoff,+    // which fires on the same pushes whenever the block just gained its first note. A+    // rebuild that both adds a note to one block AND lands a keyboard user's return+    // address on a DIFFERENT block's control in the same push is exactly the case this+    // slot exists for — not merely a defensive guard against an unreachable sequence.+    //+    // Invalidated by any explicit navigation (T-2059) — see `bridge.onExplicitNavigation`+    // below — the same way `revealPending` (prism-search.js) and `reflowAnchor`+    // (prism-scroll.js) invalidate their own deferred position intents: a TOC entry, a+    // fragment, a note-list jump, or the stored-position restore is a newer position+    // authority than a stale return address from before it. A note's own bridge+    // messages (indicator/bubble tap, block-context "+") never scroll — they only open+    // native popover/sheet state — so this invalidation cannot cancel the restore this+    // slot exists to deliver.+    var pendingFocusKey = null;++    bridge.onExplicitNavigation(function () { pendingFocusKey = null; });++    // Applies `focusControl` to `element`; on refusal, defers a restore by the+    // element's OWN focus key (falling back to `key`, the key that resolved to+    // `element` in the first place, for a resolved element with none of its own — the+    // add-note "+" `indicatorSuccessor` can return). Re-resolving by key rather than+    // caching `element` means the retry survives further rebuilds while still+    // backgrounded, exactly as `restoreFocusKey` already relied on before this helper+    // existed.+    function focusOrDefer(element, key) {+        if (!element) { return; }+        if (focusControl(element)) {+            // An explicit focus target was just established: any older deferred key is+            // now definitely stale (T-2059).+            pendingFocusKey = null;+            return;+        }+        // focusControl only fails here because the document itself is not focused (a+        // native sheet/popover is above it) — `element` is non-null and focusable, so+        // the guard is the sole other branch. Do not discard the return address.+        var ownKey = element.getAttribute && element.getAttribute("data-prism-focus-key");+        var deferredKey = ownKey || key;+        if (deferredKey) { pendingFocusKey = deferredKey; }     }      function restoreFocusKey(key) {@@ -105,9 +161,33 @@         // the user was standing on is removed while the "+" it had suppressed comes back.         // That is the suppression handoff in reverse, on the more common path, so hand         // focus to the successor rather than letting it fall to <body> (T-1725).-        focusControl(target || indicatorSuccessor(key));+        var resolved = target || indicatorSuccessor(key);+        if (!resolved) { return; }+        focusOrDefer(resolved, key);     } +    // The WebView regaining first-responder status fires a `focus` event on `window`+    // (mirroring `document.hasFocus()`, which `focusControl` reads). That is the exact+    // moment a deferred restore becomes possible, so this is where a pending key is+    // consumed — never polled, never retried on a timer.+    //+    // Refuses while native search is active (T-2059), mirroring `restoreBodyFocusIfIdle`+    // (RegularDocumentLayout.swift), which refuses the analogous native focus handoff+    // for the same reason: pulling a keyboard user into a note control while they are+    // using search is exactly the unwanted steal this whole deferral exists to avoid.+    // The key is left pending rather than discarded — a later `focus` event, once+    // search is no longer active, still owes the reader their return address.+    // `bridge.searchIsActive` is optional (a harness/script-set combination that omits+    // prism-search.js has none), so a missing seam degrades to "not active" rather than+    // throwing.+    window.addEventListener("focus", function () {+        if (!pendingFocusKey) { return; }+        if (typeof bridge.searchIsActive === "function" && bridge.searchIsActive()) { return; }+        var key = pendingFocusKey;+        pendingFocusKey = null;+        restoreFocusKey(key);+    }, false);+     // A dot is identified by the ANCHOR it opens, not just by its block: `subID` is a     // required component because a list draws one dot per noted item (T-1745), and on a     // key without it every one of them would look identical to `restoreFocusKey` — whose@@ -183,10 +263,15 @@         if (!add) { return; }         // Suppression is display:none, which drops focus to <body> if the user happened to         // be standing on this "+". The dot replacing it is its successor, so hand focus on-        // rather than losing it (T-1725).+        // rather than losing it (T-1725). Routed through `focusOrDefer`, not a bare+        // `focusControl` call, so the more common add-a-note sequence — stand on the "+",+        // tap it, save from the native sheet — defers rather than silently dropping focus+        // when the document is still backgrounded at the moment the note lands (T-2059):+        // the "+" itself carries no focus key (`makeAddNoteControl`), so without this the+        // handoff had nothing to defer and focus fell to <body> forever.         var hadFocus = document.activeElement === add;         add.setAttribute("data-prism-suppressed", "");-        if (hadFocus) { focusControl(successor); }+        if (hadFocus) { focusOrDefer(successor, null); }     }      // Whether `section` has list items whose own gutter chrome ("+" / dot) is pulled out to
prism/Resources/WebRenderer/prism-search.js Modified +11 / -0
diff --git a/prism/Resources/WebRenderer/prism-search.js b/prism/Resources/WebRenderer/prism-search.jsindex 8fca3ff9..15a2bdc6 100644--- a/prism/Resources/WebRenderer/prism-search.js+++ b/prism/Resources/WebRenderer/prism-search.js@@ -459,6 +459,17 @@         return true;     }); +    // Exposed so prism-notes.js can decline to steal focus into note chrome while the+    // reader is using search (T-2059), mirroring native's `restoreBodyFocusIfIdle`,+    // which refuses the analogous native focus handoff while+    // `session.search.isSearchActive`. `state.active` travels inside the same JSON+    // payload as `query`/`blocks` (SearchStateFeeder.encode), so it reflects native's+    // flag even on a push whose query is empty (a just-opened, not-yet-typed-into+    // search bar) — reading `!!query` here instead would miss exactly that case.+    bridge.searchIsActive = function () {+        return !!(activeState && activeState.active);+    };+     // ---- Re-window on scroll (Req 6.2 windowing) -------------------------     // Only the all/current text highlights need re-windowing; badges are sparse and     // marked once. Debounced so a fast scroll does not thrash the highlight registry.
prism/Services/SearchStateFeeder.swift Modified +15 / -5
diff --git a/prism/Services/SearchStateFeeder.swift b/prism/Services/SearchStateFeeder.swiftindex c7f14ba1..351620fb 100644--- a/prism/Services/SearchStateFeeder.swift+++ b/prism/Services/SearchStateFeeder.swift@@ -151,8 +151,17 @@ enum SearchStateFeeder {      /// Serialises the per-block states into the JSON string carried by     /// `OutboundBridgeCommand.setSearchState`. The shape mirrors what prism-search.js-    /// parses: `{ query, blocks: { domID: { textMatchCount, matchedFootnoteIds, current } } }`.-    static func encode(query: String, states: [BlockSearchState]) -> String {+    /// parses: `{ query, blocks: { domID: { textMatchCount, matchedFootnoteIds, current } },+    /// active }`.+    ///+    /// `isActive` is whether native's search UI is currently up (`SearchCoordinator+    /// .isSearchActive`), independent of whether `query` is non-empty (a just-opened+    /// search bar is active with no query yet). Carried in the payload so prism-notes.js+    /// can decline to steal focus into a note control while the reader is using search —+    /// mirroring native's `restoreBodyFocusIfIdle`, which refuses the analogous native+    /// handoff for the same reason (T-2059). Defaulted so the many existing call sites+    /// that do not care about this need no change.+    static func encode(query: String, states: [BlockSearchState], isActive: Bool = false) -> String {         var blocksObject: [String: Any] = [:]         for state in states {             var entry: [String: Any] = [@@ -169,7 +178,7 @@ enum SearchStateFeeder {             }             blocksObject[state.domID] = entry         }-        let root: [String: Any] = ["query": query, "blocks": blocksObject]+        let root: [String: Any] = ["query": query, "blocks": blocksObject, "active": isActive]         guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.sortedKeys]),               let json = String(data: data, encoding: .utf8) else {             return "{\"query\":\"\",\"blocks\":{}}"@@ -184,7 +193,8 @@ enum SearchStateFeeder {         matchCountsPerBlock: [Int],         currentGlobalMatchIndex: Int?,         context: SearchContext,-        badgeSourceStarts: (String) -> [Int]? = { _ in nil }+        badgeSourceStarts: (String) -> [Int]? = { _ in nil },+        isActive: Bool = false     ) -> String {         let states = buildStates(             query: query,@@ -194,7 +204,7 @@ enum SearchStateFeeder {             context: context,             badgeSourceStarts: badgeSourceStarts         )-        return encode(query: query, states: states)+        return encode(query: query, states: states, isActive: isActive)     }      // MARK: - Helpers
prism/ViewModels/WebDocumentControllerFactory.swift Modified +4 / -1
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex 174bac8d..0cd31f75 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -494,7 +494,10 @@ enum WebDocumentControllerFactory {             // inline renderer on the MainActor. Pushes between a reparse and the new             // revision's precompute storing miss deterministically and fall back to a             // bounded one-block render inside the feeder (T-1853 review round 3).-            badgeSourceStarts: { session.cachedBadgeSourceStarts(for: $0) }+            badgeSourceStarts: { session.cachedBadgeSourceStarts(for: $0) },+            // Carried so prism-notes.js can decline to steal focus into note chrome+            // while the reader is using search (T-2059) — see SearchStateFeeder.encode.+            isActive: search.isSearchActive         )     } 
prism/Views/DocumentScrollContent.swift Modified +9 / -1
diff --git a/prism/Views/DocumentScrollContent.swift b/prism/Views/DocumentScrollContent.swiftindex 59931ffb..0f6c072f 100644--- a/prism/Views/DocumentScrollContent.swift+++ b/prism/Views/DocumentScrollContent.swift@@ -470,6 +470,11 @@ struct WebSearchStateKey: Equatable {     /// never fire, and the match could not be re-revealed after the reader     /// scrolled away.     let navigationNonce: UInt64+    /// Whether native's search UI is up (T-2059), independent of `query` — a just-opened+    /// search bar toggles this with no query yet, and that toggle must still re-push+    /// `setSearchState` so prism-notes.js's search-active gate sees it. NOT read by+    /// `isNavigation` below: activating/deactivating search never scrolls by itself.+    let isSearchActive: Bool      @MainActor     init(session: DocumentSession) {@@ -478,6 +483,7 @@ struct WebSearchStateKey: Equatable {         currentIndex = session.search.currentGlobalMatchIndex         parseRevision = session.parseRevision         navigationNonce = session.search.navigationNonce+        isSearchActive = session.search.isSearchActive     }      /// Test seam: build a key from raw values so the navigation classification@@ -487,13 +493,15 @@ struct WebSearchStateKey: Equatable {         matchCounts: [Int],         currentIndex: Int?,         parseRevision: UInt64,-        navigationNonce: UInt64 = 0+        navigationNonce: UInt64 = 0,+        isSearchActive: Bool = false     ) {         self.query = query         self.matchCounts = matchCounts         self.currentIndex = currentIndex         self.parseRevision = parseRevision         self.navigationNonce = navigationNonce+        self.isSearchActive = isSearchActive     }      /// Whether the change from `old` to `new` is a USER search navigation — the
prismTests/WebRendering/WebNoteAccessibilityTests.swift Tests +204 / -0
diff --git a/prismTests/WebRendering/WebNoteAccessibilityTests.swift b/prismTests/WebRendering/WebNoteAccessibilityTests.swiftindex 308d0f60..5e741fb6 100644--- a/prismTests/WebRendering/WebNoteAccessibilityTests.swift+++ b/prismTests/WebRendering/WebNoteAccessibilityTests.swift@@ -368,6 +368,172 @@ struct WebNoteAccessibilityTests {         #expect(restored == "BODY")     } +    @Test("A focus key deferred while the document is unfocused is restored once it regains focus")+    func focusReturnsToTheRecreatedControlWhenTheDocumentRegainsFocus() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        let json = "[{\"domID\":\"\(domID(para))\",\"label\":\"Show 1 note\"}]"+        try await harness.send(.setNoteIndicators(json: json))+        _ = try await harness.page.callJavaScript(+            "var i = document.querySelector('[data-prism-note-indicator]'); if (i) { i.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        // Same sequence as focusIsNotRestoredWhileTheDocumentIsUnfocused: the push rebuilds+        // the chrome while the document is still in the background (a native sheet is up),+        // so the restore cannot apply immediately and must be deferred rather than dropped+        // (T-2059).+        try await harness.send(.setNoteIndicators(json: json))+        // The sheet dismisses: the document becomes the thing the user is typing into again.+        // `focus` on `window` is what the real WebView regaining first-responder status+        // fires; the seam only lifts the `document.hasFocus()` gate the event handler reads.+        try await grantDocumentFocus(harness)+        _ = try await harness.page.callJavaScript(+            "window.dispatchEvent(new Event('focus')); return null;",+            contentWorld: harness.bridgeWorld+        )+        let restored = try await harness.evalString(+            "var a = document.activeElement;"+                + " return a && a.getAttribute ? (a.getAttribute('data-prism-focus-key') || a.tagName) : 'none';"+        )+        #expect(restored == "note-indicator|\(domID(para))||")+    }++    /// A deferred restore must not outlive a NEWER explicit navigation — a TOC entry, a+    /// fragment, another note's own jump, or the stored-position restore, all+    /// `scrollToBlock` — exactly like the sibling deferred-intent slots in this codebase:+    /// `revealPending` (prism-search.js) and `reflowAnchor` (prism-scroll.js) both clear+    /// on `bridge.onExplicitNavigation` for the same reason (T-2059). Without the+    /// invalidation, a return address from an edit made long ago could fire on some+    /// unrelated later refocus and yank a keyboard user's focus into a note control they+    /// have no reason to be near.+    @Test("An explicit navigation between deferral and window focus discards the pending restore")+    func explicitNavigationDiscardsThePendingFocusKey() async throws {+        let para = paragraph()+        let elsewhere = paragraph("Somewhere else entirely")+        let harness = try await harness([para, elsewhere])+        let json = "[{\"domID\":\"\(domID(para))\",\"label\":\"Show 1 note\"}]"+        try await harness.send(.setNoteIndicators(json: json))+        _ = try await harness.page.callJavaScript(+            "var i = document.querySelector('[data-prism-note-indicator]'); if (i) { i.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        // Backgrounded push: defers the restore, same setup as+        // focusReturnsToTheRecreatedControlWhenTheDocumentRegainsFocus above.+        try await harness.send(.setNoteIndicators(json: json))+        // The user navigates elsewhere (a TOC entry, a fragment, another note) BEFORE the+        // document regains focus — a newer position authority than the stale deferral.+        try await harness.send(.scrollToBlock(domID: domID(elsewhere)))+        // The sheet then dismisses and the document regains focus, exactly as in the+        // companion test above — but there is nothing left to restore.+        try await grantDocumentFocus(harness)+        _ = try await harness.page.callJavaScript(+            "window.dispatchEvent(new Event('focus')); return null;",+            contentWorld: harness.bridgeWorld+        )+        let restored = try await harness.evalString(+            "var a = document.activeElement;"+                + " return a && a.getAttribute ? (a.getAttribute('data-prism-focus-key') || a.tagName) : 'none';"+        )+        #expect(restored == "BODY")+    }++    /// The window `focus` gate mirrors native's `restoreBodyFocusIfIdle`+    /// (RegularDocumentLayout.swift), which refuses the analogous native focus handoff+    /// while `session.search.isSearchActive`: consuming a pending restore while the+    /// reader is using search would pull them into a note control they have no reason to+    /// be near. `bridge.searchIsActive` (prism-search.js, T-2059) is stubbed directly+    /// here rather than by loading prism-search.js and pushing `setSearchState` — the+    /// bridge function is this script's whole contract with that state, and stubbing it+    /// is the same seam pattern `documentHasFocus`/`grantDocumentFocus` already use. The+    /// key is refused, not discarded (unlike the explicit-navigation case above): it+    /// still fires on the next `focus` once search is no longer active.+    @Test("A pending restore is refused while search is active, and fires once it is not")+    func pendingFocusKeyWaitsForSearchToBecomeInactive() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        let json = "[{\"domID\":\"\(domID(para))\",\"label\":\"Show 1 note\"}]"+        try await harness.send(.setNoteIndicators(json: json))+        _ = try await harness.page.callJavaScript(+            "var i = document.querySelector('[data-prism-note-indicator]'); if (i) { i.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        try await harness.send(.setNoteIndicators(json: json))+        try await grantDocumentFocus(harness)+        _ = try await harness.page.callJavaScript(+            "window.__prismBridge.searchIsActive = function () { return true; }; return null;",+            contentWorld: harness.bridgeWorld+        )+        _ = try await harness.page.callJavaScript(+            "window.dispatchEvent(new Event('focus')); return null;",+            contentWorld: harness.bridgeWorld+        )+        let whileActive = try await harness.evalString(+            "var a = document.activeElement;"+                + " return a && a.getAttribute ? (a.getAttribute('data-prism-focus-key') || a.tagName) : 'none';"+        )+        #expect(whileActive == "BODY")+        _ = try await harness.page.callJavaScript(+            "window.__prismBridge.searchIsActive = function () { return false; }; return null;",+            contentWorld: harness.bridgeWorld+        )+        _ = try await harness.page.callJavaScript(+            "window.dispatchEvent(new Event('focus')); return null;",+            contentWorld: harness.bridgeWorld+        )+        let afterSearchCloses = try await harness.evalString(+            "var a = document.activeElement;"+                + " return a && a.getAttribute ? (a.getAttribute('data-prism-focus-key') || a.tagName) : 'none';"+        )+        #expect(afterSearchCloses == "note-indicator|\(domID(para))||")+    }++    // Not a defensive-only guard any more: `pendingFocusKey`'s single-slot overwrite+    // (`focusOrDefer` in prism-notes.js) is written from TWO real call sites —+    // `restoreFocusKey` and `suppressAddNote`'s "+"-to-dot handoff — both reachable on+    // the very same backgrounded push whenever one block gains its first note while a+    // keyboard user is standing on a DIFFERENT block's control (see the+    // `pendingFocusKey` comment for the full trace). This test still drives the branch+    // directly, by calling `.focus()` on the DOM the same way the harness's other cases+    // stand a keyboard user on a control, rather than reproducing that exact two-block+    // sequence — it pins the overwrite behaviour ("most recent target wins") regardless+    // of which call site produced either key.+    @Test("A second pendingFocusKey assignment overwrites the first rather than queuing")+    func pendingFocusKeyAssignmentOverwritesAnyPriorValue() async throws {+        let table = tableBlock()+        let harness = try await harness([table])+        let bothRows = "[{\"domID\":\"\(domID(table))\",\"rowOrdinal\":0,\"label\":\"Show 1 note\"},"+            + "{\"domID\":\"\(domID(table))\",\"rowOrdinal\":1,\"label\":\"Show 1 note\"}]"+        try await harness.send(.setNoteIndicators(json: bothRows))+        // Stand on row 1's dot, then take an unrelated background push — this defers row+        // 1's key, exactly like the single-dot case above.+        _ = try await harness.page.callJavaScript(+            "var i = document.querySelector('[data-prism-sub=\"row-1\"] [data-prism-note-indicator]');"+                + " if (i) { i.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        try await harness.send(.setNoteIndicators(json: bothRows))+        // Now stand on row 0's dot directly (simulated, not a reachable production+        // sequence per the note above) and take a second background push: a second+        // explicit focus target arrives while row 1's key is still pending, and the+        // single-slot assignment must overwrite it rather than queue behind it (T-2059).+        _ = try await harness.page.callJavaScript(+            "var i = document.querySelector('[data-prism-sub=\"row-0\"] [data-prism-note-indicator]');"+                + " if (i) { i.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        try await harness.send(.setNoteIndicators(json: bothRows))+        try await grantDocumentFocus(harness)+        _ = try await harness.page.callJavaScript(+            "window.dispatchEvent(new Event('focus')); return null;",+            contentWorld: harness.bridgeWorld+        )+        let restored = try await harness.evalString(+            "var a = document.activeElement;"+                + " return a && a.getAttribute ? (a.getAttribute('data-prism-focus-key') || a.tagName) : 'none';"+        )+        #expect(restored == "note-indicator|\(domID(table))|0|")+    }+     @Test("Deleting the last note hands focus from the vanishing dot back to the add control")     func focusMovesFromRemovedIndicatorToAddControl() async throws {         let para = paragraph()@@ -464,6 +630,44 @@ struct WebNoteAccessibilityTests {         #expect(focused == true)     } +    /// The backgrounded twin of `focusMovesFromSuppressedAddControl` above: the add-a-note+    /// sequence (stand on a block's "+", tap it, a native sheet opens, save) is the MORE+    /// common path to a block's first note, and until T-2059 it dropped focus to <body>+    /// unconditionally — `suppressAddNote`'s `focusControl(successor)` call sat outside+    /// `restoreFocusKey` entirely, so a refusal (the document is still backgrounded when+    /// the note lands) had nothing to defer, unlike a restore reached through+    /// `restoreFocusKey`. The unreleased T-1725 CHANGELOG entry already claimed this+    /// exact handoff worked; it did not while a sheet was still up.+    @Test("A suppressed add-note handoff deferred while backgrounded restores to its dot")+    func focusReturnsToTheDotThatReplacedASuppressedAddControlWhenBackgrounded() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        // The "+" is injected at load; stand on it — no grantDocumentFocus, matching the+        // save-from-a-native-sheet sequence the other backgrounded tests in this file use.+        _ = try await harness.page.callJavaScript(+            "var a = document.querySelector('[data-prism-add-note]'); if (a) { a.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        // The note is saved while the document is still backgrounded: suppressAddNote+        // hides the focused "+" (which has no focus key of its own to fall back to) and+        // must defer the handoff to the dot rather than dropping it (T-2059).+        try await harness.send(+            .setNoteIndicators(json: "[{\"domID\":\"\(domID(para))\",\"label\":\"Show 1 note\"}]")+        )+        // The sheet dismisses: the document becomes the thing the user is typing into+        // again, and the deferred handoff is consumed on the resulting `focus` event.+        try await grantDocumentFocus(harness)+        _ = try await harness.page.callJavaScript(+            "window.dispatchEvent(new Event('focus')); return null;",+            contentWorld: harness.bridgeWorld+        )+        let restored = try await harness.evalString(+            "var a = document.activeElement;"+                + " return a && a.getAttribute ? (a.getAttribute('data-prism-focus-key') || a.tagName) : 'none';"+        )+        #expect(restored == "note-indicator|\(domID(para))||")+    }+     // MARK: - Live: the per-item dots #345 added (merge reconciliation, T-1725 + T-1745)      /// Contract point 1. Every item dot on a list used to derive the SAME focus key
CLAUDE.md Docs +1 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex 368e4e38..4e7a187b 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -51,7 +51,7 @@ The document is rendered by WebKit-for-SwiftUI (`WebView`/`WebPage`). The SwiftU 2. **Emit**: `BlockHTMLEmitter` (`prism/Services/WebRendering/`) is a pure, deterministic function of `[MarkdownBlock]` + `FootnoteData` + `RenderSettings`. It emits one `<section>` per block carrying the content-hash block identity and an occurrence-qualified DOM id (`b-{hash}-{sourceIndex}`, allocated via the shared `BlockDOMID`), escapes by default, and is total (a block that fails to emit falls back to escaped-source `<pre>`, never dropped). `InlineHTMLRenderer` wraps mappable text runs in `<span data-prism-run>` and records a `DocumentSourceMap` (UTF-16 offsets, shipped as an inert `<div hidden>` data island) for selection-anchored notes. `emit` (and the model/service value types it reads) is `nonisolated`, so it runs off the MainActor: `WebDocumentControllerFactory.precomputeDocumentHTML` emits once per `parseRevision` on a `Task.detached` and caches the HTML on `DocumentSession`; the scheme handler serves that cache (synchronous on-main emit only on a miss). Its per-block/inline `HTMLSanitizer` (SwiftSoup) passes are serialized behind a shared `Mutex` because SwiftSoup keeps unsynchronized static pools (T-1681, `specs/offmain-html-emit/`). 3. **Serve**: `PrismDocSchemeHandler` (`prism-doc://` `URLSchemeHandler`) is the single audited I/O path — it serves the document HTML, `document.css` (the only asset fetched through the scheme), and mediates every image subresource through `/img/?src=` (rewritten absolute/relative URLs routed via `ImagePathResolver`/`ImageLoader`/`SVGSourceLoader`). It serves the verbatim CSP (`script-src 'none'`, `connect-src 'none'`, …) as a response header. The document is loaded via the scheme, never `loadHTMLString`. 4. **Host + bridge**: `WebDocumentController`/`WebDocumentView` (`prism/ViewModels/`, `prism/Views/`) own one `WebPage` per session (non-persistent store, `allowsContentJavaScript = false`, all JS injected as user scripts via `WebDocumentControllerFactory.userScripts()`). The native↔JS bridge runs in a dedicated isolated `WKContentWorld`; every message in both directions carries a generation tag (sessionID, parseRevision, processGeneration). `BridgeMessageRouter` validates inbound messages against an enumerated allowlist + exact-generation match and drops forged/stale/malformed ones; `WebDocumentMessageRouter` routes accepted messages onto the existing native session/coordinator state. Outbound commands queue until `ready`; scroll restore waits for `layoutSettled`; on WebContent termination the controller bumps the process generation, reloads, and replays one coalesced state snapshot. That termination is observed by `startNavigationObservation()`, armed from the controller's `init` — it was missing entirely until T-1943, so the whole recovery path was dead code in production. `WebPage` offers no delegate callback and no Observable property for a crash: it surfaces as `WebPage.NavigationError.webContentProcessTerminated` **thrown** by `page.navigations`, which ENDS the sequence — and which also throws for ordinary navigation failures — so the observer classifies the error (`drainNavigationStream`) and re-subscribes (`applyNavigationOutcome`), or the first failed navigation would silently disarm crash recovery for the rest of the session. A recovery reload that fails as an ORDINARY navigation is the same failure wearing a different error, so `recoveryInFlight` makes a `.navigationFailed` legible: during a recovery it is charged and retried, outside one it is a benign bad link. Recovery gives up after `maxUnproductiveRecoveries` consecutive attempts that never reach the stability milestone that resets the budget — `layoutSettled`, not merely `ready` — rather than reloading in a hot loop; `ready` alone used to reset it, so a crash landing after `ready` but before `layoutSettled` restarted the chain at attempt one every time and could reload forever without ever hitting the cap (T-2107). Giving up is neither silent nor permanent: it clears the observation task handle, raises `recoveryAbandoned` (the banner in `DocumentScrollContent`), and any fresh `load` restores the budget and re-arms observation. Because a direct-invocation test cannot see missing wiring (that is exactly how T-1943 survived the cutover and every review), the production subscription is pinned by a live test over a real `WebPage`: `WebContentTerminationWiringTests.controllerObservesItsOwnPageNavigationStream`. `WebDocumentStateSynchronizer` (T-1719) is the single production owner that pushes native truth (sections, details open-state, table modes, notes, typography, comment visibility) to the controller and routes navigation targets (TOC/fragment via `session.pendingAnchorScroll`, notes via `coordinator.noteNavigationTarget`, search current match) through `controller.scrollTo` with `BlockDOMID.navigationDOMID` id translation — Observation-framework driven, so it works with no view mounted; `DocumentScrollContent` mounts the whole assembly via `WebDocumentStateSynchronizer.makeAssembly`. Two inputs are view-fed, because both are view-world environment values: the palette, pushed via `applyTheme(themeKey:contrast:)` — the colorScheme-resolved theme key plus `colorSchemeContrast`, grouped as a `WebPaletteFeed` so a single `.onChange` pushes them together and they can never be applied out of step (T-1829) — and `dynamicTypeSize`, fed in via `start(dynamicTypeSize:)` / `applyDynamicTypeSize(_:)`, which gets NO push of its own: the synchronizer folds it into the typography domain, because `applyTypography` carries one variables dict that wholly replaces the snapshot's typography, so a second pusher would drop the settings-derived half from the recovery replay (T-1828, font-settings Decision 18).-5. **Notes**: `NoteStateFeeder` (`prism/Services/WebRendering/`) maps `NotesManager` state onto `setNoteIndicators`/`setInlineNotes` payloads; `NoteHTMLBuilder` renders the (escaped) bubble/banner HTML natively; `prism-notes.js` (isolated world) injects it as `data-prism-chrome` and posts interaction messages back. Every interactive piece of that chrome is a NATIVE `<button>` or `<a href>` (T-1725) — never a `div`/`span` with `role="button"` — so the user agent supplies focusability, tab order, and Enter/Space activation, and there is no synthetic key handling to keep in sync. Those elements suppress their UA appearance, so `document.css` must reset it; the indicator dot's `font-size: 1em` is load-bearing rather than cosmetic, since the dot's whole gutter geometry is expressed in em. Accessible names are native-owned because the JS cannot reach the string catalog: the indicator's name rides the `setNoteIndicators` payload (`label`, pluralised via `NoteRenderStrings.noteIndicator`), the bubble's action label is baked in by `NoteHTMLBuilder` as visually-hidden text (an `aria-label` there would *replace* the note's own text in the accessible name), and the add-note "+" reads `<main data-prism-add-note-label>`. Both push handlers rebuild all chrome, so each control carries a `data-prism-focus-key` and `prism-notes.js` captures/restores focus around the rebuild.+5. **Notes**: `NoteStateFeeder` (`prism/Services/WebRendering/`) maps `NotesManager` state onto `setNoteIndicators`/`setInlineNotes` payloads; `NoteHTMLBuilder` renders the (escaped) bubble/banner HTML natively; `prism-notes.js` (isolated world) injects it as `data-prism-chrome` and posts interaction messages back. Every interactive piece of that chrome is a NATIVE `<button>` or `<a href>` (T-1725) — never a `div`/`span` with `role="button"` — so the user agent supplies focusability, tab order, and Enter/Space activation, and there is no synthetic key handling to keep in sync. Those elements suppress their UA appearance, so `document.css` must reset it; the indicator dot's `font-size: 1em` is load-bearing rather than cosmetic, since the dot's whole gutter geometry is expressed in em. Accessible names are native-owned because the JS cannot reach the string catalog: the indicator's name rides the `setNoteIndicators` payload (`label`, pluralised via `NoteRenderStrings.noteIndicator`), the bubble's action label is baked in by `NoteHTMLBuilder` as visually-hidden text (an `aria-label` there would *replace* the note's own text in the accessible name), and the add-note "+" reads `<main data-prism-add-note-label>`. Both push handlers rebuild all chrome, so each control carries a `data-prism-focus-key` and `prism-notes.js` captures/restores focus around the rebuild. The restore is gated on `document.hasFocus()` — never pull focus into a document the user is not in — and a restore the gate refuses is DEFERRED as `pendingFocusKey`, not discarded, then consumed by a `window` `focus` listener when the document actually comes back (T-2059); discarding it lost the return address for every note edited through a native sheet. The shared writer, `focusOrDefer`, is called from BOTH `restoreFocusKey` and `suppressAddNote`'s "+"-to-dot handoff — the add-note "+" carries no focus key of its own (`makeAddNoteControl`), so before this the handoff had nothing to defer and lost focus outright on the more common add-a-note sequence (stand on a block's "+", tap it, save from the sheet); `focusOrDefer` falls back to the successor's OWN `data-prism-focus-key` so that path is covered too. `focusControl` verifies `document.activeElement === element` after calling `.focus()` rather than trusting the call, because a suppressed "+" stays in the DOM (`display:none`) and a no-op `.focus()` on it must not read as success. The pending key is invalidated by `bridge.onExplicitNavigation`, matching the sibling deferrals in `prism-search.js` (`revealPending`) and `prism-scroll.js` (`reflowAnchor`) — safe because a note's own bridge messages (indicator/bubble tap, block-context "+") never scroll, so the invalidation cannot cancel the restore it exists to deliver. The consuming `window` `focus` listener also refuses while `bridge.searchIsActive()` (prism-search.js, sourced from `SearchCoordinator.isSearchActive`), mirroring native's `restoreBodyFocusIfIdle` (`RegularDocumentLayout.swift`) — the key stays pending rather than discarded, so it still fires once search is no longer active. 6. **Search**: counts and navigation order stay in `SearchService`/`SearchCoordinator`. `SearchStateFeeder` translates that into a per-block `setSearchState` payload; `prism-search.js` re-finds the query in each block's rendered text and registers ranges on two named **CSS Custom Highlights** (`prism-search`, `prism-search-current`), windowed to the viewport. The web view's built-in find navigator stays disabled so Cmd+F routes to Prism's search. 7. **Security**: `HTMLSanitizer` (over SwiftSoup) reduces raw HTML embedded in markdown to an allowlist subset on load (Req 1.8/8.1); its `plainText` feeds searchable text. Combined with `allowsContentJavaScript = false` and the served CSP, active-content vectors are blocked by construction. 8. **In-page render libraries**: mermaid.js and highlight.js (+ their thin Prism drivers) run in the **page world** (they only render DOM, never post bridge messages). The bundled JS lives in `prism/Resources/WebRenderer/`.
CHANGELOG.md Docs (conflicts) +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bb517458..6896f6dd 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Editing an inline note through its native sheet no longer loses your keyboard focus return point once the sheet closes (T-2059). Saving rebuilds the note's chrome while the sheet still covers the document, and the focus restore that follows could not run in the background — it discarded the return address instead of remembering it, so Tab traversal restarted from the top of the document after every edit. The intended target is now held and restored as soon as the document regains focus. - An image that is tiny as a file but enormous as a picture can no longer exhaust memory or terminate Prism, whether it comes from the web, from a file beside the document, or written directly into the markdown (T-2132, T-2149, T-2151, T-1867). A picture is stored compressed, and a plain-coloured one compresses at about a thousand to one — so a 400 KB download can be a 20,000 by 20,000 image that needs about 400 MB the moment anything tries to display it, and four times that if it is in colour. Prism's limits were all written on the wrong side of that: a 50 MB cap on the download said nothing about the picture inside it, and the 2 MB cap on a local SVG was applied only after the whole file had already been read, so a very large one could freeze the app on its way to being refused. The limits that did exist covered only images fetched from the web; the same image referenced from a file next to your document, or embedded inline in the markdown, went straight to the renderer unchecked. Prism now reads the picture's dimensions from its header — a few bytes, before anything is decoded — and decides from that. An ordinary image is displayed as before. A very large one referenced from the web or from a file is scaled down to fit. One beyond any reasonable size is refused outright and shows the usual "Image failed to load" placeholder, rather than being handed to a decoder that would have to build the whole thing first. How large a picture is now also accounts for how much detail each dot of it carries: most pictures store one byte per colour, but some store two or four, and Prism previously assumed the smaller size for all of them and so under-counted the deep ones by half or three quarters. One consequence you may see: a very large deep-colour photograph that used to display at full size is now scaled down, because its true size was always above the limit and is now measured as such. Files are now read up to their limit instead of read whole and then measured — including the copy Prism keeps of a document you have not saved yet, which is restored when the app reopens. How much decoding happens at once is limited by how much memory those pictures actually need rather than by how many of them there are, so a page full of large images no longer overruns while appearing to stay within its bounds. Two things behave differently, both deliberately. An image whose file does not say how big it is, or what kind of dots it stores, now shows the "Image failed to load" placeholder instead of being displayed — there is no way to know what it would cost until it has already cost it. And an image written directly into the markdown is treated more strictly than the same image kept in a file beside the document: it is either small enough to display as it is or refused, never scaled down. That difference is about memory rather than effort. Scaling a picture that is written into the markdown means rebuilding it and writing the smaller version back into the page, where it then stays for as long as the document is open — which costs more memory, for longer, than not showing it. A picture in a file has somewhere else to keep its smaller version, so it can be scaled instead of refused. Animated images are unaffected in either case: they play as before, however many frames they have. - A verification scan that starts during the app's initial entitlement bootstrap can no longer publish a stale result while a newer scan is still in flight (T-2152). While `entitlementState` was still `.loading`, any scan's result was accepted regardless of whether a more recent scan — for example one started right after `AppStore.sync()` — was still reading the world; the older scan finishing first could briefly flip the paywall to locked (or unlocked) ahead of the newer, more current answer. An older result that arrives while a newer scan is still outstanding is now held back rather than published. If the newer scan goes on to answer, its fresher result is published and the held-back one is simply dropped; if instead it is cancelled without ever answering, the held-back result is released, so a cancelled scan cannot leave the paywall stranded on `.loading`. The trade is that the brief loading state now ends when the last overlapping scan answers rather than the first, so it can last marginally longer; every control it gates is disabled meanwhile, so nothing silently does nothing. - Saving a pasted document to a file no longer disturbs whatever document you opened next (T-2213). A save finishes in two parts: the file is written straight away, but the document only becomes that file once its notes have been moved across, and on a slow iCloud connection that second part can still be running after you have closed the document or opened another one. When it finished late, it acted on the document then on screen instead of the one it had saved: the pasted text of that other document was deleted from the place Prism keeps unsaved documents — so it could no longer be recovered after a relaunch — its entry in Recent Files was labelled with the wrong document's title, and an action you had queued behind its own Save prompt could run without you confirming it. A save that failed to move its notes also raised an alert naming a file you were no longer looking at. Each of these now belongs to the document that was actually saved, and the document on screen is left alone. Its Recent Files entry is labelled with its own title rather than the other document's. Where that other document had itself started saving in the meantime, the late save no longer takes over the shortcut that document had prepared for its own file, which can leave the saved file without a Recent Files entry of its own. The file is saved either way, and can be opened from the Files app.

Things to double-check

The claimed native mirror is inert on this path.

Both the code comment and the new CLAUDE.md text say the search gate mirrors restoreBodyFocusIfIdle (RegularDocumentLayout.swift:217-221). The reasoning transfers fine. The function itself does not: it sets bodyHasFocus = true, and the only .focused($bodyHasFocus) in the codebase is RawSourceView.swift:145 — so on the rendered path it is a no-op. Worth knowing when reading either comment, and it is the supporting evidence for finding 1.

One sequence where the invalidation does cancel a real pending key.

With the notes panel open: edit a note there (defers a key, since document.activeElement stays sticky while the WebView is not first responder), then tap a note row in the panel — that fires scrollToBlock and clears the key. It is a genuine explicit navigation and the outcome equals pre-fix behaviour, so no regression. But it means the comment's absolute wording ("cannot cancel the restore this slot exists to deliver") is exactly true only for the document-side note flows.

The pending key has no time bound.

Only an explicit navigation clears it. A reader who edits a note while search is open, never navigates, reads for twenty minutes, then switches apps and back will get focus placed on that note's control by the resulting focus event. preventScroll: true keeps it invisible unless the focus ring shows, so this is an observation rather than a finding — but it is the shape a future "focus jumped somewhere odd" report would take.

CHANGELOG conflicts with main.

git merge-tree reports exactly one content conflict, in CHANGELOG.md, from a sibling entry landing in the same ### Fixed block. No source file conflicts. The merge queue resolves it; flagged only so it is not mistaken for something worse.