PR #336 — search current-match footnote badges resolved per-section with occurrence-aware identity. Badge eligibility (which [^id] occurrences actually render as badges) is captured as a byproduct of the document emit and cached per parseRevision, so the search hot path never re-runs the inline renderer. Reviewed against the merge-base diff origin/main...HEAD after three local-review rounds.
querySelector.SearchStateFeeder.CurrentMatch.badge now carries (id, occurrence); prism-search.js resolves badges inside the state entry's own <section> and indexes by occurrence, clamped to the section's last badge.[^1] written in a code span resolves to a footnote (its definition content occupies match-ordinal space) but renders no badge — eligibility comes from the render pipeline itself (InlineHTMLRenderer walker), never re-approximated, so non-badging occurrences cannot skew the DOM index.EmittedDocument.badgeSourceStarts cached on the session; the renderer fallback only fires in the documented post-reparse window.Ready to push
No blockers, no majors. The core correctness questions all verify: the eligibility flags index-align with the footnote match array by construction (identical iteration, regex, and resolvable filter), every emitter inline path routes through the single renderInline choke point so the capture is complete and DOM order matches the feeder's scan, the source-string cache key is sound (pure function of source + footnotes, revision-guarded in one tuple), and clearBadges runs on every push so attributes cannot go stale in practice. Reuse is clean — cssEscape and the per-section lookup pattern from prism-notes.js are reused, and eligibility comes from the walker rather than a re-implementation. Targeted suites pass 34/34 (WebSearchBridgeTests + OffMainEmitTests, result-bundle verified) and SwiftLint reports zero violations. Five minors and three nits are recorded as follow-up candidates — two one-token defensive JS hardenings, a structural-alignment refactor, capture-breadth gating, and the Mutex-contention angle on the documented fallback — none of which block the push after three completed review rounds.
4b3909e T-1853: Add failing regression tests for repeated footnote refs search badge bbe626c Fix T-1853: Resolve search badges per-section with occurrence-aware identity 42c0dc4 T-1853 review: derive badge occurrence from the render pipeline's eligibility 3c42871 T-1853 review round 2: badge eligibility off the search hot path 10dc600 T-1853 review round 3: honest fallback docs, clamp-to-last tests, CHANGELOG Prism shows footnotes as small numbered pill badges in the text. When you search for words that appear in a footnote's definition, the app highlights the badge that points at that footnote and scrolls to it as you step through matches. Before this fix, if the same footnote was referenced in more than one place, the app always jumped to the first badge in the whole document, no matter which reference the current match actually belonged to — and the one you had actually stepped to never even lit up.
Repeated references to one footnote are common (citations, recurring terms). Search stepping that keeps snapping back to the top of the document makes the feature useless for those documents.
[^1]. The same footnote id can legally appear on several badges.<section> with a unique DOM id. The fix looks for badges only inside the section that owns the current match, instead of the whole page.`[^1]` written in inline code is shown literally, not as a badge — but its footnote's text still counts as a search match. The fix knows these render no badge and steps around them.SearchStateFeeder.swift: CurrentMatch.badge(id:) → .badge(id:occurrence:); new badgeEligibility(of:context:cachedStarts:) computes, per reference occurrence, whether it renders a DOM badge; currentMatch counts prior same-id badge-eligible entries to produce the badge's DOM index.prism-search.js: badgesInSection(domID, id) + resolveCurrentBadge(domID, current) replace the two document-global querySelector calls in renderBadges and the scroll path; occurrence is clamped to the section's badge list.InlineHTMLRenderer.swift: the walker records each emitted badge's UTF-16 source offset (badgeSourceStarts, DOM order); a static helper re-runs the full scan+parse+walk when no cache is available.BlockHTMLEmitter.swift: the emit captures badgeStartsBySource: [String: [Int]] into EmittedDocument.badgeSourceStarts (skipped entirely for footnote-less documents).DocumentSession.swift / WebDocumentControllerFactory.swift: the per-parseRevision HTML cache tuple grows a third element; searchStateJSON supplies the feeder a (String) -> [Int]? lookup closure into it.Native stays the source of truth: the feeder resolves the current match into (section, id, occurrence) and JS only indexes the DOM. Eligibility is deliberately read from the render pipeline's own output — the walker's position rules (code spans, escapes, link nesting) decide what badges — matched back to the feeder's regex occurrences by UTF-16 offset, never re-derived with a second approximation of those rules.
[String:[Int]] sidecar riding the HTML cache.The within-block ordinal space is [text matches…][footnote matches in source order]. footnoteMatchCounts and badgeEligibility iterate the identical inlineSources(of:) sequence with the identical FootnoteData.referencePattern and resolvable-definition filter (one filters via continue, the other via a where clause), so the eligibility flags index-align with the FootnoteMatch array by construction. currentMatch counts prior same-id entries with a true flag — including count == 0 entries, correctly, since a badge exists regardless of whether its definition matches the query. A missing flag (defensive count mismatch) degrades to eligible, i.e. the pre-fix behaviour, rather than dropping occurrences.
Every emitter inline path (paragraphs, headings, blockquote children, list items and their child paragraphs, table headers/cells, details summaries) routes through renderInline, which records the capture — so the sidecar is complete, and DOM order within a section provably matches the feeder's source-order scan. Keying by raw source string is sound because badge starts are a pure function of (source, footnotes): the walker parses the source standalone, so identical strings in different blocks produce identical starts. An empty array is a meaningful entry ("badges nowhere"), distinct from a nil miss.
The change stays inside the established native-truth/JS-projection contract: the bridge payload gains one integer field, the JS gains no policy (it clamps, it does not classify). The emit remains a pure function of blocks+footnotes+settings; the sidecar rides the existing revision-guarded cache tuple, so revision, HTML, and starts cannot drift apart, and staleness falls out of the existing parseRevision guard.
renderBadges now marks every same-id badge in the section for matched footnotes (previously one document-global badge) — visually correct per Req 7.2, with per-entry querySelectorAll cost bounded by badges-per-section.SearchStateFeeder.swift
Why it matters. The core of the fix: the bridge payload's current-badge descriptor gains a DOM index, computed by counting prior same-id badge-eligible reference occurrences. Correctness rests on the eligibility flags index-aligning with the footnoteMatches array.
What to look at. prism/Services/SearchStateFeeder.swift:37-52 (CurrentMatch), 304-321 (badgeEligibility), 326-362 (currentMatch)
InlineHTMLRenderer.swift
Why it matters. Eligibility is captured where badges are actually decided (the walker's text-position + linkDepth rules), eliminating the re-approximation failure class the T-1716 redesign removed.
What to look at. prism/Services/WebRendering/InlineHTMLRenderer.swift:437-470 (Result.badgeSourceStarts + static helper), 483-492 (walker capture)
BlockHTMLEmitter.swift
Why it matters. Keeps the search hot path free of renderer work: EmittedDocument grows badgeSourceStarts, stored in the session's revision-guarded cache tuple next to the HTML so revision/html/starts can never drift apart.
What to look at. prism/Services/WebRendering/BlockHTMLEmitter.swift:33-46 (EmittedDocument), 732-739 (capture); prism/Models/DocumentSession.swift:147-198
prism-search.js
Why it matters. The user-visible half of the fix: document-global querySelector (always the first badge) is replaced by badgesInSection + resolveCurrentBadge for both marking and scrolling; matched-set marking now covers every same-id badge in the section.
What to look at. prism/Resources/WebRenderer/prism-search.js:95-171 (badgesInSection, resolveCurrentBadge, renderBadges), 272-285 (scroll path)
DocumentSession.swift
Why it matters. Round 3 corrected the docs: the miss between a reparse and the off-main precompute storing is deterministic (parsedBlocks' didSet pushes search state synchronously, before the store lands), not a rare race — important for anyone reasoning about MainActor render cost.
What to look at. prism/Models/DocumentSession.swift:69-80; prism/Services/SearchStateFeeder.swift:74-84, 292-303
WebSearchBridgeTests.swift
Why it matters. Behaviour is pinned at both layers: payload shape (occurrence encoding, look-alike skipping, past-the-end occurrence) natively, and actual DOM marking/scrolling/clamping in a live WebPage via WebDocumentLiveHarness.
What to look at. prismTests/WebRendering/WebSearchBridgeTests.swift:665-1008; prismTests/WebRendering/OffMainEmitTests.swift:613-632
A [^id]-shaped occurrence in a code span resolves to a footnote (its definition content occupies match-ordinal space) but renders no badge. Counting it would push the occurrence index off the section's real badge list — with the old fallback landing on the first badge. Eligibility therefore comes from the render pipeline's own output, matched by UTF-16 source offset.
The look-alike has no DOM badge of its own, but its match must land somewhere visible. The feeder's prior-eligible count naturally addresses the nearest following badge; when none follows, the payload carries a past-the-end occurrence and resolveCurrentBadge clamps to the section's last badge. Degradation is pinned in both native and live tests rather than left implicit.
Round 2 rejected running the inline renderer on every search push (MainActor, SwiftSoup-adjacent cost). The starts are captured during the emit that already runs off-main, keyed by source string (pure function of (source, footnotes)), and stored in the same revision-guarded tuple as the HTML so staleness is inherited, not re-implemented. Footnote-less documents skip the capture — the map would just duplicate every source string.
Any reparse during an active query pushes search state before the new revision's precompute stores (parsedBlocks' didSet is synchronous), so the miss window is hit deterministically, not rarely. Accepted because the fallback is lazy (only the block owning the current match, only when that match is a footnote match) and bounded to that block's inline sources.
If the flags array is shorter than the match index (a count mismatch that should be impossible), the entry is treated as eligible — degrading to the pre-fix occurrence counting rather than silently dropping occurrences.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | prism-search.js clearBadges | clearBadges selects only [data-prism-search-match] and strips both attributes from those elements. Today 'current' implies 'matched' (the current badge's footnote necessarily has count > 0, so every same-id badge in the section gets the match attribute first), but that invariant lives entirely on the native side — a payload carrying current without the id in matchedFootnoteIds would leave a data-prism-search-current no future clearBadges can find, persisting until reload. One-token hardening: querySelectorAll("[data-prism-search-match], [data-prism-search-current]"). | Reported for a follow-up; defensive-only today (native always upholds the invariant), and hardening JS behaviour is out of scope for a report-only review after three fix rounds. |
| minor | prism-search.js resolveCurrentBadge | typeof current.occurrence === "number" admits NaN and fractional values: NaN < 0 and NaN >= badges.length are both false, so badges[NaN] is undefined and the function silently returns no badge — violating its own 'clamped, never dropped' contract. Native always sends an Int, so defensive-only. Number.isInteger(current.occurrence) ? current.occurrence : 0 closes it. | Reported for a follow-up; unreachable from the production feeder. |
| minor | SearchStateFeeder scan alignment | The index alignment badgeEligibility depends on is maintained by convention: two hand-kept copies of the same iterate-sources → regex → resolvable-filter loop (referencedFootnoteIdentifiers at :229-240 vs badgeEligibility at :304-321), with a doc comment promising they match. A shared helper returning [(identifier, utf16Offset)] consumed by both would make the one-to-one alignment structural instead of documented. | Verified correct as written (same iteration, regex, and filter — alignment holds today). Recommended as the highest-value follow-up refactor; restructuring production code is out of scope for this report-only pass. |
| minor | BlockHTMLEmitter capture breadth | With any footnote present, badgeStartsBySource records an entry for EVERY inline source in the document (every table cell, heading, list item), though character data is CoW-shared with parsedBlocks — real cost is ~40-50 bytes of dictionary metadata per entry, low single-digit MB for a table-heavy 10MB document. Relatedly, badgeEligibility computes starts (fallback render on a miss) before checking whether the source has any resolvable reference, so a miss-window current match in a large table renders every cell. Coupled fix: skip reference-free sources in badgeEligibility first, then gate the emitter's recording to reference-bearing sources (emitter-side gating alone would turn reference-free sources into fallback renders). | Reported for a follow-up; acceptable at current scale and the two halves must ship together. |
| minor | Cache-miss fallback vs SwiftSoup Mutex | The fallback InlineHTMLRenderer.badgeSourceStarts runs the full render including HTMLSanitizer (Mutex-serialised SwiftSoup) on the MainActor, and the miss window is precisely when the new revision's off-main precompute is actively taking the same Mutex — a current-match block containing raw inline HTML can briefly stall the main thread on lock contention during typing-while-reparsing. Waits are per-pass and short. Possible outs: an eligibility-only walk mode that skips sanitise/HTML assembly, or treating the deterministic miss as all-eligible (the degradation the defensive count-mismatch path already codifies) until the precompute lands. | Reported for a follow-up; the accepted-cost decision is documented in the code, this sharpens it with the lock-contention angle. |
| nit | SearchStateFeeder.badgeEligibility offsets | source.utf16.distance(from: startIndex, to:) per reference occurrence is O(block length) each, making the eligibility scan O(n*m) for one block. Bounded to a single block per push. | Skipped — only worth touching if the capture-breadth restructure happens. |
| nit | BlockHTMLEmitter.renderInnerBlock default branch | Unexpected nested kinds (.html/.metadata/.details as blockquote or list-item children) render via renderInline(block.textContent), which could emit badges from a source string the feeder's inlineSources(of:) never enumerates — a latent occurrence-space skew guarded by a parser invariant the emitter's own comment states. | Skipped — latent edge behind a stated parser invariant; a cross-reference comment at most. |
| nit | InlineHTMLRenderer walker fallback start | occurrence?.sourceStart ?? cursor can record a start differing from the regex-match offset on the defensive nil-occurrence path, flagging a real badge ineligible and shifting the occurrence by one; the JS clamp bounds degradation to a neighbouring badge and the code documents the cost. | Skipped — documented, bounded, defensive-only. |
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9d6c8b3..e3a8694 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 +- Searching for text that lives in a footnote's definition now highlights and scrolls to the right reference badge when the same footnote is referenced more than once (T-1853). Badges were looked up document-wide by footnote number, so stepping to the match belonging to a later reference always marked and scrolled to the document's first badge — the one actually selected never even showed as matched. Each reference's badge is now resolved within its own block, and repeated references inside a single block are told apart by position, so stepping through matches visits each badge in turn. Text that merely looks like a reference — `` `[^1]` `` written in inline code — still occupies its place in the match order but renders no badge, so stepping onto it marks the nearest following badge in the block, or the last one when none follows. - 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.
diff --git a/prism/Models/DocumentSession.swift b/prism/Models/DocumentSession.swiftindex c236eee..67882ce 100644--- a/prism/Models/DocumentSession.swift+++ b/prism/Models/DocumentSession.swift@@ -147,23 +147,33 @@ final class DocumentSession: Identifiable { // MARK: - Emitted HTML Cache (T-1681) - /// The document HTML (excluding the per-serve generation island) and the `parseRevision`- /// it was emitted for. The emit runs off the MainActor- /// (`WebDocumentControllerFactory.precomputeDocumentHTML`); this property is *written* on- /// the MainActor, so the scheme handler serves cached HTML instead of running the full- /// emit + sanitise on the MainActor on every serve — the initial load, a WebContent-- /// recovery reload, and the iOS folder-access re-navigation all reuse it. One optional- /// tuple (not two parallel optionals) so revision and html can never drift apart.+ /// The document HTML (excluding the per-serve generation island), the emit-captured+ /// badge source starts, and the `parseRevision` they were emitted for. The emit runs+ /// off the MainActor (`WebDocumentControllerFactory.precomputeDocumentHTML`); this+ /// property is *written* on the MainActor, so the scheme handler serves cached HTML+ /// instead of running the full emit + sanitise on the MainActor on every serve — the+ /// initial load, a WebContent-recovery reload, and the iOS folder-access re-navigation+ /// all reuse it. `badgeSourceStarts` (`EmittedDocument.badgeSourceStarts`) rides in the+ /// same tuple so `SearchStateFeeder`'s badge-occurrence identity reads the render+ /// pipeline's eligibility without re-running the inline renderer on the search hot+ /// path (T-1853 review round 2). One optional tuple (not parallel optionals) so+ /// revision, html, and starts can never drift apart. /// `@ObservationIgnored`: written during a serve and read by the scheme handler, never in /// a view body, so it must not drive SwiftUI updates.- @ObservationIgnored private var cachedDocument: (revision: UInt64, html: String)?-- /// Stores emitted HTML for `revision`, dropping a stale write whose revision no longer- /// matches the live `parseRevision` (a precompute that finished after a newer parse- /// started). Keeps the cache from ever holding HTML for a superseded revision.- func storeCachedDocumentHTML(_ html: String, for revision: UInt64) {+ @ObservationIgnored private var cachedDocument:+ (revision: UInt64, html: String, badgeSourceStarts: [String: [Int]])?++ /// Stores emitted HTML plus its emit-captured badge source starts for `revision`,+ /// dropping a stale write whose revision no longer matches the live `parseRevision`+ /// (a precompute that finished after a newer parse started). Keeps the cache from+ /// ever holding output for a superseded revision.+ func storeCachedDocumentHTML(+ _ html: String,+ badgeSourceStarts: [String: [Int]],+ for revision: UInt64+ ) { guard revision == parseRevision else { return }- cachedDocument = (revision: revision, html: html)+ cachedDocument = (revision: revision, html: html, badgeSourceStarts: badgeSourceStarts) } /// The cached HTML iff it was emitted for the current `parseRevision`; nil once a reparse@@ -178,6 +188,19 @@ final class DocumentSession: Identifiable { cachedDocument.flatMap { $0.revision == parseRevision ? $0.html : nil } } + /// The emit-captured badge source starts for `source`, iff the cache holds the+ /// current `parseRevision`. Nil on a miss (no emit yet for this revision, or a+ /// source string the emitter never rendered) — the caller falls back to computing+ /// eligibility via the renderer, mirroring `emitHTML`'s own cache-miss fallback.+ /// The revision-miss window is deterministic after a reparse, not a rare race:+ /// `parseAndApplyBlocks` assigns `parsedBlocks` (which synchronously triggers the+ /// search push via its `didSet`) while the new revision's off-main precompute is+ /// still in flight, so a search active across a reparse reads nil here until+ /// `storeCachedDocumentHTML` lands (T-1853 review round 3).+ func cachedBadgeSourceStarts(for source: String) -> [Int]? {+ cachedDocument.flatMap { $0.revision == parseRevision ? $0.badgeSourceStarts[source] : nil }+ }+ // MARK: - Table Display Mode State /// Tracks table display modes by composite block ID.
diff --git a/prism/Resources/WebRenderer/prism-search.js b/prism/Resources/WebRenderer/prism-search.jsindex 797742a..019c351 100644--- a/prism/Resources/WebRenderer/prism-search.js+++ b/prism/Resources/WebRenderer/prism-search.js@@ -162,23 +162,55 @@ } } - // Marks footnote badges by id for the matched set, and the current badge if the- // current match is a footnote-content match (Req 7.2).+ // Resolves the badges carrying footnote `id` INSIDE the block's own section.+ // Badges are per-reference-occurrence: the same footnote id can legally appear on+ // several badges (within one section and across sections), so resolution is+ // scoped to the state entry's domID section — never document-global, which would+ // always land on the document's first badge (T-1853). DOM order within the+ // section matches the feeder's source order over BADGE-RENDERING references:+ // the native side counts only occurrences the render pipeline actually badges,+ // so `[^id]`-shaped text that never badges (code spans, escapes, link-nested+ // text) cannot skew the index.+ function badgesInSection(domID, id) {+ var section = document.getElementById(domID);+ if (!section) { return []; }+ return section.querySelectorAll(+ "[data-prism-footnote=\"" + cssEscape(id) + "\"]"+ );+ }++ // The current-match badge for a state entry: the occurrence-th same-id badge+ // within the entry's section (occurrence-aware identity, T-1853). `occurrence`+ // is the badge's DOM index — natively computed over badge-rendering references+ // only. Clamped to the badge list, never reset to the first: when the current+ // match's owning reference renders no badge (a code-span/escaped/link-nested+ // look-alike whose definition content still occupies match-ordinal space), the+ // index addresses the next same-id badge, or the section's last one.+ function resolveCurrentBadge(domID, current) {+ var badges = badgesInSection(domID, current.id);+ if (!badges.length) { return null; }+ var occurrence = typeof current.occurrence === "number" ? current.occurrence : 0;+ if (occurrence < 0) { occurrence = 0; }+ if (occurrence >= badges.length) { occurrence = badges.length - 1; }+ return badges[occurrence];+ }++ // Marks footnote badges for the matched set — every badge referencing a matched+ // footnote within the entry's own section — and the current badge if the current+ // match is a footnote-content match (Req 7.2). function renderBadges(state) { for (var domID in state.blocks) { if (!Object.prototype.hasOwnProperty.call(state.blocks, domID)) { continue; } var entry = state.blocks[domID]; var matched = entry.matchedFootnoteIds || []; for (var i = 0; i < matched.length; i++) {- var badge = document.querySelector(- "[data-prism-footnote=\"" + cssEscape(matched[i]) + "\"]"- );- if (badge) { badge.setAttribute("data-prism-search-match", "true"); }+ var badges = badgesInSection(domID, matched[i]);+ for (var j = 0; j < badges.length; j++) {+ badges[j].setAttribute("data-prism-search-match", "true");+ } } if (entry.current && entry.current.kind === "badge") {- var currentBadge = document.querySelector(- "[data-prism-footnote=\"" + cssEscape(entry.current.id) + "\"]"- );+ var currentBadge = resolveCurrentBadge(domID, entry.current); if (currentBadge) { currentBadge.setAttribute("data-prism-search-current", "true"); }@@ -240,14 +272,13 @@ } return; }- // Current match is a footnote badge: bring its host badge into view.+ // Current match is a footnote badge: bring the owning host block's badge —+ // resolved within its own section, occurrence-aware (T-1853) — into view. for (var domID in state.blocks) { if (!Object.prototype.hasOwnProperty.call(state.blocks, domID)) { continue; } var entry = state.blocks[domID]; if (entry.current && entry.current.kind === "badge") {- var badge = document.querySelector(- "[data-prism-footnote=\"" + cssEscape(entry.current.id) + "\"]"- );+ var badge = resolveCurrentBadge(domID, entry.current); if (badge) { badge.scrollIntoView({ block: "center", behavior: "auto" }); } return; }
diff --git a/prism/Services/SearchStateFeeder.swift b/prism/Services/SearchStateFeeder.swiftindex 077fe3c..6ebe0f0 100644--- a/prism/Services/SearchStateFeeder.swift+++ b/prism/Services/SearchStateFeeder.swift@@ -15,10 +15,11 @@ // - matchedFootnoteIds: identifiers of footnotes referenced in the block whose // definition content matches the query, surfaced as badge indication (Req 7.2). // - current: whether the globally-current match falls in this block and, if so,-// whether it is a text match (by ordinal) or a footnote-content match (by id) —-// because native counts include appended footnote text that has no rendered text-// equivalent, the current badge match is addressed by id, never a text ordinal-// (design `setSearchState` row).+// whether it is a text match (by ordinal) or a footnote-content match (by id ++// reference occurrence) — because native counts include appended footnote text+// that has no rendered text equivalent, the current badge match is addressed by+// id and occurrence, never a text ordinal (design `setSearchState` row; the+// occurrence disambiguates repeated references to the same footnote, T-1853). // // The DOM ids are occurrence-qualified via the shared `BlockDOMID.map(blocks:)` // (`b-{contentHash}-{sourceIndex}`) — the same source of truth the emitter and@@ -38,8 +39,16 @@ enum SearchStateFeeder { /// among the block's text matches. case text(ordinal: Int) /// A match inside a footnote's content, addressed by the footnote identifier- /// (never a text ordinal — appended footnote text has no rendered equivalent).- case badge(id: String)+ /// plus the badge occurrence (never a text ordinal — appended footnote+ /// text has no rendered equivalent). `occurrence` is the 0-based index among+ /// the block's BADGE-RENDERING references to that SAME identifier in source+ /// order — i.e. the badge's DOM index within the section. References that+ /// resolve to a footnote but never render a badge (code spans, escaped+ /// tokens, link-nested text — `FootnoteReferenceScanner`/+ /// `InlineHTMLRenderer.Walker` own that classification) still occupy+ /// match-ordinal space, but are excluded from the occurrence count because+ /// they have no DOM badge to address (T-1853).+ case badge(id: String, occurrence: Int) } /// The search instruction for one rendered block.@@ -62,6 +71,17 @@ enum SearchStateFeeder { /// - matchCountsPerBlock: SearchCoordinator's per-block total counts (index i ↔ block i). /// - currentGlobalMatchIndex: The globally-selected match ordinal, or nil. /// - context: The search context (HTML-comment visibility + footnote data).+ /// - badgeSourceStarts: Looks up the emit-captured badge source starts for an+ /// inline source string (`EmittedDocument.badgeSourceStarts`, cached on the+ /// session per `parseRevision`). Nil on a miss, in which case eligibility is+ /// computed via `InlineHTMLRenderer.badgeSourceStarts` — correct but a full+ /// inline render on the MainActor. The production feed path+ /// (`WebDocumentControllerFactory.searchStateJSON`) supplies the cache lookup,+ /// but a miss there is deterministic, not a rare race: any reparse while a+ /// query is active pushes search state before the new revision's off-main+ /// emit has stored, so the push(es) until the precompute lands fall back.+ /// Acceptable because the cost is bounded to rendering the current-match+ /// block's inline sources (T-1853 review round 3). /// - Returns: One `BlockSearchState` per block that has at least one match or a /// matched footnote. Blocks with nothing to show are omitted. static func buildStates(@@ -69,7 +89,8 @@ enum SearchStateFeeder { blocks: [MarkdownBlock], matchCountsPerBlock: [Int], currentGlobalMatchIndex: Int?,- context: SearchContext+ context: SearchContext,+ badgeSourceStarts: (String) -> [Int]? = { _ in nil } ) -> [BlockSearchState] { guard !query.isEmpty else { return [] } @@ -111,7 +132,10 @@ enum SearchStateFeeder { let current = currentMatch( indexInBlock: info?.currentMatchIndexInBlock, textMatchCount: textMatchCount,- footnoteMatches: footnoteMatches+ footnoteMatches: footnoteMatches,+ badgeEligibilityForBlock: {+ badgeEligibility(of: block, context: context, cachedStarts: badgeSourceStarts)+ } ) states.append(BlockSearchState(@@ -138,8 +162,8 @@ enum SearchStateFeeder { switch state.current { case .text(let ordinal): entry["current"] = ["kind": "text", "ordinal": ordinal]- case .badge(let id):- entry["current"] = ["kind": "badge", "id": id]+ case .badge(let id, let occurrence):+ entry["current"] = ["kind": "badge", "id": id, "occurrence": occurrence] case nil: break }@@ -159,14 +183,16 @@ enum SearchStateFeeder { blocks: [MarkdownBlock], matchCountsPerBlock: [Int], currentGlobalMatchIndex: Int?,- context: SearchContext+ context: SearchContext,+ badgeSourceStarts: (String) -> [Int]? = { _ in nil } ) -> String { let states = buildStates( query: query, blocks: blocks, matchCountsPerBlock: matchCountsPerBlock, currentGlobalMatchIndex: currentGlobalMatchIndex,- context: context+ context: context,+ badgeSourceStarts: badgeSourceStarts ) return encode(query: query, states: states) }@@ -251,24 +277,84 @@ enum SearchStateFeeder { return sources } + /// Badge-eligibility flags aligned one-to-one with `footnoteMatchCounts`'s+ /// entries (same inline-source iteration, same reference regex, same+ /// resolvable-identifier filter). A flag is true when that reference occurrence+ /// actually renders as a badge in the DOM.+ ///+ /// Eligibility comes from the render pipeline itself+ /// (`FootnoteReferenceScanner` candidacy plus the walker's position rules),+ /// matched back to each regex occurrence by its UTF-16 source offset — never+ /// re-approximated here, which is the failure class the badge redesign removed+ /// (T-1853 review). A `[^id]`-shaped occurrence that resolves but never badges+ /// (code span, escape, link nesting) gets `false`.+ ///+ /// `cachedStarts` supplies the starts the document emit already captured+ /// (`EmittedDocument.badgeSourceStarts`, cached per `parseRevision`), so the+ /// steady state runs no renderer on the search hot path. A miss — the emit for+ /// the current revision has not stored yet, or a source string the emitter never+ /// rendered — falls back to `InlineHTMLRenderer.badgeSourceStarts`, a full+ /// inline render on the MainActor. The miss is deterministic, not a rare race:+ /// `DocumentSession.parseAndApplyBlocks` assigns `parsedBlocks` (whose `didSet`+ /// recomputes match counts, which triggers the search push) before the new+ /// revision's off-main precompute has stored, so a reparse during an active+ /// query whose current match sits on a footnote occurrence takes this path on+ /// every push until the precompute lands. Accepted because the scope is one+ /// block's inline sources per push (T-1853 review round 3).+ private static func badgeEligibility(+ of block: MarkdownBlock,+ context: SearchContext,+ cachedStarts: (String) -> [Int]?+ ) -> [Bool] {+ var flags: [Bool] = []+ for source in inlineSources(of: block) {+ let starts = cachedStarts(source)+ ?? InlineHTMLRenderer.badgeSourceStarts(source: source, footnotes: context.footnoteData)+ let badgeStarts = Set(starts)+ for match in source.matches(of: FootnoteData.referencePattern)+ where context.footnoteData.definition(for: String(match.1)) != nil {+ let offset = source.utf16.distance(from: source.startIndex, to: match.range.lowerBound)+ flags.append(badgeStarts.contains(offset))+ }+ }+ return flags+ }+ /// Resolves the current within-block match index into a text ordinal or a /// footnote badge id. The within-block ordinal space is [text matches…][footnote /// matches in source order], matching `combinedSearchableText`'s concatenation. private static func currentMatch( indexInBlock: Int?, textMatchCount: Int,- footnoteMatches: [FootnoteMatch]+ footnoteMatches: [FootnoteMatch],+ badgeEligibilityForBlock: () -> [Bool] ) -> CurrentMatch? { guard let index = indexInBlock, index >= 0 else { return nil } if index < textMatchCount { return .text(ordinal: index) } // The current match is in the appended footnote portion: walk the footnote- // matches in order and find which one owns this overflow position.+ // matches in order and find which reference occurrence owns this overflow+ // position. The occurrence is the winner's DOM badge index: the count of+ // PRIOR same-id entries that actually render as badges. Entries that resolve+ // but never badge (code span, escape, link nesting) occupy ordinal space —+ // their definition content is appended to the searchable text — but have no+ // DOM badge, so counting them would skew the index off the section's real+ // badge list (T-1853 review). Eligibility is resolved lazily and only for+ // the one block that owns the current match — normally a lookup of the+ // emit-captured starts, with a renderer fallback only on a cache miss. var remaining = index - textMatchCount- for footnote in footnoteMatches where footnote.count > 0 {+ for (matchIndex, footnote) in footnoteMatches.enumerated() where footnote.count > 0 { if remaining < footnote.count {- return .badge(id: footnote.identifier)+ let eligibility = badgeEligibilityForBlock()+ // Defensive: a missing flag (count mismatch) is treated as eligible,+ // which degrades to the pre-eligibility behaviour rather than+ // silently dropping occurrences.+ let occurrence = (0..<matchIndex).count {+ footnoteMatches[$0].identifier == footnote.identifier+ && ($0 < eligibility.count ? eligibility[$0] : true)+ }+ return .badge(id: footnote.identifier, occurrence: occurrence) } remaining -= footnote.count }
diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex 3219189..7734b9f 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -33,6 +33,15 @@ nonisolated struct EmittedDocument: Equatable, Sendable { let html: String /// The block-ordered, anchor-based source map (UTF-16 offsets). let sourceMap: DocumentSourceMap+ /// Per inline source string, the UTF-16 offsets of the `[^id]` occurrences that+ /// actually rendered as badges, in DOM order — the render pipeline's badge+ /// eligibility, captured as a byproduct of the emit (T-1853 review round 2).+ /// Badge starts are a pure function of (source, footnotes), so the source string+ /// is a sound key; the same string in two blocks records the same value.+ /// `SearchStateFeeder` reads this (cached per `parseRevision` on the session) so+ /// the search hot path never re-runs the inline renderer on the MainActor.+ /// Empty when the document has no footnotes.+ let badgeSourceStarts: [String: [Int]] } nonisolated enum BlockHTMLEmitter {@@ -47,6 +56,10 @@ nonisolated enum BlockHTMLEmitter { var nextRunID = 0 /// Runs collected for the block currently being emitted. var blockRuns: [DocumentSourceMap.Run] = []+ /// Badge source starts per inline source rendered so far (see+ /// `EmittedDocument.badgeSourceStarts`). Only populated when the document+ /// has footnotes — without them no source can badge.+ var badgeStartsBySource: [String: [Int]] = [:] init(footnotes: FootnoteData, settings: RenderSettings) { self.footnotes = footnotes@@ -84,7 +97,11 @@ nonisolated enum BlockHTMLEmitter { let sourceMap = DocumentSourceMap(runs: allRuns) let html = wrapDocument(body: body, sourceMap: sourceMap, settings: settings)- return EmittedDocument(html: html, sourceMap: sourceMap)+ return EmittedDocument(+ html: html,+ sourceMap: sourceMap,+ badgeSourceStarts: context.badgeStartsBySource+ ) } // MARK: - Document scaffold@@ -715,6 +732,14 @@ nonisolated enum BlockHTMLEmitter { source: source, footnotes: context.footnotes, runIDAllocator: &context.nextRunID ) if recordRuns { context.blockRuns.append(contentsOf: result.runs) }+ // Capture the render pipeline's badge eligibility for this source (see+ // `EmittedDocument.badgeSourceStarts`). An empty array is a meaningful entry —+ // "this source badges nowhere" — so it is recorded too; only footnote-less+ // documents skip the capture entirely (nothing can badge, and the map would+ // just duplicate every source string).+ if !context.footnotes.isEmpty {+ context.badgeStartsBySource[source] = result.badgeSourceStarts+ } return result.html }
diff --git a/prism/Services/WebRendering/InlineHTMLRenderer.swift b/prism/Services/WebRendering/InlineHTMLRenderer.swiftindex 5956f9b..183ed7b 100644--- a/prism/Services/WebRendering/InlineHTMLRenderer.swift+++ b/prism/Services/WebRendering/InlineHTMLRenderer.swift@@ -37,6 +37,10 @@ nonisolated struct InlineHTMLRenderer { struct Result { var html: String var runs: [DocumentSourceMap.Run]+ /// UTF-16 source offsets of the `[^id]` occurrences that actually rendered+ /// as badges, in emission (DOM) order. Occurrences the walker restored to+ /// literal text (code spans, link nesting, escapes, …) are absent.+ var badgeSourceStarts: [Int] } /// Renders `source` (a block's inline markdown text) to HTML, allocating run IDs@@ -74,7 +78,27 @@ nonisolated struct InlineHTMLRenderer { renderer.visit(document) renderer.closeRun() runIDAllocator = renderer.nextRunID- return Result(html: renderer.html, runs: renderer.runs)+ return Result(+ html: renderer.html,+ runs: renderer.runs,+ badgeSourceStarts: renderer.badgeSourceStarts+ )+ }++ /// The UTF-16 source offsets of the `[^id]` occurrences in `source` that actually+ /// render as badges, in DOM (emission) order.+ ///+ /// Runs the same scan + parse + walk as `render`, so badge eligibility comes from+ /// the authoritative pipeline — `FootnoteReferenceScanner` candidacy plus the+ /// walker's position rules (text position, no `Link` ancestor) — never from a+ /// re-derived approximation (T-1853 review). Callers that need to know which+ /// source references have a DOM badge (e.g. `SearchStateFeeder`'s occurrence+ /// identity) match these offsets against their own occurrence scan.+ static func badgeSourceStarts(source: String, footnotes: FootnoteData) -> [Int] {+ guard !footnotes.isEmpty else { return [] }+ var allocator = 0+ return render(source: source, footnotes: footnotes, runIDAllocator: &allocator)+ .badgeSourceStarts } // MARK: - Walker@@ -94,6 +118,9 @@ nonisolated struct InlineHTMLRenderer { var runs: [DocumentSourceMap.Run] = [] var nextRunID: Int + /// Source starts of the badges emitted so far, in emission (DOM) order.+ var badgeSourceStarts: [Int] = []+ /// UTF-16 cursor into the source: the next unmatched source position. Locating /// rendered text always scans forward from here so repeated substrings map to /// successive source occurrences in document order.@@ -291,6 +318,12 @@ nonisolated struct InlineHTMLRenderer { return } closeRun()+ // Record the badge's source position (emission order == DOM order). The+ // occurrence's own start is authoritative; when it is nil (defensive), the+ // cursor sits at the marker's start because the preceding text was just+ // located — an approximation that costs eligibility-matching precision for+ // one occurrence, never badge correctness.+ badgeSourceStarts.append(occurrence?.sourceStart ?? cursor) // Anchoring on the occurrence's own source range keeps the cursor exact even // when the preceding text did not locate verbatim (escaped or entity-encoded // text does not). The marker is length-preserving, so the fallback step is the
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex 2e1fc10..4a7c945 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -165,8 +165,13 @@ enum WebDocumentControllerFactory { // Cache miss (a serve raced ahead of the off-main precompute). Emit synchronously // on the MainActor — the pre-T-1681 behaviour — and store the result so the next // same-revision serve (e.g. a WebContent-recovery replay) hits the cache.- html = renderDocumentHTML(for: session, settings: settings)- session.storeCachedDocumentHTML(html, for: session.parseRevision)+ let emitted = renderDocument(for: session, settings: settings)+ html = emitted.html+ session.storeCachedDocumentHTML(+ emitted.html,+ badgeSourceStarts: emitted.badgeSourceStarts,+ for: session.parseRevision+ ) } // The generation island is stamped per serve: `processGeneration` is the controller's // live value (bumped on a WebContent-crash reload), so it is NOT baked into the cached@@ -179,22 +184,23 @@ enum WebDocumentControllerFactory { return WebDocumentLoader.injectGeneration(generation, into: html) } - /// Builds the document HTML (without the generation island) for `session`. Shared by the- /// off-main precompute and the synchronous cache-miss fallback so both produce identical- /// output; a pure function of the session's blocks + footnotes + `RenderSettings`.+ /// Builds the document (HTML without the generation island, plus the emit-captured+ /// badge source starts) for `session`. Shared by the off-main precompute and the+ /// synchronous cache-miss fallback so both produce identical output; a pure function+ /// of the session's blocks + footnotes + `RenderSettings`. /// /// The selection "Add note" affordance is a native SwiftUI overlay on both platforms /// (T-1542): the emitter ships no in-page pill — the WebKit native selection callout would /// cover it. Selection state is reported via the selectionCandidate bridge message.- private static func renderDocumentHTML(+ private static func renderDocument( for session: DocumentSession, settings: AppSettings- ) -> String {+ ) -> EmittedDocument { BlockHTMLEmitter.emit( blocks: session.parsedBlocks, footnotes: session.footnoteData, settings: documentRenderSettings(from: settings)- ).html+ ) } /// The `RenderSettings` for the document surface: the comment-visibility flag plus@@ -226,11 +232,13 @@ enum WebDocumentControllerFactory { // MainActor caller's executor and would emit ON the main thread. Detaching forces the // full emit + per-block SwiftSoup sanitise onto the cooperative pool — mirroring the // parse offload in DocumentSession.parseAndApplyBlocks. Inputs are all Sendable.- let html = await Task.detached(priority: .userInitiated) {- BlockHTMLEmitter.emit(blocks: blocks, footnotes: footnotes, settings: renderSettings).html+ let emitted = await Task.detached(priority: .userInitiated) {+ BlockHTMLEmitter.emit(blocks: blocks, footnotes: footnotes, settings: renderSettings) }.value guard !Task.isCancelled else { return }- session.storeCachedDocumentHTML(html, for: revision)+ session.storeCachedDocumentHTML(+ emitted.html, badgeSourceStarts: emitted.badgeSourceStarts, for: revision+ ) } /// Pushes the current theme, typography, and comment visibility onto the@@ -356,7 +364,13 @@ enum WebDocumentControllerFactory { context: SearchContext( showHTMLComments: settings.showHTMLComments, footnoteData: session.footnoteData- )+ ),+ // Badge-occurrence identity reads the emit-captured starts cached on the+ // session (keyed by parseRevision): steady-state pushes never re-run the+ // 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) } ) }
diff --git a/prismTests/WebRendering/OffMainEmitTests.swift b/prismTests/WebRendering/OffMainEmitTests.swiftindex c0e2fb7..33dda49 100644--- a/prismTests/WebRendering/OffMainEmitTests.swift+++ b/prismTests/WebRendering/OffMainEmitTests.swift@@ -135,7 +135,7 @@ nonisolated struct OffMainEmitTests { let before = session.currentCachedDocumentHTML #expect(before == nil, "nothing cached before a store") - session.storeCachedDocumentHTML("<html>cached</html>", for: revision)+ session.storeCachedDocumentHTML("<html>cached</html>", badgeSourceStarts: [:], for: revision) let after = session.currentCachedDocumentHTML #expect(after == "<html>cached</html>") }@@ -147,7 +147,7 @@ nonisolated struct OffMainEmitTests { await session.parseContent() let current = session.parseRevision - session.storeCachedDocumentHTML("<html>stale</html>", for: current &- 1)+ session.storeCachedDocumentHTML("<html>stale</html>", badgeSourceStarts: [:], for: current &- 1) let cached = session.currentCachedDocumentHTML #expect(cached == nil, "a stale-revision write must be dropped") }@@ -158,7 +158,7 @@ nonisolated struct OffMainEmitTests { let session = DocumentSession(clipboardContent: "# One") await session.parseContent() let revision = session.parseRevision- session.storeCachedDocumentHTML("<html>rev1</html>", for: revision)+ session.storeCachedDocumentHTML("<html>rev1</html>", badgeSourceStarts: [:], for: revision) let cached1 = session.currentCachedDocumentHTML #expect(cached1 == "<html>rev1</html>") @@ -167,6 +167,27 @@ nonisolated struct OffMainEmitTests { #expect(cached2 == nil, "cache must be stale after a reparse") } + @MainActor+ @Test("Cached badge source starts answer for the current revision and go stale on reparse")+ func cachedBadgeStartsReadAndStale() async {+ let session = DocumentSession(clipboardContent: "See[^1] end.\n\n[^1]: note")+ await session.parseContent()+ let revision = session.parseRevision+ #expect(session.cachedBadgeSourceStarts(for: "See[^1] end.") == nil, "no starts before a store")++ session.storeCachedDocumentHTML(+ "<html>x</html>", badgeSourceStarts: ["See[^1] end.": [3]], for: revision+ )+ let hit = session.cachedBadgeSourceStarts(for: "See[^1] end.")+ #expect(hit == [3])+ let miss = session.cachedBadgeSourceStarts(for: "not a rendered source")+ #expect(miss == nil, "an unknown source string is a miss, not an empty entry")++ await session.reloadContent(markdownString: "Changed[^1] text.\n\n[^1]: note")+ let stale = session.cachedBadgeSourceStarts(for: "See[^1] end.")+ #expect(stale == nil, "starts must go stale with the HTML after a reparse")+ }+ // MARK: - 4. Off-main precompute populates the cache @MainActor@@ -194,7 +215,9 @@ nonisolated struct OffMainEmitTests { func emitHTMLServesCacheHit() async { let session = DocumentSession(clipboardContent: "# Doc\n\ntext") await session.parseContent()- session.storeCachedDocumentHTML("<div data-cache-marker>cached body</div>", for: session.parseRevision)+ session.storeCachedDocumentHTML(+ "<div data-cache-marker>cached body</div>", badgeSourceStarts: [:], for: session.parseRevision+ ) let served = WebDocumentControllerFactory.emitHTML( for: session, settings: AppSettings(), processGeneration: 0
diff --git a/prismTests/WebRendering/WebSearchBridgeTests.swift b/prismTests/WebRendering/WebSearchBridgeTests.swiftindex 360e519..4e256e9 100644--- a/prismTests/WebRendering/WebSearchBridgeTests.swift+++ b/prismTests/WebRendering/WebSearchBridgeTests.swift@@ -159,7 +159,7 @@ struct WebSearchBridgeTests { currentGlobalMatchIndex: 2, context: context )- #expect(states.first?.current == .badge(id: "1"))+ #expect(states.first?.current == .badge(id: "1", occurrence: 0)) } // MARK: - JSON encoding shape@@ -300,6 +300,351 @@ struct WebSearchBridgeTests { #expect(unmatched != "true") } + // MARK: - T-1853: repeated footnote references (occurrence-aware badge identity)++ @Test("Current badge match on a repeated same-block reference encodes its occurrence")+ func currentBadgeOccurrenceEncodedForRepeatedReference() throws {+ // The same footnote referenced twice in ONE block: the within-block ordinal+ // space is [footnote occ 0, footnote occ 1] ("alpha" matches only the+ // definition). Current global index 1 is the SECOND reference occurrence —+ // the payload must carry occurrence identity, not just the id, or the web+ // renderer can only ever mark the first badge (T-1853).+ let blocks: [MarkdownBlock] = [.paragraph(markdown: "See[^1] and again[^1].")]+ let context = footnoteContext()+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+ #expect(counts[0] == 2)++ let states = SearchStateFeeder.buildStates(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 1,+ context: context+ )+ let json = SearchStateFeeder.encode(query: "alpha", states: states)+ let data = try #require(json.data(using: .utf8))+ let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])+ let blocksObject = try #require(root["blocks"] as? [String: Any])+ let entry = try #require(blocksObject.values.first as? [String: Any])+ let current = try #require(entry["current"] as? [String: Any])+ #expect(current["kind"] as? String == "badge")+ #expect(current["id"] as? String == "1")+ // Expected: occurrence 1 (the second [^1] reference). Actual (bug): the+ // payload has no occurrence at all — badge identity is the bare id.+ #expect(current["occurrence"] as? Int == 1)+ }++ @Test("Current badge in a later distant block is marked and scrolled, not the first reference")+ func liveCurrentBadgeInLaterDistantBlock() async throws {+ // Two distant blocks reference the SAME footnote; the query matches only the+ // definition content. The current match belongs to the LATER host block, so+ // the later badge must carry data-prism-search-current and be scrolled to.+ // Actual (bug): global querySelector always resolves the FIRST badge in the+ // document for marking and scrolling (T-1853).+ var blocks: [MarkdownBlock] = [.paragraph(markdown: "First reference.[^1]")]+ for i in 0..<40 {+ blocks.append(.paragraph(markdown: "Filler paragraph number \(i) with no match."))+ }+ blocks.append(.paragraph(markdown: "Second, distant reference.[^1]"))++ let footnotes = footnoteData()+ let context = footnoteContext()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks,+ footnotes: footnotes,+ featureScripts: ["prism-search"]+ )+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+ // One footnote-content match per host block; global index 1 is the later host's.+ let json = SearchStateFeeder.searchStateJSON(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 1,+ context: context+ )+ try await harness.send(.setSearchState(json: json))+ try await Task.sleep(for: .milliseconds(150))++ // Document order: badge 0 in the first host, badge 1 in the later host.+ let attributes = try await harness.evalString(+ "var badges = document.querySelectorAll('[data-prism-footnote=\\\"1\\\"]');"+ + " if (badges.length !== 2) { return 'count:' + badges.length; }"+ + " function d(b) { return (b.getAttribute('data-prism-search-match') || '-')"+ + " + '/' + (b.getAttribute('data-prism-search-current') || '-'); }"+ + " return d(badges[0]) + '|' + d(badges[1]);"+ )+ // Both host blocks list the matched footnote; only the LATER badge is current.+ #expect(attributes == "true/-|true/true")++ // The current badge lives at the bottom of a long document: applying the+ // state must scroll it into view (scrollY moves off the top).+ let scrollY = try await harness.evalString("return String(window.scrollY);")+ #expect((Double(scrollY ?? "0") ?? 0) > 0)+ }++ @Test("Repeated same-block references mark the current occurrence's badge, not the first")+ func liveCurrentBadgeOccurrenceWithinBlock() async throws {+ let blocks: [MarkdownBlock] = [.paragraph(markdown: "See[^1] and again[^1].")]+ let footnotes = footnoteData()+ let context = footnoteContext()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks,+ footnotes: footnotes,+ featureScripts: ["prism-search"]+ )+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+ // Ordinal space: [footnote occ 0, footnote occ 1]; global index 1 → the+ // SECOND badge in the block is current.+ let json = SearchStateFeeder.searchStateJSON(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 1,+ context: context+ )+ try await harness.send(.setSearchState(json: json))+ try await Task.sleep(for: .milliseconds(150))++ let attributes = try await harness.evalString(+ "var badges = document.querySelectorAll('[data-prism-footnote=\\\"1\\\"]');"+ + " if (badges.length !== 2) { return 'count:' + badges.length; }"+ + " function d(b) { return (b.getAttribute('data-prism-search-match') || '-')"+ + " + '/' + (b.getAttribute('data-prism-search-current') || '-'); }"+ + " return d(badges[0]) + '|' + d(badges[1]);"+ )+ #expect(attributes == "true/-|true/true")+ }++ // MARK: - T-1853 review: non-badge same-id look-alikes must not skew the occurrence++ @Test("Badge source starts report only badge-rendering occurrences, in DOM order")+ func badgeSourceStartsSkipNonBadgeOccurrences() {+ // Three `[^1]`-shaped, resolvable occurrences; the middle one sits inside a+ // code span, so the walker restores it to literal text and emits no badge+ // (FootnoteBadgeSubstitutionTests A1). The helper reports the source offsets+ // of the two occurrences that DO badge — the render pipeline's own+ // eligibility, which the feeder matches occurrences against.+ let source = "See[^1] here `[^1]` and also[^1] end."+ let starts = InlineHTMLRenderer.badgeSourceStarts(+ source: source, footnotes: footnoteData()+ )+ #expect(starts == [3, 28])+ }++ @Test("A non-badge look-alike between real badges does not skew the current occurrence")+ func currentBadgeOccurrenceSkipsNonBadgeReferences() {+ // Ordinal space: three footnote-content matches, one per `[^1]`-shaped+ // occurrence (the code-span one has no badge, but its definition content is+ // still appended to the searchable text). Global index 2 is owned by the+ // THIRD structural occurrence — which is the section's SECOND real badge —+ // so the payload must carry occurrence 1, not 2 (which would run off the+ // section's 2-badge DOM list and previously fell back to the FIRST badge).+ let blocks: [MarkdownBlock] = [.paragraph(markdown: "See[^1] here `[^1]` and also[^1] end.")]+ let context = footnoteContext()+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+ #expect(counts[0] == 3)++ let states = SearchStateFeeder.buildStates(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 2,+ context: context+ )+ #expect(states.first?.current == .badge(id: "1", occurrence: 1))+ }++ @Test("Current match owned by the non-badging occurrence addresses the next real badge")+ func currentMatchOnNonBadgeOccurrenceAddressesNextBadge() {+ // Global index 1 is owned by the code-span occurrence ITSELF — its definition+ // content occupies ordinal space but it renders no badge. Pinned behaviour+ // (T-1853 review round 2): the payload addresses the nearest FOLLOWING real+ // badge — occurrence counts prior badge-eligible entries (here 1: the first+ // reference), so the section's second badge ("also[^1]") is marked current.+ // The JS side clamps to the section's last badge when no badge follows, so a+ // trailing look-alike degrades to the nearest preceding badge instead.+ let blocks: [MarkdownBlock] = [.paragraph(markdown: "See[^1] here `[^1]` and also[^1] end.")]+ let context = footnoteContext()+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }++ let states = SearchStateFeeder.buildStates(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 1,+ context: context+ )+ #expect(states.first?.current == .badge(id: "1", occurrence: 1))+ }++ @Test("A trailing look-alike carries a past-the-end occurrence for the JS clamp")+ func currentMatchOnTrailingLookAlikeCarriesPastEndOccurrence() {+ // The look-alike is the LAST occurrence — no real badge follows it. The+ // feeder counts prior badge-eligible entries (both real badges), so the+ // payload carries occurrence 2 against the section's 2-badge DOM list.+ // Addressing past the end is deliberate: prism-search.js's+ // resolveCurrentBadge clamps to the section's last badge, so the current+ // marking degrades to the nearest PRECEDING badge (asserted live in+ // liveCurrentMatchOnTrailingLookAlikeClampsToLastBadge).+ let blocks: [MarkdownBlock] = [.paragraph(markdown: "See[^1] and also[^1] then `[^1]` end.")]+ let context = footnoteContext()+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+ #expect(counts[0] == 3)++ let states = SearchStateFeeder.buildStates(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 2,+ context: context+ )+ #expect(states.first?.current == .badge(id: "1", occurrence: 2))+ }++ // MARK: - T-1853 review round 2: badge eligibility off the search hot path++ @Test("The document emit captures badge source starts keyed by inline source")+ func emitCapturesBadgeSourceStarts() {+ // The emit records, per inline source string, the badge starts its own render+ // produced — the sidecar the session caches per parseRevision so search pushes+ // read eligibility instead of re-running the inline renderer on the MainActor.+ let source = "See[^1] here `[^1]` and also[^1] end."+ let emitted = BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: source)],+ footnotes: footnoteData(),+ settings: RenderSettings(showHTMLComments: false, strings: .fallback)+ )+ #expect(emitted.badgeSourceStarts[source] == [3, 28])+ }++ @Test("The feeder resolves eligibility from the supplied starts, not a re-render")+ func feederUsesSuppliedBadgeStartsOverRecompute() {+ // The provider deliberately reports NO badges for the source. If the feeder+ // consulted it, occurrence must be 0 (no prior badge-eligible entries); if it+ // re-ran the renderer instead, the first reference would count as eligible+ // and occurrence would be 1. This pins that the hot path reads the+ // emit-captured starts and never re-renders when the cache answers.+ let source = "See[^1] here `[^1]` and also[^1] end."+ let blocks: [MarkdownBlock] = [.paragraph(markdown: source)]+ let context = footnoteContext()+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }++ let states = SearchStateFeeder.buildStates(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 2,+ context: context,+ badgeSourceStarts: { _ in [] }+ )+ #expect(states.first?.current == .badge(id: "1", occurrence: 0))+ }++ @Test("Live: current badge lands on the right badge across a code-span look-alike")+ func liveCurrentBadgeSkipsCodeSpanLookAlike() async throws {+ let blocks: [MarkdownBlock] = [.paragraph(markdown: "See[^1] here `[^1]` and also[^1] end.")]+ let footnotes = footnoteData()+ let context = footnoteContext()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks,+ footnotes: footnotes,+ featureScripts: ["prism-search"]+ )+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+ // Global index 2 → the third structural occurrence, i.e. the SECOND (last)+ // real badge in the DOM: the code-span look-alike renders no badge.+ let json = SearchStateFeeder.searchStateJSON(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 2,+ context: context+ )+ try await harness.send(.setSearchState(json: json))+ try await Task.sleep(for: .milliseconds(150))++ let attributes = try await harness.evalString(+ "var badges = document.querySelectorAll('[data-prism-footnote=\\\"1\\\"]');"+ + " if (badges.length !== 2) { return 'count:' + badges.length; }"+ + " function d(b) { return (b.getAttribute('data-prism-search-match') || '-')"+ + " + '/' + (b.getAttribute('data-prism-search-current') || '-'); }"+ + " return d(badges[0]) + '|' + d(badges[1]);"+ )+ #expect(attributes == "true/-|true/true")+ }++ @Test("Live: current match owned by the code-span look-alike marks the next real badge")+ func liveCurrentMatchOnLookAlikeMarksNextBadge() async throws {+ let blocks: [MarkdownBlock] = [.paragraph(markdown: "See[^1] here `[^1]` and also[^1] end.")]+ let footnotes = footnoteData()+ let context = footnoteContext()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks,+ footnotes: footnotes,+ featureScripts: ["prism-search"]+ )+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+ // Global index 1 → the code-span occurrence itself, which renders NO badge.+ // Pinned behaviour (T-1853 review round 2): the nearest FOLLOWING real badge —+ // the section's second badge — is marked current.+ let json = SearchStateFeeder.searchStateJSON(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 1,+ context: context+ )+ try await harness.send(.setSearchState(json: json))+ try await Task.sleep(for: .milliseconds(150))++ let attributes = try await harness.evalString(+ "var badges = document.querySelectorAll('[data-prism-footnote=\\\"1\\\"]');"+ + " if (badges.length !== 2) { return 'count:' + badges.length; }"+ + " function d(b) { return (b.getAttribute('data-prism-search-match') || '-')"+ + " + '/' + (b.getAttribute('data-prism-search-current') || '-'); }"+ + " return d(badges[0]) + '|' + d(badges[1]);"+ )+ #expect(attributes == "true/-|true/true")+ }++ @Test("Live: current match on a trailing look-alike clamps to the section's last badge")+ func liveCurrentMatchOnTrailingLookAlikeClampsToLastBadge() async throws {+ // The look-alike is the LAST occurrence, so no badge follows it: the payload's+ // occurrence (2) runs past the section's 2-badge DOM list and+ // resolveCurrentBadge clamps to the last badge — the nearest preceding one —+ // instead of marking nothing (T-1853 review round 3).+ let blocks: [MarkdownBlock] = [.paragraph(markdown: "See[^1] and also[^1] then `[^1]` end.")]+ let footnotes = footnoteData()+ let context = footnoteContext()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks,+ footnotes: footnotes,+ featureScripts: ["prism-search"]+ )+ let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }+ // Global index 2 → the trailing code-span occurrence, which renders NO badge+ // and has none after it.+ let json = SearchStateFeeder.searchStateJSON(+ query: "alpha",+ blocks: blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: 2,+ context: context+ )+ try await harness.send(.setSearchState(json: json))+ try await Task.sleep(for: .milliseconds(150))++ let attributes = try await harness.evalString(+ "var badges = document.querySelectorAll('[data-prism-footnote=\\\"1\\\"]');"+ + " if (badges.length !== 2) { return 'count:' + badges.length; }"+ + " function d(b) { return (b.getAttribute('data-prism-search-match') || '-')"+ + " + '/' + (b.getAttribute('data-prism-search-current') || '-'); }"+ + " return d(badges[0]) + '|' + d(badges[1]);"+ )+ #expect(attributes == "true/-|true/true")+ }+ // MARK: - Req 6.4: highlight spans formatting boundaries @Test("A query straddling bold/inline-code formatting still registers a highlight")
Between a reparse and the off-main precompute storing, every search push whose current match is a footnote match re-renders the owning block's inline sources on the MainActor. Bounded and documented, but if a future profiler trace shows emit work on the main thread during typing-while-reparsing, this is where it comes from.
Stepping onto a look-alike's match marks the following badge mid-block but the preceding badge when the look-alike is trailing. Both are deliberate and tested; just remember the asymmetry if a user reports 'search marked the wrong badge' on a syntax-documentation page.