PR #335 — search highlights vanished (or clung to old text) after a document reload that reworded the matching passages without changing per-block match counts. The fix adds parseRevision to the search-highlight push trigger so a reparse always re-sends a payload addressed to the blocks the reloaded page actually shows.
setSearchState payload is keyed by content-hash block DOM ids (b-{hash}-{sourceIndex}), but the re-push trigger only watched (query, per-block counts, current index). A reload that reworded matching text while preserving the match arithmetic changed every matching block's DOM id without firing the trigger — the reload's coalesced snapshot then replayed highlight instructions addressing sections that no longer exist. Native counts and navigation stayed correct, so only the page-side highlights went stale.WebSearchStateKey and add session.parseRevision — bumped exactly once per applied parse in DocumentSession.parseAndApplyBlocks, the canonical block-identity token.DocumentScrollContent.swift) and green after; no second push path exists; the test file's ordering claims (generation stamped at dispatch, last-write-wins snapshot replay) match the real WebDocumentController implementation.origin/main is churn-free despite PR #334 landing on main — the three-dot diff equals the merge-base diff and touches none of the files #334 changed.// Both layouts comment line (419d2c4). Everything else is reported, not changed.Ready to push
The fix is correct, minimal, and at the only production push site: WebDocumentStateSynchronizer deliberately excludes the search-highlight domain (documented T-1680 ownership), and pushSearchState has exactly two call sites, both in DocumentScrollContent. parseRevision was verified as a sound identity token in both directions — DOM ids cannot change without a bump, and a bump without an id change costs one small redundant push per reload. Both regression tests were re-run against the pre-fix tree during this review and fail there; they pass on HEAD (2/2 via check-test-results.sh). SwiftLint is clean. Remaining findings are suggestions for follow-up, none blocking.
07862c7 T-1751: Add failing regression tests for stale search highlights after reload 3365d93 Fix T-1751: Include parseRevision in the search-highlight push trigger e932fee Address review feedback: lockstep test comment and CHANGELOG entry 419d2c4 Reflow orphaned comment line in DocumentScrollContent (review nit) Prism shows markdown documents and lets you search inside them — matching words get highlighted on the page. Documents can also reload: if the file changes on disk, or you refresh a document opened from a URL, Prism re-reads it and redraws the page.
The bug: if a reload reworded the passages your search matched — but each paragraph kept the same number of matches — the highlights disappeared or pointed at stale text, even though the match counter (“3 of 5”) and the next/previous buttons kept working.
Search that silently stops highlighting after a file edit looks broken and is genuinely misleading: the counter says there are matches, but the page shows none.
One production file: prism/Views/DocumentScrollContent.swift. The private SearchStateKey struct becomes the top-level WebSearchStateKey (so tests can construct and pin it, following the WebPaletteFeed precedent) and gains a parseRevision: UInt64 field, populated by a @MainActor init(session:) that derives all four fields from the session. Plus a new test file (prismTests/WebRendering/WebSearchReloadTests.swift, 2 tests) and a CHANGELOG entry.
Prism's web renderer keeps native code as the source of truth: SearchService/SearchCoordinator compute matches, and the view pushes a setSearchState payload keyed by occurrence-qualified content-hash DOM ids (BlockDOMID). The push fires from .onChange(of: searchStateKey) — an Equatable value observed by SwiftUI. The payload is a joint function of the coordinator's native state and block identity; the old key modelled only the native half. The initial push lives in a .task(id: session.id), and session.id does not change on reload — so the reload path is covered solely by the .onChange, which is exactly why the bug existed.
On reload, WebDocumentController.load replays the coalesced snapshot — including the stored searchStateJSON string. Ordering between the .onChange push and the .task-driven load is immaterial: last write wins into the snapshot, and a push while the page is not yet ready appends to the same command queue, so either interleaving converges. The second test asserts exactly this convergence.
WebDocumentStateSynchronizer value-diffs every domain it owns and could own search state too (diffing the payload itself makes this bug class unrepresentable), but search-highlight ownership is documented as T-1680's view-side seam; the fix respects that boundary rather than relocating a domain inside a bugfix PR.The invariant being restored: a push trigger must span the full input domain of the payload it guards. searchStateJSON = f(query, matchCountsPerBlock, currentGlobalMatchIndex, BlockDOMID.map(parsedBlocks), settings.showHTMLComments); the old key spanned only the first three coordinates. The bug's precondition — a rewording that preserves per-block match cardinality and the current index — keeps the projection onto those three fixed while moving the fourth, so .onChange sees equality and the stale searchStateJSON survives into scheduleSnapshotReplay().
parseRevision is sound as the identity token: parsedBlocks has exactly one production assignment, immediately followed by parseRevision &+= 1 in the same synchronous stretch, and parsedBlocks.didSet recomputes match counts before the bump — so by the time the key changes, every field is fresh (no torn read). iOS reloadWebDocumentForAccess() reloads at the same revision with unchanged ids (correctly no push); process-termination recovery replays the snapshot without reparsing (fresh as long as the last push was). In the reversed onChange/load interleaving the page transiently receives a payload addressing dead DOM ids — a harmless no-op highlight instruction; generation stamping at dispatch time means the post-load push is accepted either way.
Minimal surface: one field on a hoisted value type. The structural alternative the review surfaced: the synchronizer already caches BlockDOMID.Mapping per parseRevision and value-diffs every domain (lastNoteIndicatorsJSON, …); a lastSearchStateJSON domain would diff the payload itself — no proxy key to keep in sync. It even solved the identical problem for search navigation (lastSeededParseRevision, T-1775). The T-1680 comment in the synchronizer reserves that seam; worth taking if search push logic grows again. Note the fifth payload input, showHTMLComments, is still not in the key — covered transitively because recomputeAfterVisibilityChange() changes counts or pins the cursor, but the key's doc comment (“the state the payload is derived from”) is not literally true.
if key != lastPushedKey) and assembles via WebDocumentControllerFactory.make rather than the makeAssembly path sibling suites use — if someone narrows the .onChange key or drops it, both tests keep passing. The lockstep comment acknowledges but does not mitigate this.==: parseRevision is declared last, so the reparse case does a full O(blocks) [Int] compare before reaching the discriminating UInt64. Once per reload; noise.prism/Views/DocumentScrollContent.swift
Why it matters. The actual fix. The setSearchState payload is a joint function of native search state and block identity; adding the parse revision makes the .onChange trigger span the identity half, so a reload always re-pushes a payload addressed to the re-emitted document's DOM ids before the coalesced snapshot is replayed.
What to look at. WebSearchStateKey.parseRevision, populated in init(session:); see the .onChange(of: searchStateKey) wiring
prism/Views/DocumentScrollContent.swift
Why it matters. Pure refactor (landed pre-fix as its own commit) that lets tests construct and pin the trigger key — the first regression test is impossible against a view-private type.
What to look at. struct WebSearchStateKey, @MainActor init(session:)
prismTests/WebRendering/WebSearchReloadTests.swift
Why it matters. Pins both halves of the fix: the key must change across a reparse that changes block identity with identical native numbers, and the controller's coalesced snapshot — the exact state a reload replays — must address the new DOM ids after driving the view's gated push sequence.
What to look at. searchStateKeyChangesWhenBlockIdentityChanges(), snapshotUsesFreshDOMIDsAfterReload()
CHANGELOG.md
Why it matters. Describes the symptom (stale or missing highlights after a reload that rewords matches, counter still correct) and the cause/fix in user terms, matching the house style of surrounding entries.
What to look at. CHANGELOG.md, [Unreleased] > Fixed, first entry
The commit message calls parseRevision “the canonical block-identity token, bumped once per applied parse”. It is a strict superset trigger: it can over-fire on a byte-identical reparse (one redundant bounded push) but can never under-fire, because parsedBlocks has a single production assignment immediately followed by the bump. Hashing BlockDOMID.map output would be exact but O(blocks) on every SwiftUI body evaluation.
WebDocumentStateSynchronizer documents twice that search highlight state “is owned by T-1680 and intentionally not pushed here”, and reserves the seam where the domain could be added. Moving searchStateJSON into the synchronizer's value-diffed domains would have fixed T-1751 structurally (the JSON differs whenever the ids differ, by construction) — but relocating a documented ownership boundary is a larger change than a bugfix PR should carry. The shipped fix extends the existing trigger instead.
The custom init(session:) suppresses the memberwise initializer, so no caller — including tests — can build a key whose fields disagree with real session state. That is why the first test must construct a session and reload it rather than compare synthetic keys; the trade is deliberate (the key is always trustworthy) at the cost of field-level sensitivity tests.
Commit 07862c7 (“investigation checkpoint… Both tests fail before the fix”) lands the extraction refactor plus red tests; 3365d93 is the one-hunk fix. This review exploited that split to independently verify the red state by checking out the pre-fix DocumentScrollContent.swift and re-running the suite: both tests fail there, both pass on HEAD.
The test drives pushSearchState/load call-for-call with an explicit lockstep comment (added in e932fee after PR feedback), and argues order-independence from the controller's last-write-wins snapshot plus dispatch-time generation stamping. The alternative — routing through WebDocumentStateSynchronizer.makeAssembly like WebSearchWiringTests.assembleProductionController — would pin the production mount; the chosen shape keeps the test small but leaves gate-drift undetectable (see findings).
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | DocumentScrollContent.swift — WebSearchStateKey completeness | The payload also consumes settings.showHTMLComments (via SearchContext in WebDocumentControllerFactory.searchStateJSON), which is not in the key — the same input-domain gap class the PR fixes. Mitigation is transitive: SearchCoordinator.recomputeAfterVisibilityChange() recomputes counts and pins the cursor to 0, so a toggle that changes any match fires the key; the residual case (toggle changes no counts, cursor already at 0) most plausibly yields an identical payload, so no user-visible staleness was demonstrated. | Report only (substantive change, author's call): either add showHTMLComments: Bool to WebSearchStateKey (two lines — context.settings is in scope) or amend the key's doc comment to state the comment-visibility input is covered transitively via matchCounts. |
| minor | WebSearchReloadTests.swift — drift blind spot | The snapshot test re-implements the view's gate (if key != lastPushedKey { push }) and assembles via WebDocumentControllerFactory.make rather than the WebDocumentStateSynchronizer.makeAssembly path sibling suites use (WebSearchWiringTests.assembleProductionController) — the synchronizer's own docs say views MUST assemble through makeAssembly so tests exercise real wiring. If the .onChange key is later narrowed or removed, both tests still pass. The in-test lockstep comment acknowledges this. | Report only: route the assembly through makeAssembly (inert for this assertion, aligns with convention), or keep the mirror pattern — precedent exists — accepting the documented blind spot. Also soften the "call for call" comment claim, which overstates fidelity given the synchronizer is skipped. |
| minor | Architecture — synchronizer seam (reuse review) | WebDocumentStateSynchronizer already is a revision-aware, value-diffed push engine over exactly this payload's inputs (it caches BlockDOMID.Mapping per parseRevision and solved the identical problem for search navigation via lastSeededParseRevision, T-1775). A lastSearchStateJSON domain there would have made this bug class unrepresentable — no proxy key to maintain. The T-1680 ownership boundary is documented, so the shipped fix is legitimate; this is a structural follow-up, not a defect. | Report only: consider a follow-up ticket to move the search-highlight domain into the synchronizer, or add a comment at the reserved seam explaining why it remains view-side. |
| nit | DocumentScrollContent.swift — comment reflow | The doc-comment edit above .onChange left "// Both layouts" orphaned on its own line mid-paragraph. | Fixed in commit 419d2c4 (comment-only; lint re-verified clean). |
| nit | CLAUDE.md accuracy | CLAUDE.md's rendering section says "Two inputs are view-fed" (palette, Dynamic Type). Search highlight state is a third view-fed input, made more prominent by promoting WebSearchStateKey to a top-level type alongside WebPaletteFeed. | Report only: one-sentence CLAUDE.md amendment naming the third view-fed input and citing the T-1680 seam. |
| nit | WebSearchStateKey — field order and doc duplication | parseRevision is declared last, so the synthesized == does a full O(blocks) [Int] compare before reaching the discriminating UInt64 in the reparse case (once per reload; dwarfed by the reparse itself). Separately, the searchStateKey property doc is now a lossy duplicate of the type's doc comment. | Report only: optionally declare parseRevision first and trim the property doc to a pointer. Not worth a round-trip on their own. |
| nit | Test fixtures — near-clone | originalMarkdown is WebSearchWiringTests.sampleMarkdown with only the H1 reworded, and makeSearchingSession() duplicates that suite's setup. Per-suite private fixtures are the established convention in prismTests (no shared session builder exists), so this is acceptable; noted for awareness. | Skipped — matches convention. |
Click to expand.
diff --git a/prism/Views/DocumentScrollContent.swift b/prism/Views/DocumentScrollContent.swiftindex c50a82c..4f9d4db 100644--- a/prism/Views/DocumentScrollContent.swift+++ b/prism/Views/DocumentScrollContent.swift@@ -175,8 +175,10 @@ struct DocumentScrollContent: View { } // Search highlights (Req 6.1/6.2/6.3/7.2, T-1680): re-push whenever the // coordinator's debounced query, per-block counts (recomputed on block or- // comment-visibility changes), or current-match selection change. Both layouts- // mutate the same SearchCoordinator, so this single feed covers the inline bar+ // comment-visibility changes), current-match selection, or the parse+ // revision change — the payload is keyed by content-hash block DOM ids, so+ // a reparse invalidates it even when the native numbers are equal (T-1751).+ // Both layouts mutate the same SearchCoordinator, so this single feed covers the inline bar // and the compact overlay; prism-search.js also scrolls the pushed current // match into view. Clearing the search pushes the empty payload, which clears // the page's highlights.@@ -308,21 +310,10 @@ struct DocumentScrollContent: View { ) } - /// The coordinator state the search-highlight push depends on; a change to any+ /// The state the search-highlight push depends on; a change to any /// field re-pushes `setSearchState` (see the `.onChange(of: searchStateKey)`).- private var searchStateKey: SearchStateKey {- SearchStateKey(- query: context.session.search.activeSearchQuery,- matchCounts: context.session.search.matchCountsPerBlock,- currentIndex: context.session.search.currentGlobalMatchIndex- )- }-- /// Equatable identity for the search-highlight push trigger.- private struct SearchStateKey: Equatable {- let query: String- let matchCounts: [Int]- let currentIndex: Int?+ private var searchStateKey: WebSearchStateKey {+ WebSearchStateKey(session: context.session) } /// The view-world palette inputs the rendered document needs: the@@ -342,6 +333,33 @@ struct DocumentScrollContent: View { } } +/// Equatable identity for the search-highlight push trigger (T-1680): the state+/// the `setSearchState` payload is derived from. A change to any field means the+/// payload the page holds is out of date and must be re-pushed. Top-level (like+/// `WebPaletteFeed`) so tests can pin exactly when a re-push must fire.+///+/// `parseRevision` is here because the payload is keyed by content-hash block+/// DOM ids (`BlockDOMID`), making it a function of block identity as well as of+/// the coordinator's native state. A reload can rewrite matching block text+/// while leaving the query, per-block counts, and current index all equal — the+/// reload then replays the coalesced snapshot, so without a block-identity+/// token the page would keep highlight instructions addressing DOM ids that no+/// longer exist (T-1751).+struct WebSearchStateKey: Equatable {+ let query: String+ let matchCounts: [Int]+ let currentIndex: Int?+ let parseRevision: UInt64++ @MainActor+ init(session: DocumentSession) {+ query = session.search.activeSearchQuery+ matchCounts = session.search.matchCountsPerBlock+ currentIndex = session.search.currentGlobalMatchIndex+ parseRevision = session.parseRevision+ }+}+ /// The palette state the document surface feeds to the web renderer from the view /// world. Grouped into one `Equatable` value so a single `.onChange` covers every /// input and they are always pushed as a consistent pair (T-1829).
diff --git a/prismTests/WebRendering/WebSearchReloadTests.swift b/prismTests/WebRendering/WebSearchReloadTests.swiftnew file mode 100644index 0000000..0252747--- /dev/null+++ b/prismTests/WebRendering/WebSearchReloadTests.swift@@ -0,0 +1,171 @@+//+// WebSearchReloadTests.swift+// prismTests+//+// T-1751 regression tests: search highlights must survive a reload that changes+// block content without changing the native search numbers.+//+// The search-highlight payload (`setSearchState`) is keyed by occurrence-+// qualified block DOM ids (`b-{contentHash}-{sourceIndex}`), so it is a joint+// function of the SearchCoordinator's native truth AND block identity. The+// view's re-push trigger (`DocumentScrollContent.searchStateKey`) only modelled+// the native half: query, per-block match counts, current match index. When a+// reload (file change, URL refresh) rewrites matching block text but leaves+// those three fields equal, no fresh push fires — and the reload's coalesced+// snapshot replay re-sends the stale payload, addressing DOM ids that no longer+// exist in the re-emitted document. Native counts stay correct while the page+// shows no highlights.+//+// These tests pin the two halves of the fix:+// 1. `WebSearchStateKey` must change when a reparse changes block identity,+// even with identical query/counts/current index.+// 2. Driving the production push path with the view's gating ("push only when+// the key changed") must leave the controller's coalesced snapshot — the+// exact state a reload replays — addressing the NEW block DOM ids.+//++import Foundation+import Testing+@testable import prism++@Suite("T-1751 search highlights across reload", .serialized)+@MainActor+struct WebSearchReloadTests {++ /// Original document: matches in two paragraphs, none in heading or closer.+ /// Per-block counts: [0, 1, 2, 0].+ private static let originalMarkdown = """+ # Reload haystack++ First paragraph mentions a needle once.++ Second paragraph has a needle and another needle.++ A closing paragraph with no match at all.+ """++ /// Reloaded document: both matching paragraphs are reworded (new content+ /// hashes, therefore new DOM ids) while keeping the same block structure and+ /// the same per-block match counts [0, 1, 2, 0], so the coordinator's query,+ /// counts, and current index are all unchanged after the reparse.+ private static let reloadedMarkdown = """+ # Reload haystack++ A rewritten opening paragraph still mentions a needle once.++ The rewritten second paragraph has a needle and another needle in it.++ A closing paragraph with no match at all.+ """++ /// Builds a session with the original content, an active query, and a+ /// selected current match — the state a reader is in mid-search.+ private func makeSearchingSession() async -> DocumentSession {+ let session = DocumentSession(clipboardContent: Self.originalMarkdown)+ await session.parseContent()+ session.search.setActiveSearchQueryForTesting("needle")+ session.search.navigateToMatch(at: 1)+ return session+ }++ @Test("the push trigger changes when a reparse changes block identity")+ func searchStateKeyChangesWhenBlockIdentityChanges() async throws {+ let session = await makeSearchingSession()+ #expect(session.search.matchCountsPerBlock == [0, 1, 2, 0])++ let countsBefore = session.search.matchCountsPerBlock+ let indexBefore = session.search.currentGlobalMatchIndex+ let domIDsBefore = Set(BlockDOMID.map(blocks: session.parsedBlocks).map(\.domID))+ let keyBefore = WebSearchStateKey(session: session)++ await session.reloadContent(markdownString: Self.reloadedMarkdown)++ // Precondition of the bug: the reparse changed block identity (the two+ // matching paragraphs have new content hashes)…+ let domIDsAfter = Set(BlockDOMID.map(blocks: session.parsedBlocks).map(\.domID))+ #expect(domIDsAfter != domIDsBefore, "the reload fixture must change block DOM ids")+ // …while the native search numbers are unchanged.+ #expect(session.search.matchCountsPerBlock == countsBefore)+ #expect(session.search.currentGlobalMatchIndex == indexBefore)++ // The payload is derived from block identity too, so the trigger key+ // must differ — equal keys mean no re-push and a stale snapshot replay+ // after the reload (T-1751).+ let keyAfter = WebSearchStateKey(session: session)+ #expect(+ keyAfter != keyBefore,+ "search push trigger did not change across a reparse that changed block ids"+ )+ }++ @Test("after a reload the coalesced snapshot addresses the new block DOM ids")+ func snapshotUsesFreshDOMIDsAfterReload() async throws {+ let session = await makeSearchingSession()+ let settings = AppSettings()+ let controller = WebDocumentControllerFactory.make(session: session, settings: settings)++ // This manually mirrors DocumentScrollContent's search wiring — the+ // initial `pushSearchState`, the `.onChange(of: searchStateKey)`+ // changed-key gating, and the `.task(id: WebLoadKey(...))` load — call+ // for call. If the view's sequence gains a step, this must gain it too.+ //+ // The sequence below drives one representative interleaving (push,+ // load, reload → gated push → load). SwiftUI does not guarantee this+ // exact order between the onChange push and the load task, and the+ // design does not require it: `load` stamps the generation at dispatch+ // time and the reload replays whatever `latestSnapshot` then holds+ // (last write wins), so a reversed ordering converges to the same+ // final snapshot and the assertions below hold either way.+ var lastPushedKey = WebSearchStateKey(session: session)+ WebDocumentControllerFactory.pushSearchState(+ to: controller, session: session, settings: settings+ )+ controller.load(+ documentURL: WebDocumentControllerFactory.documentURL(+ session: session, parseRevision: session.parseRevision+ ),+ parseRevision: session.parseRevision+ )++ // External change → reparse with unchanged query/counts/current index.+ await session.reloadContent(markdownString: Self.reloadedMarkdown)++ // Mirror the view's `.onChange(of: searchStateKey)` gating exactly: a+ // push happens only when the key changed. Before the fix the key is+ // equal, so nothing re-pushes.+ let key = WebSearchStateKey(session: session)+ if key != lastPushedKey {+ lastPushedKey = key+ WebDocumentControllerFactory.pushSearchState(+ to: controller, session: session, settings: settings+ )+ }++ // The parseRevision bump reloads the page, which replays the coalesced+ // snapshot — whatever searchStateJSON it holds is what the page gets.+ controller.load(+ documentURL: WebDocumentControllerFactory.documentURL(+ session: session, parseRevision: session.parseRevision+ ),+ parseRevision: session.parseRevision+ )++ let json = try #require(controller.latestSnapshot.searchStateJSON)+ let root = try #require(+ try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]+ )+ let payloadBlocks = try #require(root["blocks"] as? [String: Any])+ #expect(!payloadBlocks.isEmpty, "an active query must produce a non-empty payload")++ // Every payload key must address a section that exists in the+ // re-emitted document. Before the fix the snapshot still carries the+ // pre-reload DOM ids, which match no element (T-1751).+ let freshIDs = Set(BlockDOMID.map(blocks: session.parsedBlocks).map(\.domID))+ for domID in payloadBlocks.keys {+ #expect(+ freshIDs.contains(domID),+ "replayed search snapshot addresses a DOM id that no longer exists: \(domID)"+ )+ }+ }+}
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9d6c8b3..b915337 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Search highlights survive a reload that rewords the matching text (T-1751). If a file changed on disk — or a URL document was refreshed — while a search was active, and the edit reworded the matching passages without changing how many matches each block had, the reloaded page showed stale highlights or none at all, while the match counter and navigation stayed correct. Highlights are addressed to blocks by their content, which the rewrite changed, but the trigger that re-sends them only watched the match numbers, which the rewrite did not. The trigger now watches block identity as well, so the reloaded page is sent highlights that address the blocks it actually shows. - Writing a footnote reference so readers can see it now shows it (T-1716). `` `[^1]` `` in inline code came out as an empty code span followed by a tappable footnote badge, so a document explaining footnote syntax could not display it — and the text on screen no longer matched the text you could select or copy. Inline code now renders the reference exactly as written, and so does the other way of writing one literally, `\[\^1\]`, whichever order the two forms appear in and however closely they sit together. Two more faults went with the same change: text after a reference followed by four or more spaces (`word[^1] more`) was silently dropped (T-1945), and emphasis wrapped around a reference (`*em [^1] end*`) leaked literal asterisks instead of italicising. The space separating a badge from the following word is rendered again too, so selecting the text after a badge selects what you see. A reference written inside a link address, inside an image's alt text, or as HTML character references (`[^1]`) stays literal text as well. A link whose caption contains a reference — `[the citation [^1]](https://example.com)` — now renders as a working link showing the reference as written: a badge cannot go there, because a footnote badge is itself a link and one link cannot sit inside another. Before this release that caption was not a link at all; the reference broke it into plain text either side of a badge. Footnotes inside list items and table cells still carry the separate note-anchoring limitation tracked under T-1941. - The **+** button for adding a note to a block is easier to see on the dark themes (T-1980). It rests at a deliberately low opacity so it does not compete with the text beside it, but that single value was tuned for the light themes: the glyph is drawn in the same muted grey the themes use for de-emphasised text, which fades much faster against a dark background than a light one. Prism Dark and Classic Dark now rest a little brighter. It was hardest to spot on a Mac, which sat at the dimmest setting and relied on hovering to bring the button up — iPhone and iPad were already lifted, since there is no pointer to hover with. The light themes are unchanged, hovering still brings the button to full strength, and turning on the system **Increase Contrast** setting still removes the fading entirely. - The **Body Font** you choose in Settings now applies to the document (T-1827), and iOS **Larger Text** (Dynamic Type) now scales it (T-1828). Since the WebKit rendering cutover the document was drawn at a fixed system font and a fixed base size: picking a body font moved only the preview in Settings, and raising Larger Text scaled the app's toolbars, sidebars, and panels while paragraphs, headings, lists, and tables stayed put. Body text, headings, lists, and tables now use the selected family — code blocks and inline code stay monospace — and the document's base size follows the system text size, combined with the in-app Text Size slider rather than replaced by it. Both follow changes live, without reloading the document, and both survive a WebKit process recovery. Because a size or family change reflows the text, where you were reading can shift on screen; re-anchoring the reading position across a reflow is tracked separately. A font that is no longer installed falls back to the system font instead of failing, and a font name is applied as text only, so it cannot alter the document's styling. On Mac, document text also returns to the 15pt reading size the app used before the rendering engine changed — the engine cutover had left it at the iOS size, which is larger than intended on a Mac and left no room below the Text Size slider's 80% minimum. Mac documents now follow the system Text Size setting as well.
If you want certainty on finding 1's residual case: toggle comment visibility with an active query whose matches all sit outside comments and the cursor on match 0, and confirm the page's highlights are unchanged-and-correct (the expectation is the un-pushed payload is byte-identical, making the missed push harmless).
When the load task wins the race against the .onChange push, the page briefly receives a replayed payload addressing pre-reload DOM ids before the fresh push lands. Verified harmless — prism-search.js simply finds no matching sections — but if a future change makes unknown ids an error rather than a no-op, this window becomes visible.
Main advanced (PR #334 touched MarkdownBlock.swift/SearchStateFeeder.swift); this branch touches neither, the three-dot diff equals the merge-base diff, and the new tests exercise SearchStateFeeder only through its public seam — but the full suite has not been run on a merge of the two. The pre-push make test/make build-* gate covers this.