prism branch T-1941/bugfix-subspan-source-map-rebase PR #342 commits 3 (+ review edits) files 9 touched lines +578 / -88 targeted tests 150 run, 0 failed lint 0 violations / 510 files

Pre-push review: T-1941 sub-span source-map rebase

Selection-anchored notes inside table cells and list items anchored to the wrong text. The fix replaces BlockHTMLEmitter.renderInline's optional recordRuns: Bool with a required InlineSpan and centralises the rebase, so a sub-span call site can no longer record un-rebased runs by omission.

At a glance

  • Verified end to end. 150 targeted tests across 4 runs, including the live WebPage harness (WebSelectionNoteTests, 15/15) — 0 failures. make lint clean. make build-ios clean; the app-target warnings that remain were confirmed identical on a fresh origin/main build, so this PR adds none.
  • The structural claim holds. renderInline is now the only writer of context.blockRuns (enforced by fileprivate(set), which @testable does not relax), and the span parameter has no default. A new sub-span call site can state its coordinate space wrongly, but it cannot omit it.
  • No behaviour change in textContent. The extracted separator constants carry the same values as the literals they replace, so the string is byte-identical — pinned by MarkdownBlockTextContentTests passing unchanged.
  • The five newly-.unmapped sites were previously always wrong, not correct-and-regressed: each recorded a base-0 run into a textContent whose offset 0 is somewhere else entirely. Declining is strictly better than mis-anchoring, and the residual limitation is ticketed (T-2032, filed with full scope notes).
  • Merge hazard with PR #341 (T-1992). Both branches insert tests at the same anchor in DocumentSourceMapTests.swift and WebSelectionNoteTests.swift, and both edit the same CHANGELOG region — three add/add conflicts. Semantically compatible: #341 changes only InlineHTMLRenderer's internal occurrence bookkeeping, not the string-local coordinate space of the runs it returns, which is the sole contract this PR depends on.
  • Two editorial fixes applied (comment-only, zero behaviour change): a production comment and an agent-note bullet still described the deleted recordRuns parameter.

Verdict

Ready to push

The fix is correct, structurally sound, and well guarded. The coordinate-space mismatch is real, the chosen remedy (a required InlineSpan parameter plus a single rebase point, with Context.blockRuns made fileprivate(set)) closes it by construction rather than by convention, and the offset helpers live beside the textContent join they mirror so the producer and consumer cannot drift. Three prior local-review rounds added the two guard rails that mattered — a multi-byte fixture that distinguishes UTF-16 from Character counts, and a textContent upper bound on the corpus sweep that catches the opposite failure direction — both with mutation evidence in the commit bodies.

Everything raised here is documentation-level. Two stale recordRuns references (one production comment, one agent-note bullet) were fixed editorially during this review; the remaining items are pointers the author may want to place, and a merge-time collision with the in-flight sibling PR #341 that is textual only, not semantic.

Review findings

8 raised · 2 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism lets you select text in a document and attach a note to it. To do that, the app has to translate "the user highlighted these pixels on screen" into "these characters in the original markdown file". It keeps a little translation table — the source map — that says: this stretch of rendered text corresponds to characters 12 through 20 of this block.

The bug: for tables and lists, the app built that table wrong. It renders a table one cell at a time, and each time it starts counting characters from zero again. But when a note is saved, the app looks the offsets up against the whole block's text — every cell glued together with " | " between them. So an offset of 3 meant "3 characters into this cell" on the way in, and "3 characters into the whole table" on the way out. Only the very first cell and the very first list item, which genuinely do start at zero, came out right. Everything else quoted text from the start of the table or list.

Why it matters

It was not just a cosmetic wrong quote in the note sheet. The wrong character range was saved, so the note carried it forward: into the logic that re-finds a note after you edit the file, and into exporting a document with its notes inlined.

Key concepts

  • Coordinate space — "3 characters in" is meaningless unless you say in what. The bug was two pieces of code disagreeing about the "in what".
  • UTF-16 units, not characters — an emoji counts as one character but two units. The whole map is measured in units, so the offsets have to be too.
  • Safe-by-decline — where the app genuinely cannot work out the right anchor, it now refuses to offer "Add note" on a selection rather than guessing. A wrong anchor is worse than no anchor.

Architecture

BlockHTMLEmitter walks [MarkdownBlock] and emits one <section> per block, accumulating DocumentSourceMap.Run entries into a per-block buffer. InlineHTMLRenderer.render is called once per inline string and always restarts its source cursor at 0, so its runs are in the coordinate space of that string. The consumer — WebDocumentMessageRouter.createSelectionNote — slices MarkdownBlock.textContent with those offsets. For .paragraph and .heading, the string handed to the renderer is textContent and everything lines up. For .table and .list, textContent is a join of the strings the emitter renders one at a time, and the runs were appended verbatim.

The pattern

The previous API made this reachable by omission: renderInline(_:context:recordRuns: Bool = true). A caller rendering a sub-span opted into recording by writing nothing at all. This PR replaces that with a required InlineSpan enum — .wholeBlock, .subspan(offset:), .unmapped — and moves the rebase inside renderInline. Two properties fall out: no default argument means a new call site must state its space, and Context.blockRuns becoming fileprivate(set) means nothing else can append to bypass the rebase.

The offsets themselves come from MarkdownBlock.tableCellTextOffsets / listItemTextOffsets, deliberately placed in MarkdownBlock.swift next to the textContent join, sharing extracted separator constants. Co-location is the anti-drift mechanism: changing a separator changes both sides in one edit.

Trade-offs

Five call sites render text that is absent from textContent altogether — a nested list's items, a list reached via renderInnerBlock, a <details> child list, a nested <details> summary, the child-less blockquote fallback. There is no offset to rebase into, so they are .unmapped: no run recorded, selection declined. Each of these previously recorded a base-0 run, which was always wrong, so this is not a regression of working behaviour — but it is user-visible (the Add-note overlay stops appearing on those selections), which is why T-2032 was filed rather than left implicit. Making them work needs textContent widened to contain nested text, and textContent is also read by search and export — genuine blast radius, correctly scoped out.

Deep dive

The defect is a producer/consumer coordinate-space mismatch, and the interesting part is that it is the fourth occurrence of the same class (T-1673 → T-1876 → T-1941), each time closed at the call site. The remedy here is a type change rather than another point fix, and it earns that framing: InlineSpan is a required parameter (no default ⇒ omission is a compile error) and Context.blockRuns is fileprivate(set) (⇒ bypass is a compile error, and @testable import does not relax fileprivate, so tests cannot route around it either). The residual failure mode is stating the span wrongly — which is what the test guards target.

Guard-rail design

Two failure directions, two independent guards, and the round-3 work is what made both non-vacuous:

  • Too small (a sub-span claiming .wholeBlock): caught by run monotonicity — offsets restarting at 0 interleave with runs already recorded.
  • Too large (a Character count where a UTF-16 count is required): stays monotonic and passes the first guard entirely. Caught only by the new upper bound, run.sourceStart + run.length <= block.textContent.utf16.count, resolved per block through BlockDOMID.map(blocks:) — the same walk emit uses to key the map, so a map key with no matching block is itself recorded as a failure rather than silently skipped.

The multi-byte fixtures are the sharper piece. All 15 parity fixtures are ASCII, where String.count and utf16.count agree, so a Character-count regression passed the entire corpus. multiByteTable() puts an astral scalar in a header cell and in the first body row, which is the minimum shape that distinguishes all three counts tableCellTextOffsets performs independently: joinedOffsets over the headers, headerLineLength as the body-row base, and joinedOffsets with that base. With ASCII headers, a regression confined to headerLineLength shifts every body offset uniformly and still passes. Both directions carry mutation evidence in the commit bodies (33 → 31/2 for the UTF-16 swap; 7/7 → fail for a uniform +1 drift in Run.rebased).

Edge cases checked during review

  • tableCellTextOffsets with empty headers: headerLineLength is 0 and the first row line starts at 1, matching textContent's unconditional headerStr + "\n" + rowsStr. Empty rows is likewise consistent.
  • Top-level <details>: textContent is summary + "\n" + children, so .subspan(offset: 0) for the summary is exact, and the entire body is .unmapped, so nothing past it can be mis-anchored.
  • Nested <details>: the recursion emits its own <section> with a distinct DOM id, but emit keys the map by the top-level block — so any run recorded there would land under the wrong id. .unmapped makes that unreachable.
  • Run ordering across a table: header cells, then rows in order, offsets strictly increasing; nested content contributes nothing, so no interleaving. Monotonicity is structural, not incidental.
  • Defence in depth downstream: noteTextRange already bounds-checks utf16End <= total and declines on a surrogate-pair split, so even an out-of-range run degrades to a decline rather than a corrupt anchor.

Architecture impact

MarkdownBlock gains three static offset helpers and three separator constants. This is arguably renderer knowledge living in the model, but the justification is sound and stated in Q2: the helpers exist precisely to mirror a join that textContent performs, and separating them is what allowed the drift in the first place. No SwiftUI import, no isolation change — the file stays nonisolated, so the off-main emit path (T-1681) is unaffected.

Important changes — detailed

BlockHTMLEmitter: renderInline takes a required InlineSpan and owns the rebase

prism/Services/WebRendering/BlockHTMLEmitter.swift

Why it matters. This is the whole fix. Replacing an optional boolean with a required three-case enum turns an error of omission into a compile error, and moving the rebase inside the function makes it impossible to record un-rebased runs from anywhere else.

What to look at. BlockHTMLEmitter.swift:917-969 (InlineSpan + renderInline)

Takeaway. When the same defect recurs at call sites, the fix is usually the signature, not the call site. Two levers here: drop the default argument so the decision cannot be skipped, and narrow the write access on the accumulator so the decision cannot be bypassed. `fileprivate(set)` is the underused half — it is not relaxed by `@testable import`, so it holds for tests too.
Rationale. Q2 in specs/webview-rendering/decision_log.md: a per-site rebase would have been the fourth occurrence of the class (T-1673 → T-1876 → T-1941). With no default a new call site cannot append un-rebased runs by omission, and with renderInline the only writer of context.blockRuns it cannot bypass the rebase either.

MarkdownBlock: offset helpers and separator constants beside the textContent join

prism/Models/MarkdownBlock.swift

Why it matters. The emitter and the consumer previously agreed only by coincidence — `textContent` spelled its separators as inline literals that nothing in the emitter referenced. This puts the offset derivation in the same file, over the same constants, so a separator change updates both sides in one edit.

What to look at. MarkdownBlock.swift:766-816 (separators, joinedOffsets, tableCellTextOffsets, listItemTextOffsets)

Takeaway. When two files must agree on a serialisation detail, co-locate the derivation with the definition and share the constant. Contributing factor to the original bug, named as such in the commit body — a genuine root-cause fix rather than a symptom fix. `joinedOffsets` counting `utf16` rather than Characters is the load-bearing detail, and the reason the multi-byte fixtures exist.
Rationale. Stated in the source comment: `textContent` is the coordinate space every run is consumed in, and for a table and a list that string is a join of the same inline strings the emitter renders one at a time. The separators and the offset helpers live beside the join they describe, so the two sides cannot drift.

Five sub-span sites become .unmapped rather than base-0 wrong

prism/Services/WebRendering/BlockHTMLEmitter.swift

Why it matters. This is the one user-visible behaviour change in the PR: selecting text on a nested list item (and four sibling shapes) no longer offers Add note at all. Reviewing it means confirming it replaces a wrong anchor, not a working one.

What to look at. renderListMarkup itemOffsets:nil (597-607), emitDetails summarySpan:.unmapped (885-893), blockquote fallback (455)

Takeaway. Where a mapping cannot be computed, record nothing rather than record a plausible-looking default. `noteTextRange` already declines on an unresolvable range, so `.unmapped` degrades into an existing, tested path instead of needing new handling. Verified during review: each of these five sites previously recorded a run at base 0 into a `textContent` whose offset 0 is elsewhere, so none of them ever anchored correctly.
Rationale. web-markdown-fidelity Decision 8 (safe-by-decline): declining is always correct because the block-level `+` covers those targets; silent mis-anchoring is not. The residual limitation is ticketed as T-2032 with the scope note that mapping them requires widening `textContent`, which search and export also read.

Corpus sweep gains a textContent upper bound and a non-vacuity guard

prismTests/WebRendering/WebStructuredSelectionTests.swift

Why it matters. Monotonicity alone catches only offsets that are too small. The UTF-16 failure direction produces offsets that are too large, stays monotonic, and would have passed the old invariant across the entire fixture corpus.

What to look at. WebStructuredSelectionTests.swift:378-403 (bound + BlockDOMID resolution), 473-488 (parityCorpusRunsMonotonic)

Takeaway. A one-sided invariant tends to look complete because it catches the bug you just fixed. Ask which direction it does *not* catch, then find a mutation that survives it — here, a uniform +1 drift in `Run.rebased` passed 7/7 with the bound removed. The `#expect(!map.runs.isEmpty)` guard is the matching discipline: a sweep whose invariant is vacuous over an empty result silently stops testing anything.
Rationale. Recorded in the round-1 and round-2 commit bodies with mutation evidence for both guards. The per-block limit resolves through `BlockDOMID.map(blocks:)` — the same walk `emit` uses to key the map — so a map key with no matching block is recorded as a failure rather than skipped.

Multi-byte fixtures that distinguish UTF-16 units from Character counts

prismTests/WebRendering/DocumentSourceMapTests.swift

Why it matters. Every fixture in this area, including all 15 parity fixtures, was ASCII. A regression swapping `part.utf16.count` for `part.count` passed all of them.

What to look at. DocumentSourceMapTests.swift:152-234 (multiByteTable, multiByteList, and the two tests)

Takeaway. The fixture design is the notable part: the astral scalar sits in a header cell *and* the first body row because `tableCellTextOffsets` performs three independent counts, and with ASCII headers a regression confined to `headerLineLength` shifts every body offset uniformly and still passes. Pick the fixture that distinguishes every count you make, not just the one that reproduces the reported bug.
Rationale. Round-1 commit body: mutation-verified — swapping `part.utf16.count` for `part.count` gives total=33 passed=31 failed=2, exactly the two new tests.

Key decisions

Required <code>InlineSpan</code> over a per-site rebase.

Recorded as Q2 in specs/webview-rendering/decision_log.md, superseding Q1. A per-site rebase would have been the fourth occurrence of the class. The chosen shape removes both failure modes available to a future call site: omission (no default argument) and bypass (fileprivate(set) on Context.blockRuns).

Offset helpers live in <code>MarkdownBlock.swift</code>, not in the emitter.

Renderer-shaped knowledge in the model layer, justified by anti-drift: the helpers exist to mirror the textContent join, and the original bug's contributing factor was exactly that the join's separators lived as unreferenced literals in one file while the emitter assumed them in another. Sharing the constants makes a separator change a single edit.

<code>.unmapped</code> for the five sites absent from <code>textContent</code>, with the fix deferred to T-2032.

Mapping them requires widening textContent to include nested item and <details>-child text. textContent is also consumed by search and note export, so widening it in place has blast radius beyond notes. Scoping T-1941 to correctness and ticketing the enhancement separately is the right split — and the ticket was filed with the full scope analysis rather than as a stub.

Parallel-array indexing left trapping rather than guarded.

Declined explicitly in round 1: "safe today, and changing a trapping contract belongs in its own change." Confirmed during this review — both the offsets and the items come from the same arrays at the single call site, so a de-sync is not reachable. Reasonable, though it is worth remembering that BlockHTMLEmitter.emit is otherwise total by design.

<code>.wholeBlock</code> kept as a distinct case from <code>.subspan(offset: 0)</code>.

The two are behaviourally identical. Keeping both lets a call site distinguish "this string is the block's text" from "this string is a prefix of it" — the blockquote first-child case is the second, and reads correctly because of it. A small amount of redundancy bought for intent.

(inferred — not stated by the author.)
Bugfix report kept in the commit body rather than <code>specs/bugfixes/&lt;name&gt;/report.md</code>.

Checked against history: the last specs/bugfixes/ report landed with T-1541 (87269b2), and none of the five most recent bugfix commits on main added one. CHANGELOG + decision log + agent note is the current practice, and this PR follows it. Noted for the record, not raised as a finding.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorBlockHTMLEmitter.swift:439 (emitBlockquote comment)A production comment still described `renderInnerBlock` as rendering with `(recordRuns:false)` — a parameter this PR deletes. Directly contradicts the new API two lines above its own `.subspan(offset: 0)` call.Rewritten to `(.unmapped)`. Comment-only; rebuilt and re-ran the 53 affected tests after the edit.
minordocs/agent-notes/webview-rendering-status.md:60The safe-by-decline bullet still documented the mechanism as `renderInline(recordRuns:)`. The PR rewrote the neighbouring `InlineHTMLRenderer` bullet in the same file but missed this one, so the note now describes the API two different ways.Updated to name the mechanism as the required `InlineSpan` (`.unmapped` being the decline case), with a pointer to the `InlineHTMLRenderer` bullet and an explicit spec qualifier on 'Decision 8'.
minorspecs/web-markdown-fidelity/ (decision_log.md:208, design.md:22-23/68-69/79, tasks.md:43-46)That spec documents `renderInline(_ source: String, context: Context, recordRuns: Bool = true)` as the API, including a literal signature line in design.md. That function no longer exists. Decision 8 there is also the decision the emitter and the new Q2 both cite, so it is where a reader looking up 'Decision 8' lands — and it now describes a mechanism that has been replaced.Reported, not fixed — annotating another feature's accepted decision is the author's call. Suggested minimum: a one-line supersession note on web-markdown-fidelity Decision 8 pointing at webview-rendering Q2, so the stale signature is not read as current. The decision's substance (safe-by-decline) is unchanged; only its stated mechanism moved.
minorspecs/webview-rendering/decision_log.md:8 (Q2)Q2 says 'declined, never mis-anchored (Decision 8)' — but the file Q2 lives in has its own Decision 8, 'Native-as-truth architecture with a scheme-served document and user-script-only JS', which is unrelated. The intended reference is web-markdown-fidelity's Decision 8. Unqualified 'Decision 8' is a pre-existing habit in the emitter comments, but Q2 newly places it in the one file where it resolves to the wrong entry.Reported, not fixed — a spec-content edit. Suggested: qualify as 'web-markdown-fidelity Decision 8' in Q2 (and, opportunistically, in the emitter comments that carry it).
nitBlockHTMLEmitter.renderListMarkup / tableHTML (parallel-array indexing)`itemOffsets.map { .subspan(offset: $0[index]) }`, `$0.headers[column]`, `$0.rows[rowIndex][column]` index a parallel array with no bound. Safe today — both arrays derive from the same source at the single non-nil call site — and explicitly declined in round 1 with a defensible reason. Worth noting only because `BlockHTMLEmitter.emit` is otherwise total by design (a block that fails to emit falls back to escaped `<pre>`), so this is the one new place where a caller mistake traps rather than degrades.Not changed — the author's reasoning holds and the risk is not reachable. If someone touches this again: passing an `enclosingTextBase: Int?` and computing the offsets inside `renderListMarkup`/`tableHTML` removes the parallel array entirely.
nitMarkdownBlock.tableCellTextOffsets (allocation)Computes `headers.joined(separator:)` and `rows.map { $0.joined(separator:) }` purely to measure lengths, duplicating the join `textContent` already performs — one full copy of the table's text per table per emit. `headerLineLength` is derivable from `headerOffsets.last` plus the last header's UTF-16 length without any join.Not changed — negligible next to the per-block SwiftSoup sanitisation in the same pass, and it runs off-main via `precomputeDocumentHTML`. Clarity is worth more than the allocation here.
infoMerge coordination with PR #341 (T-1992)Three add/add textual conflicts are certain at merge: both branches insert new tests at the same anchor in `prismTests/WebRendering/DocumentSourceMapTests.swift` (@@ -300,6) and `prismTests/WebRendering/WebSelectionNoteTests.swift` (@@ -320,6), and both edit the same CHANGELOG `[Unreleased] > Fixed` region. Semantically the two are compatible: #341 changes `InlineHTMLRenderer`'s internal occurrence bookkeeping (positional claiming for markers landing in attributes, destinations, titles and raw HTML) but leaves the returned runs in the handed-in string's coordinate space, which is the only contract T-1941 depends on. This PR leaves `InlineHTMLRenderer.swift` untouched.Flagged, not acted on. Whoever merges second resolves the three conflicts and then re-runs `WebStructuredSourceMapInvariantTests` + `DocumentSourceMapInvariantTests` — this PR's new `parityCorpusRunsMonotonic` sweep (15 fixtures, with the `textContent` upper bound) is precisely the guard that would catch a bad merge, and it only exists once this PR lands. Merging T-1941 first therefore gives the stronger safety net for #341.
infoBuild warnings`make build-ios` emits one unique warning (main-actor-isolated `Equatable` conformance of `ImageDimension`), and the macOS test build adds `ListItemChild`, `WebPaletteFeed` and `CopyNotesButton.swift:99`. `ListItemChild` is declared in `MarkdownBlock.swift`, a file this PR edits, so this was worth ruling out explicitly.Confirmed pre-existing: a fresh build of `origin/main` in a throwaway worktree produced the identical set (in fact a superset). This PR introduces no new warnings, and none of the touched files emits any.

Per-file diffs

Click to expand.

prism/Services/WebRendering/BlockHTMLEmitter.swift Modified +132 / -49
diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex 6327e23..b79c7a9 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -55,8 +55,12 @@ nonisolated enum BlockHTMLEmitter {         let settings: RenderSettings         /// Monotonic run-ID allocator, unique across the whole document.         var nextRunID = 0-        /// Runs collected for the block currently being emitted.-        var blockRuns: [DocumentSourceMap.Run] = []+        /// Runs collected for the block currently being emitted. The setter is+        /// `fileprivate` so "runs are only ever appended by `renderInline`, already+        /// rebased into the stated `InlineSpan`" is enforced by the language rather than+        /// by convention: no other file can write here, and within this file the only+        /// writers are `emit` (which resets it per block) and `renderInline` (T-1941).+        fileprivate(set) 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.@@ -358,7 +362,10 @@ nonisolated enum BlockHTMLEmitter {          case .details(let summary, let children, let isOpen, let depth):             let model = DetailsModel(summary: summary, children: children, isOpen: isOpen, depth: depth)-            return emitDetails(model, block: block, domID: domID, context: context)+            // details.textContent is `summary + "\n" + children`, so the summary is its+            // prefix (offset 0).+            return emitDetails(model, block: block, domID: domID,+                               summarySpan: .subspan(offset: 0), context: context)         }     } @@ -386,7 +393,8 @@ nonisolated enum BlockHTMLEmitter {         level: Int, text: String, block: MarkdownBlock, domID: String, sourceIndex: Int, context: Context     ) -> String {         let clamped = min(max(level, 1), 6)-        let rendered = renderInline(text, context: context)+        // A heading's textContent IS its text.+        let rendered = renderInline(text, in: .wholeBlock, context: context)         // Collapse chevron (Req 1.6): chrome at the heading's leading edge, inline so it         // shares the heading's line. Clicking it posts `sectionToggled` with the composite         // section id (= MarkdownSection.id) so the native section model + TOC stay in sync.@@ -414,7 +422,8 @@ nonisolated enum BlockHTMLEmitter {     private static func emitParagraph(         markdown: String, block: MarkdownBlock, domID: String, context: Context     ) -> String {-        let rendered = renderInline(markdown, context: context)+        // A paragraph's textContent IS its markdown source.+        let rendered = renderInline(markdown, in: .wholeBlock, context: context)         return section(block: block, domID: domID, role: nil, inner: "<p>\(rendered)</p>")     } @@ -430,16 +439,20 @@ nonisolated enum BlockHTMLEmitter {             // other children render section-lessly via renderInnerBlock (recordRuns:false),             // so a selection there is declined rather than mis-anchored (Decision 8).             if index == 0, case .paragraph(let markdown) = child {-                inner += "<p>" + renderInline(markdown, context: context) + "</p>"+                // blockquote.textContent joins the children, so the FIRST child sits at+                // offset 0 of it — a prefix, not the whole string.+                inner += "<p>" + renderInline(markdown, in: .subspan(offset: 0), context: context) + "</p>"             } else {                 inner += renderInnerBlock(child, context: context)             }         }         inner += "</blockquote>"         // A blockquote with no structured children (defensive: legacy/edge parse) falls back-        // to its flat content so nothing is dropped.+        // to its flat content so nothing is dropped. That content is NOT the block's+        // textContent (which derives from the children, and so is empty here), so it is+        // unmapped: a selection is declined rather than anchored into an empty string.         if children.isEmpty {-            inner = "<blockquote><p>" + renderInline(content, context: context) + "</p></blockquote>"+            inner = "<blockquote><p>" + renderInline(content, in: .unmapped, context: context) + "</p></blockquote>"         }         return section(block: block, domID: domID, role: nil, inner: inner)     }@@ -530,8 +543,12 @@ nonisolated enum BlockHTMLEmitter {     private static func emitList(         ordered: Bool, start: Int, items: [ListItem], block: MarkdownBlock, domID: String, context: Context     ) -> String {+        // list.textContent joins the top-level items' content with "\n", so each item's runs+        // are rebased by that item's offset in the join (T-1941).         let inner = renderListMarkup(ordered: ordered, start: start, items: items,-                                     subIDPrefix: "item", context: context)+                                     subIDPrefix: "item",+                                     itemOffsets: MarkdownBlock.listItemTextOffsets(items: items),+                                     context: context)         return section(block: block, domID: domID, role: nil, inner: inner)     } @@ -551,8 +568,16 @@ nonisolated enum BlockHTMLEmitter {     /// "+"; omitting the attribute keeps a quote/list-item-nested list out of that scan so the     /// parent block keeps its single block-level "+". The `nil` propagates down nested     /// recursion. Top-level / in-`<details>` lists pass `"item"` and are unchanged.+    ///+    /// `itemOffsets` is each item content's UTF-16 offset in the enclosing block's+    /// `textContent` (from `MarkdownBlock.listItemTextOffsets`), or `nil` when this list's+    /// item text does not appear in that string at all — which is every list except a+    /// top-level `.list` block: a NESTED list's items live in `ListItem.children`, and+    /// `.list.textContent` joins only the top-level items, so nested item text has no offset+    /// to rebase into and is recorded nowhere (T-1941, Decision 8 safe-by-decline).     private static func renderListMarkup(-        ordered: Bool, start: Int, items: [ListItem], subIDPrefix: String?, context: Context+        ordered: Bool, start: Int, items: [ListItem], subIDPrefix: String?,+        itemOffsets: [Int]?, context: Context     ) -> String {         let tag = ordered ? "ol" : "ul"         // <ol start="1"> is the HTML default, so it is omitted; any other value (including 0)@@ -569,18 +594,21 @@ nonisolated enum BlockHTMLEmitter {                 let checked = checkbox == .checked ? " checked" : ""                 html += "<input type=\"checkbox\" disabled\(checked) data-prism-chrome>"             }-            html += renderInline(item.content, context: context)+            let itemSpan = itemOffsets.map { InlineSpan.subspan(offset: $0[index]) } ?? .unmapped+            html += renderInline(item.content, in: itemSpan, context: context)             for child in item.children {                 switch child {                 case .nestedList(let nested):+                    // Nested item text is absent from the block's textContent: unmapped.                     html += renderListMarkup(ordered: nested.ordered, start: nested.start,                                              items: nested.items,                                              subIDPrefix: sub.map { "\($0)-item" },+                                             itemOffsets: nil,                                              context: context)                 case .paragraph(let text):-                    // Continuation paragraphs render section-lessly with recordRuns:false so a-                    // selection there is declined rather than mis-anchored (Decision 8).-                    html += "<p>" + renderInline(text, context: context, recordRuns: false) + "</p>"+                    // Continuation paragraphs are unmapped, so a selection there is declined+                    // rather than mis-anchored (Decision 8).+                    html += "<p>" + renderInline(text, in: .unmapped, context: context) + "</p>"                 case .block(let nestedBlock):                     // Rich blocks (table, image, mermaid, code, blockquote) render fully via                     // renderInnerBlock — no own <section>, inline text unrecorded — so the@@ -596,17 +624,17 @@ nonisolated enum BlockHTMLEmitter {      /// Recursively renders any block nested inside a list item or blockquote WITHOUT its own     /// `<section>` wrapper (no `data-prism-block-id`, no add-note affordance): nested blocks-    /// anchor at the parent list item / blockquote (Req 5.3). Inline text is rendered with-    /// `recordRuns: false` so a selection there resolves to no source run and is declined,-    /// never mis-anchored (Decision 8). Nested mermaid/image keep their `data-prism-*`+    /// anchor at the parent list item / blockquote (Req 5.3). Inline text is rendered+    /// `.unmapped` so a selection there resolves to no source run and is declined, never+    /// mis-anchored (Decision 8). Nested mermaid/image keep their `data-prism-*`     /// attributes so the page-world renderers still work.     private static func renderInnerBlock(_ block: MarkdownBlock, context: Context) -> String {         switch block {         case .paragraph(let markdown):-            return "<p>" + renderInline(markdown, context: context, recordRuns: false) + "</p>"+            return "<p>" + renderInline(markdown, in: .unmapped, context: context) + "</p>"         case .heading(let level, let text):             let clamped = min(max(level, 1), 6)-            return "<h\(clamped)>" + renderInline(text, context: context, recordRuns: false) + "</h\(clamped)>"+            return "<h\(clamped)>" + renderInline(text, in: .unmapped, context: context) + "</h\(clamped)>"         case .blockquote(_, let children):             var inner = ""             for child in children {@@ -619,14 +647,14 @@ nonisolated enum BlockHTMLEmitter {             // items emit no data-prism-sub, keeping prism-notes.js from injecting per-item "+"             // affordances (which would also suppress the parent block's own block-level "+").             return renderListMarkup(ordered: ordered, start: start, items: items,-                                    subIDPrefix: nil, context: context)+                                    subIDPrefix: nil, itemOffsets: nil, context: context)         case .codeBlock(let language, let code):             return codeHTML(language: language, code: code, context: context)         case .mermaid(let source, _):             return mermaidHTML(source: source, context: context)         case .table(let headers, let rows, let alignments):             return tableHTML(headers: headers, rows: rows, alignments: alignments,-                             context: context, recordRuns: false)+                             context: context, cellOffsets: nil)         case .image(let source, let alt, let title, let link, let width, let height):             let model = ImageModel(source: source, alt: alt, title: title,                                    link: link, width: width, height: height)@@ -643,7 +671,7 @@ nonisolated enum BlockHTMLEmitter {         default:             // Other block kinds (html, metadata, details) are not expected as list             // or blockquote children; fall back to their escaped text so nothing is dropped.-            return "<p>" + renderInline(block.textContent, context: context, recordRuns: false) + "</p>"+            return "<p>" + renderInline(block.textContent, in: .unmapped, context: context) + "</p>"         }     } @@ -653,19 +681,29 @@ nonisolated enum BlockHTMLEmitter {         headers: [String], rows: [[String]], alignments: [ColumnAlignment],         block: MarkdownBlock, domID: String, context: Context     ) -> String {+        // table.textContent joins the cells (" | " within a row, "\n" between rows and after+        // the header line), so each cell's runs are rebased by that cell's offset in the+        // join (T-1941).         section(block: block, domID: domID, role: nil,                 extraAttributes: ["data-prism-kind=\"table\""],-                inner: tableHTML(headers: headers, rows: rows, alignments: alignments,-                                 context: context, recordRuns: true))+                inner: tableHTML(+                    headers: headers, rows: rows, alignments: alignments, context: context,+                    cellOffsets: MarkdownBlock.tableCellTextOffsets(headers: headers, rows: rows)+                ))     }      /// Builds the inner table markup (the `.prism-table-wrap` + `<table>`) WITHOUT the     /// per-block `<section>` wrapper. `emitTable` wraps it via `section(...)` for top-level-    /// use (recording runs); `renderInnerBlock` calls it directly for a table nested in a-    /// list item or blockquote with `recordRuns: false`.+    /// use; `renderInnerBlock` calls it directly for a table nested in a list item or+    /// blockquote.+    ///+    /// `cellOffsets` is each cell's UTF-16 offset in the enclosing block's `textContent`+    /// (from `MarkdownBlock.tableCellTextOffsets`), or `nil` for a nested table whose cell+    /// text is not part of that string — then every cell is unmapped and a selection inside+    /// it is declined (Decision 8).     private static func tableHTML(         headers: [String], rows: [[String]], alignments: [ColumnAlignment],-        context: Context, recordRuns: Bool+        context: Context, cellOffsets: (headers: [Int], rows: [[Int]])?     ) -> String {         func alignment(_ column: Int) -> String {             guard column < alignments.count else { return "left" }@@ -679,15 +717,19 @@ nonisolated enum BlockHTMLEmitter {         // Header row uses the fixed sub-ID `row-header` matching NoteAnchor (Req 5.1).         html += "<thead><tr data-prism-sub=\"row-header\">"         for (column, header) in headers.enumerated() {+            let span = cellOffsets.map { InlineSpan.subspan(offset: $0.headers[column]) } ?? .unmapped             html += "<th scope=\"col\" style=\"text-align:\(alignment(column))\">"-                + renderInline(header, context: context, recordRuns: recordRuns) + "</th>"+                + renderInline(header, in: span, context: context) + "</th>"         }         html += "</tr></thead><tbody>"         for (rowIndex, row) in rows.enumerated() {             html += "<tr data-prism-sub=\"row-\(rowIndex)\">"             for (column, cell) in row.enumerated() {+                let span = cellOffsets.map {+                    InlineSpan.subspan(offset: $0.rows[rowIndex][column])+                } ?? .unmapped                 html += "<td style=\"text-align:\(alignment(column))\">"-                    + renderInline(cell, context: context, recordRuns: recordRuns) + "</td>"+                    + renderInline(cell, in: span, context: context) + "</td>"             }             html += "</tr>"         }@@ -820,11 +862,18 @@ nonisolated enum BlockHTMLEmitter {         let depth: Int     } +    /// `summarySpan` states where the summary sits in the enclosing block's `textContent`:+    /// `.subspan(offset: 0)` for a top-level `<details>` (its `textContent` starts with the+    /// summary), `.unmapped` for a NESTED one — the nested summary's runs would be recorded+    /// under the OUTER block's DOM id (the recursion emits its own `<section>`, but `emit`+    /// keys the map by the top-level block), and every other nested child is unmapped too,+    /// so anchoring just the summary would be both inconsistent and, before T-1941, wrong.     private static func emitDetails(-        _ model: DetailsModel, block: MarkdownBlock, domID: String, context: Context+        _ model: DetailsModel, block: MarkdownBlock, domID: String,+        summarySpan: InlineSpan, context: Context     ) -> String {         let openAttr = model.isOpen ? " open" : ""-        let summaryHTML = renderInline(model.summary, context: context)+        let summaryHTML = renderInline(model.summary, in: summarySpan, context: context)         var childrenHTML = ""         for (index, child) in model.children.enumerated() {             switch child {@@ -833,10 +882,15 @@ nonisolated enum BlockHTMLEmitter {                 // Occurrence-qualify the child DOM id (matching BlockDOMID's `-{index}`                 // suffix) so two sibling <details> under the same parent don't collide on                 // one id — duplicate ids are invalid HTML and break id-based anchoring.-                childrenHTML += emitDetails(nested, block: child, domID: "\(domID)-d\(index)", context: context)+                childrenHTML += emitDetails(nested, block: child, domID: "\(domID)-d\(index)",+                                            summarySpan: .unmapped, context: context)             case .list(let ordered, let start, let items):+                // A list child sits past the summary in the details' textContent, and its+                // sibling children all render unmapped via renderInnerBlock; keep the whole+                // body unmapped rather than anchoring one child kind (Decision 8).                 childrenHTML += renderListMarkup(ordered: ordered, start: start, items: items,-                                                subIDPrefix: "item", context: context)+                                                subIDPrefix: "item", itemOffsets: nil,+                                                context: context)             default:                 childrenHTML += renderInnerBlock(child, context: context)             }@@ -850,30 +904,59 @@ nonisolated enum BlockHTMLEmitter {      // MARK: - Inline rendering + footnote badges -    /// Renders inline markdown to HTML, accumulating the block's runs into `context`.+    /// Where an inline string sits inside the enclosing block's `MarkdownBlock.textContent`+    /// — the coordinate space every run recorded for that block is consumed in+    /// (`WebDocumentMessageRouter.createSelectionNote` slices `textContent` with the offsets+    /// JS resolves through the map).+    ///+    /// `InlineHTMLRenderer` starts a fresh cursor at zero for every string it is handed, so+    /// runs always come back in the coordinate space of THAT string. Stating the span is+    /// therefore mandatory at every call site (no default): a caller that renders only part+    /// of `textContent` cannot append un-rebased runs by omission, which is what made this a+    /// recurring defect class (T-1673 → T-1876 → T-1941).+    enum InlineSpan: Equatable, Sendable {+        /// The string IS the block's whole `textContent` (offset 0) — a paragraph, a+        /// heading.+        case wholeBlock+        /// The string is a sub-span of the block's `textContent` starting `offset` UTF-16+        /// units in: a table cell, a list item's content, a leading child's text. The+        /// offset must come from the same join `textContent` performs — see+        /// `MarkdownBlock.tableCellTextOffsets` / `listItemTextOffsets`.+        case subspan(offset: Int)+        /// The string does not appear in the block's `textContent` at all (a nested block, a+        /// continuation paragraph, a nested list item). Run IDs are still allocated so the+        /// HTML is byte-identical, but nothing is recorded: a selection there resolves to no+        /// map entry and is DECLINED rather than mis-anchored (Decision 8, safe-by-decline).+        case unmapped+    }++    /// Renders inline markdown to HTML, accumulating the block's runs into `context` after+    /// rebasing them into `span`'s coordinate space.     ///     /// The whole string is parsed once. Resolvable `[^id]` references become inert badge     /// chrome inside `InlineHTMLRenderer`, which classifies them from the SOURCE before-    /// parsing (`FootnoteReferenceScanner`) — the emitter no longer splits the source, so-    /// there is one parse, one cursor and one coordinate space. Runs therefore come back in-    /// the coordinate space of the string handed in, with no rebasing.+    /// parsing (`FootnoteReferenceScanner`) — the emitter never splits the source, so there+    /// is one parse and one cursor per call.     ///-    /// That string is the block's own source for a paragraph, heading, blockquote first-    /// child, or top-level details summary. `renderListMarkup` (item content), `tableHTML`-    /// (header/cell) and `emitDetails`' recursive NESTED summary hand it a SUBSTRING and-    /// record the result into the enclosing block's runs, so those offsets are still-    /// sub-span-local — tracked as T-1941, and unrelated to footnotes.+    /// The rebase is centralised here rather than at the call sites: callers state WHERE+    /// their string sits, and this function is the only place that appends to+    /// `context.blockRuns`.     ///-    /// When `recordRuns` is `false` the emitted HTML is byte-identical (run IDs are still-    /// allocated so the `data-prism-run` attributes match), but the runs are NOT appended to-    /// `context.blockRuns`. A selection landing on an unrecorded run resolves to no source-    /// map entry and is declined rather than mis-anchored (Decision 8, safe-by-decline). This-    /// is used by `renderInnerBlock` (nested blocks) and a blockquote's non-first children.-    static func renderInline(_ source: String, context: Context, recordRuns: Bool = true) -> String {+    /// `badgeSourceStarts` is deliberately NOT rebased: it is keyed by the inline source+    /// string and consumed by `SearchStateFeeder` against that same string's own occurrence+    /// scan (T-1853), so it lives in the string's coordinate space, not the block's.+    static func renderInline(_ source: String, in span: InlineSpan, context: Context) -> String {         let result = InlineHTMLRenderer.render(             source: source, footnotes: context.footnotes, runIDAllocator: &context.nextRunID         )-        if recordRuns { context.blockRuns.append(contentsOf: result.runs) }+        switch span {+        case .wholeBlock:+            context.blockRuns.append(contentsOf: result.runs)+        case .subspan(let offset):+            context.blockRuns.append(contentsOf: result.runs.map { $0.rebased(by: offset) })+        case .unmapped:+            break+        }         // 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
prism/Models/MarkdownBlock.swift Modified +67 / -4
diff --git a/prism/Models/MarkdownBlock.swift b/prism/Models/MarkdownBlock.swiftindex d03f6b6..fd3485e 100644--- a/prism/Models/MarkdownBlock.swift+++ b/prism/Models/MarkdownBlock.swift@@ -755,6 +755,66 @@ nonisolated enum MarkdownBlock: Identifiable, Equatable, Sendable {         return result     } +    // MARK: - textContent coordinate space (T-1941)+    //+    // `textContent` is the coordinate space every source-map run is consumed in+    // (`WebDocumentMessageRouter.createSelectionNote` slices it with the UTF-16 offsets JS+    // resolves). For a table and a list that string is a JOIN of the same inline strings the+    // emitter renders one at a time, so the emitter has to rebase each part's runs by that+    // part's offset in the join. The separators and the offset helpers live here, beside the+    // join they describe, so the two sides cannot drift: changing a separator changes both.++    /// The separator `textContent` puts between a table row's cells.+    static let tableCellSeparator = " | "+    /// The separator `textContent` puts between a table's rows (and its header line).+    static let tableRowSeparator = "\n"+    /// The separator `textContent` puts between a list's top-level items.+    static let listItemSeparator = "\n"++    /// The UTF-16 offset of each part within `parts.joined(separator: separator)`,+    /// shifted by `base`.+    static func joinedOffsets(of parts: [String], separator: String, from base: Int = 0) -> [Int] {+        var offsets: [Int] = []+        offsets.reserveCapacity(parts.count)+        var offset = base+        let separatorLength = separator.utf16.count+        for part in parts {+            offsets.append(offset)+            offset += part.utf16.count + separatorLength+        }+        return offsets+    }++    /// The UTF-16 offsets, in the `textContent` of a `.table` block, of every header cell+    /// and every body cell — the coordinate space `tableHTML`'s per-cell runs must be+    /// rebased into.+    static func tableCellTextOffsets(+        headers: [String], rows: [[String]]+    ) -> (headers: [Int], rows: [[Int]]) {+        let headerOffsets = joinedOffsets(of: headers, separator: tableCellSeparator)+        // textContent is `headerLine + rowSeparator + rowLines.joined(rowSeparator)`, so the+        // first row line starts one separator past the header line — including when the+        // header line is empty.+        let headerLineLength = headers.joined(separator: tableCellSeparator).utf16.count+        let rowLineOffsets = joinedOffsets(+            of: rows.map { $0.joined(separator: tableCellSeparator) },+            separator: tableRowSeparator,+            from: headerLineLength + tableRowSeparator.utf16.count+        )+        let rowOffsets = zip(rows, rowLineOffsets).map { row, lineStart in+            joinedOffsets(of: row, separator: tableCellSeparator, from: lineStart)+        }+        return (headers: headerOffsets, rows: rowOffsets)+    }++    /// The UTF-16 offsets, in the `textContent` of a `.list` block, of every top-level+    /// item's content — the coordinate space `renderListMarkup`'s per-item runs must be+    /// rebased into. Nested items are absent from `textContent` entirely, so they have no+    /// offset and must not be recorded at all.+    static func listItemTextOffsets(items: [ListItem]) -> [Int] {+        joinedOffsets(of: items.map(\.content), separator: listItemSeparator)+    }+     /// Plain text representation of block content for notes feature.     ///     /// Used for:@@ -776,11 +836,14 @@ nonisolated enum MarkdownBlock: Identifiable, Equatable, Sendable {             // paragraph's run offsets index into this prefix (Decision 8).             return children.map { $0.textContent }.joined(separator: "\n\n")         case .list(_, _, let items):-            return items.map { $0.content }.joined(separator: "\n")+            // Top-level item content only; nested items live in `item.children` and are+            // deliberately absent (see `listItemTextOffsets`).+            return items.map { $0.content }.joined(separator: Self.listItemSeparator)         case .table(let headers, let rows, _):-            let headerStr = headers.joined(separator: " | ")-            let rowsStr = rows.map { $0.joined(separator: " | ") }.joined(separator: "\n")-            return headerStr + "\n" + rowsStr+            let headerStr = headers.joined(separator: Self.tableCellSeparator)+            let rowsStr = rows.map { $0.joined(separator: Self.tableCellSeparator) }+                .joined(separator: Self.tableRowSeparator)+            return headerStr + Self.tableRowSeparator + rowsStr         case .thematicBreak:             return ""         case .image(_, let alt, _, _, _, _):
prism/Models/DocumentSourceMap.swift Modified +12 / -0
diff --git a/prism/Models/DocumentSourceMap.swift b/prism/Models/DocumentSourceMap.swiftindex bd7df96..53e9645 100644--- a/prism/Models/DocumentSourceMap.swift+++ b/prism/Models/DocumentSourceMap.swift@@ -41,6 +41,18 @@ nonisolated struct DocumentSourceMap: Codable, Equatable, Sendable {          /// UTF-16 length of the run's source span.         let length: Int++        /// The same run shifted into an enclosing coordinate space.+        ///+        /// The emitter renders some inline strings that are only a SUB-SPAN of the block's+        /// source text — a table cell, a list item's content — and the inline renderer+        /// always starts its cursor at zero, so those runs come back in sub-span-local+        /// coordinates. They are consumed against `MarkdownBlock.textContent`, which joins+        /// those parts, so each part's runs must be rebased by that part's offset in the+        /// join before they are recorded (T-1941; the same defect class as T-1876/T-1673).+        func rebased(by offset: Int) -> Run {+            offset == 0 ? self : Run(runID: runID, sourceStart: sourceStart + offset, length: length)+        }     }      /// Runs keyed by occurrence-qualified DOM id (the `<section id="b-{hash}-{i}">`
prismTests/WebRendering/DocumentSourceMapTests.swift Tests +227 / -0
diff --git a/prismTests/WebRendering/DocumentSourceMapTests.swift b/prismTests/WebRendering/DocumentSourceMapTests.swiftindex d252113..e7c66da 100644--- a/prismTests/WebRendering/DocumentSourceMapTests.swift+++ b/prismTests/WebRendering/DocumentSourceMapTests.swift@@ -300,6 +300,233 @@ struct DocumentSourceMapInvariantTests {         #expect(wordRange?.selectedText == "after")     } +    // MARK: - Sub-span call sites record in textContent coordinates (T-1941)+    //+    // `tableHTML` renders one inline string per CELL and `renderListMarkup` one per list+    // ITEM, but the block's runs are consumed against `MarkdownBlock.textContent`, which+    // JOINS those strings (" | " between cells, "\n" between rows and between items). A run+    // recorded in cell-local / item-local coordinates therefore indexes a different string+    // than the one the router slices, so every cell after the first in a row, every row after+    // the first, and every list item after the first quote the wrong text.++    /// A three-item list whose `textContent` is "alpha one\nbeta two\ngamma three".+    private func multiItemList() -> MarkdownBlock {+        .list(ordered: false, start: 1, items: [+            ListItem(content: "alpha one", checkbox: nil),+            ListItem(content: "beta two", checkbox: nil),+            ListItem(content: "gamma three", checkbox: nil),+        ])+    }++    /// A 2x2 table whose `textContent` is+    /// "Name | Role\nAda | Engineer\nBob | Writer".+    private func multiCellTable() -> MarkdownBlock {+        .table(+            headers: ["Name", "Role"],+            rows: [["Ada", "Engineer"], ["Bob", "Writer"]],+            alignments: [.leading, .leading]+        )+    }++    /// Asserts the runs are ordered, non-overlapping and in bounds of `source`, and returns+    /// the source substring each run covers.+    private func orderedSpans(+        _ runs: [DocumentSourceMap.Run], in source: String, _ label: String+    ) -> [String] {+        var previousEnd = 0+        for run in runs {+            #expect(run.length >= 0, "\(label): negative run length")+            #expect(run.sourceStart >= previousEnd,+                    "\(label): run at \(run.sourceStart) overlaps/precedes previous end \(previousEnd)")+            #expect(run.sourceStart + run.length <= source.utf16.count,+                    "\(label): run [\(run.sourceStart), \(run.length)] out of bounds of the joined text")+            previousEnd = run.sourceStart + run.length+        }+        return runs.compactMap { sourceSpan($0, in: source) }+    }++    @Test("A multi-item list records runs in the joined textContent coordinate space")+    func listItemRunsUseJoinedCoordinates() {+        let block = multiItemList()+        let runs = runs(for: block)+        let spans = orderedSpans(runs, in: block.textContent, "multi-item list")+        #expect(spans == ["alpha one", "beta two", "gamma three"],+                "each item's run must map to that item's text in the joined list textContent")+    }++    @Test("A multi-cell, multi-row table records runs in the joined textContent space")+    func tableCellRunsUseJoinedCoordinates() {+        let block = multiCellTable()+        let runs = runs(for: block)+        let spans = orderedSpans(runs, in: block.textContent, "multi-cell table")+        #expect(spans == ["Name", "Role", "Ada", "Engineer", "Bob", "Writer"],+                "each cell's run must map to that cell's text in the joined table textContent")+    }++    @MainActor+    @Test("A selection in a non-first list item converts to that item's text (T-1941)")+    func selectionInNonFirstListItemQuotesThatItem() throws {+        let block = multiItemList()+        let last = try #require(runs(for: block).last)+        let textRange = WebDocumentMessageRouter.noteTextRange(+            in: block.textContent,+            utf16Range: .init(start: last.sourceStart, length: last.length)+        )+        #expect(textRange?.selectedText == "gamma three")+    }++    @MainActor+    @Test("A selection in a non-first table cell converts to that cell's text (T-1941)")+    func selectionInNonFirstTableCellQuotesThatCell() throws {+        let block = multiCellTable()+        let last = try #require(runs(for: block).last)+        let textRange = WebDocumentMessageRouter.noteTextRange(+            in: block.textContent,+            utf16Range: .init(start: last.sourceStart, length: last.length)+        )+        #expect(textRange?.selectedText == "Writer")+    }++    @Test("Table/list sub-span offsets are derived from the same joins as textContent")+    func subspanOffsetsAgreeWithTextContent() {+        // The emitter's coordinate space and `textContent` must agree EXPLICITLY: the offsets+        // the emitter rebases by are produced by the same join helpers `textContent` uses, so+        // slicing textContent at each offset returns the part verbatim.+        let table = multiCellTable()+        let tableText = table.textContent as NSString+        let offsets = MarkdownBlock.tableCellTextOffsets(+            headers: ["Name", "Role"], rows: [["Ada", "Engineer"], ["Bob", "Writer"]]+        )+        for (column, header) in ["Name", "Role"].enumerated() {+            #expect(tableText.substring(with: NSRange(location: offsets.headers[column],+                                                      length: (header as NSString).length)) == header)+        }+        for (rowIndex, row) in [["Ada", "Engineer"], ["Bob", "Writer"]].enumerated() {+            for (column, cell) in row.enumerated() {+                #expect(tableText.substring(with: NSRange(location: offsets.rows[rowIndex][column],+                                                          length: (cell as NSString).length)) == cell)+            }+        }++        let items = [+            ListItem(content: "alpha one", checkbox: nil),+            ListItem(content: "beta two", checkbox: nil),+            ListItem(content: "gamma three", checkbox: nil),+        ]+        let listText = MarkdownBlock.list(ordered: false, start: 1, items: items).textContent as NSString+        for (index, offset) in MarkdownBlock.listItemTextOffsets(items: items).enumerated() {+            let content = items[index].content+            #expect(listText.substring(with: NSRange(location: offset,+                                                     length: (content as NSString).length)) == content)+        }+    }++    // MARK: - Multi-byte sub-spans (T-1941)+    //+    // `joinedOffsets` advances by `part.utf16.count`, and every consumer of a run indexes+    // `textContent` in UTF-16 units. Every other fixture in this area — and the whole parity+    // corpus — is ASCII, where a Character count and a UTF-16 count agree, so a regression+    // swapping `part.utf16.count` for `part.count` would pass all of them. The two tests+    // below put an astral scalar in a cell / item BEFORE the asserted one, which is the only+    // shape that distinguishes them: "Ada 😀" is 5 Characters but 6 UTF-16 units, so a+    // Character-count offset lands every following cell one unit short — still monotonic,+    // still in bounds, and quoting text shifted by one.++    /// A 2x2 table with astral + CJK text in the first HEADER cell and the first body row, so+    /// every offset after each of them differs between UTF-16 units and Character count.+    /// `textContent` is "Name 😀 | Role\nAda 😀 | エンジニア\nBob | Writer".+    ///+    /// The astral scalar sits in the header as well as the body because the table offsets are+    /// built from three separate counts, and one fixture has to distinguish all three: the+    /// header cell offsets (`joinedOffsets` over the headers), the body-row base+    /// (`headerLineLength`, a second UTF-16 count of the joined header line), and the body cell+    /// offsets (`joinedOffsets` with `from:` that base). With ASCII headers, a Character-count+    /// regression in `headerLineLength` alone would shift every body offset uniformly and still+    /// pass.+    private func multiByteTable() -> MarkdownBlock {+        .table(+            headers: ["Name 😀", "Role"],+            rows: [["Ada 😀", "エンジニア"], ["Bob", "Writer"]],+            alignments: [.leading, .leading]+        )+    }++    /// A 3-item list with astral + CJK text in the first two items.+    /// `textContent` is "alpha 😀 one\nベータ two\ngamma three".+    private func multiByteList() -> MarkdownBlock {+        .list(ordered: false, start: 1, items: [+            ListItem(content: "alpha 😀 one", checkbox: nil),+            ListItem(content: "ベータ two", checkbox: nil),+            ListItem(content: "gamma three", checkbox: nil),+        ])+    }++    @MainActor+    @Test("A cell after multi-byte text records its UTF-16 offset, not a Character offset")+    func multiByteTableCellsUseUTF16Offsets() throws {+        let block = multiByteTable()+        let text = block.textContent+        #expect(text.count != text.utf16.count,+                "the fixture must distinguish Character count from UTF-16 count")++        // Every cell maps to its own text in the joined textContent, in bounds and ordered.+        let spans = orderedSpans(runs(for: block), in: text, "multi-byte table")+        #expect(spans == ["Name 😀", "Role", "Ada 😀", "エンジニア", "Bob", "Writer"])++        // Pin the persisted offsets themselves against NSString (UTF-16) positions, so the+        // assertion fails on a Character-count helper even if the slices happened to line up.+        // The header cell after the astral scalar pins `joinedOffsets` over the headers; the+        // body cells pin `headerLineLength` (the base every row line is measured from) and+        // `joinedOffsets` with that base.+        let ns = text as NSString+        let offsets = MarkdownBlock.tableCellTextOffsets(+            headers: ["Name 😀", "Role"], rows: [["Ada 😀", "エンジニア"], ["Bob", "Writer"]]+        )+        #expect(offsets.headers[0] == 0)+        #expect(offsets.headers[1] == ns.range(of: "Role").location)+        #expect(offsets.rows[0][0] == ns.range(of: "Ada 😀").location)+        #expect(offsets.rows[0][1] == ns.range(of: "エンジニア").location)+        #expect(offsets.rows[1][0] == ns.range(of: "Bob").location)+        #expect(offsets.rows[1][1] == ns.range(of: "Writer").location)++        // End to end: the last cell's recorded run, through the router, quotes that cell.+        let last = try #require(runs(for: block).last)+        #expect(last.sourceStart == ns.range(of: "Writer").location)+        let textRange = WebDocumentMessageRouter.noteTextRange(+            in: text, utf16Range: .init(start: last.sourceStart, length: last.length)+        )+        #expect(textRange?.selectedText == "Writer")+    }++    @MainActor+    @Test("An item after multi-byte text records its UTF-16 offset, not a Character offset")+    func multiByteListItemsUseUTF16Offsets() throws {+        let block = multiByteList()+        let text = block.textContent+        #expect(text.count != text.utf16.count,+                "the fixture must distinguish Character count from UTF-16 count")++        let spans = orderedSpans(runs(for: block), in: text, "multi-byte list")+        #expect(spans == ["alpha 😀 one", "ベータ two", "gamma three"])++        let ns = text as NSString+        let items = [+            ListItem(content: "alpha 😀 one", checkbox: nil),+            ListItem(content: "ベータ two", checkbox: nil),+            ListItem(content: "gamma three", checkbox: nil),+        ]+        let offsets = MarkdownBlock.listItemTextOffsets(items: items)+        #expect(offsets[1] == ns.range(of: "ベータ two").location)+        #expect(offsets[2] == ns.range(of: "gamma three").location)++        let last = try #require(runs(for: block).last)+        #expect(last.sourceStart == ns.range(of: "gamma three").location)+        let textRange = WebDocumentMessageRouter.noteTextRange(+            in: text, utf16Range: .init(start: last.sourceStart, length: last.length)+        )+        #expect(textRange?.selectedText == "gamma three")+    }+     @Test("UTF-16 coordinate space: astral chars count as surrogate pairs")     func utf16Coordinates() {         // One astral scalar = 2 UTF-16 units; the run length must reflect that.
prismTests/WebRendering/WebStructuredSelectionTests.swift Tests +80 / -31
diff --git a/prismTests/WebRendering/WebStructuredSelectionTests.swift b/prismTests/WebRendering/WebStructuredSelectionTests.swiftindex f750579..5a44047 100644--- a/prismTests/WebRendering/WebStructuredSelectionTests.swift+++ b/prismTests/WebRendering/WebStructuredSelectionTests.swift@@ -9,18 +9,20 @@ // //  1. A PURE source-map invariant test (no live page): for the representative blocks the //     fidelity feature now emits structurally — a single-paragraph quote, a MULTI-paragraph-//     quote, a list item with a nested block, and a nested blockquote — the runs recorded-//     for each block id are non-overlapping and ordered ascending in source space-//     (sourceStart monotonic, no overlap). This guards against a multi-run rebasing-//     regression: the structured emitter renders several `renderInline` calls per block, and-//     a regression that started recording the secondary segments would interleave runs whose-//     offsets restart at 0 (overlap / non-monotonic), which this catches.+//     quote, a list item with a nested block, and a nested blockquote — plus the whole+//     parity fixture corpus, the runs recorded for each block id are ordered ascending in+//     source space, non-overlapping, and within the block's `textContent` (T-1941). This+//     guards against a multi-run rebasing regression in BOTH directions: the structured+//     emitter renders several `renderInline` calls per block, and a regression that recorded+//     a secondary segment un-rebased would interleave runs restarting at 0 (caught by+//     monotonicity), while an offset that drifts too far forward — a Character count instead+//     of a UTF-16 count — stays monotonic and is caught only by the upper bound. // //  2. A selection-behaviour test for the safe-decline rule (Decision 8). The structured //     emitter records runs ONLY for a multi-paragraph blockquote's FIRST child paragraph-//     (the run-recording path), and renders every other child — and every nested block —-//     with `recordRuns: false`. So a selection in the first paragraph resolves to a valid-//     source range (its text), while a selection in the second paragraph (or a nested block)+//     (`InlineSpan.subspan(offset: 0)`), and renders every other child — and every nested+//     block — `.unmapped`. So a selection in the first paragraph resolves to a valid source+//     range (its text), while a selection in the second paragraph (or a nested block) //     resolves to no run and is DECLINED, never mis-anchored. // //     This is verified at BOTH levels:@@ -44,26 +46,58 @@ struct WebStructuredSourceMapInvariantTests {      private static let settings = RenderSettings(showHTMLComments: false, strings: .fallback) -    /// Emits `markdown` through the full parse + emit pipeline and returns the source map.-    private func sourceMap(_ markdown: String) -> DocumentSourceMap {+    /// Emits `markdown` through the full parse + emit pipeline and returns the source map+    /// together with the blocks it was built from. The blocks are needed to bound each run+    /// against the `MarkdownBlock.textContent` it indexes.+    private func emitted(_ markdown: String) -> (map: DocumentSourceMap, blocks: [MarkdownBlock]) {         let parsed = MarkdownBlockParser.parseWithFootnotes(markdown)-        return BlockHTMLEmitter.emit(+        let document = BlockHTMLEmitter.emit(             blocks: parsed.blocks, footnotes: parsed.footnoteData, settings: Self.settings-        ).sourceMap+        )+        return (document.sourceMap, parsed.blocks)     }      /// Asserts that, for every block id in `map`, the recorded runs are ordered ascending in-    /// source space and never overlap (sourceStart monotonic, each run ends at or before the-    /// next begins). Empty/single-run blocks trivially satisfy this.-    private func expectRunsOrderedNonOverlapping(_ map: DocumentSourceMap, _ label: String) {+    /// source space, never overlap (sourceStart monotonic, each run ends at or before the next+    /// begins), and end within the UTF-16 length of the `textContent` they index.+    /// Empty/single-run blocks trivially satisfy the ordering half.+    ///+    /// Both bounds are load-bearing and catch opposite failure directions (T-1941):+    ///+    /// - Monotonicity catches an offset that is too SMALL — a sub-span call site that states+    ///   `.wholeBlock` (or `.subspan(offset: 0)`) for a string that is not at the block's+    ///   start restarts its offsets at 0, interleaving with the runs already recorded.+    /// - The upper bound catches an offset that is too LARGE, which stays monotonic and would+    ///   otherwise pass. That is exactly the shape of a Character-count regression in+    ///   `MarkdownBlock.joinedOffsets` — the reason it counts `utf16` — and of any offset that+    ///   drifts while still increasing.+    ///+    /// The per-block limit is resolved through `BlockDOMID.map(blocks:)`, which walks blocks to+    /// occurrence-qualified DOM ids exactly as `BlockHTMLEmitter.emit` keys the map, so a key+    /// present in the map with no matching block is itself a failure.+    private func expectRunsOrderedNonOverlapping(+        _ map: DocumentSourceMap, blocks: [MarkdownBlock], _ label: String+    ) {+        let textLengths = Dictionary(+            BlockDOMID.map(blocks: blocks).map { ($0.domID, $0.block.textContent.utf16.count) },+            uniquingKeysWith: { first, _ in first }+        )         for (domID, runs) in map.runs {             var previousEnd = 0+            guard let limit = textLengths[domID] else {+                Issue.record("\(label): \(domID) has recorded runs but no matching block — the source map key and BlockDOMID have drifted")+                continue+            }             for (index, run) in runs.enumerated() {                 #expect(run.length >= 0, "\(label): \(domID) run \(index) has negative length")                 #expect(                     run.sourceStart >= previousEnd,                     "\(label): \(domID) run \(index) starts at \(run.sourceStart) before previous end \(previousEnd) — overlap/out-of-order"                 )+                #expect(+                    run.sourceStart + run.length <= limit,+                    "\(label): \(domID) run \(index) [\(run.sourceStart), \(run.length)] ends past the block's textContent (\(limit) UTF-16 units) — the run is not in textContent coordinates"+                )                 previousEnd = run.sourceStart + run.length             }         }@@ -71,8 +105,8 @@ struct WebStructuredSourceMapInvariantTests {      @Test("Single-paragraph blockquote: runs are ordered and non-overlapping")     func singleParagraphQuote() {-        let map = sourceMap("> alpha **beta** gamma")-        expectRunsOrderedNonOverlapping(map, "single-paragraph quote")+        let (map, blocks) = emitted("> alpha **beta** gamma")+        expectRunsOrderedNonOverlapping(map, blocks: blocks, "single-paragraph quote")         // A single-paragraph quote records runs (its prose is mapped).         #expect(!map.runs.isEmpty, "single-paragraph quote should map its first paragraph's runs")     }@@ -82,8 +116,8 @@ struct WebStructuredSourceMapInvariantTests {         // Two paragraphs in one quote; only the first records runs (Decision 8), so the         // recorded runs must still be monotonic — a regression that also recorded paragraph 2         // would restart offsets at 0 and break monotonicity.-        let map = sourceMap("> first **para** here\n>\n> second para there")-        expectRunsOrderedNonOverlapping(map, "multi-paragraph quote")+        let (map, blocks) = emitted("> first **para** here\n>\n> second para there")+        expectRunsOrderedNonOverlapping(map, blocks: blocks, "multi-paragraph quote")     }      @Test("List item with a nested block: runs are ordered and non-overlapping")@@ -98,8 +132,8 @@ struct WebStructuredSourceMapInvariantTests {             let x = 1             ```         """-        let map = sourceMap(markdown)-        expectRunsOrderedNonOverlapping(map, "list item with nested block")+        let (map, blocks) = emitted(markdown)+        expectRunsOrderedNonOverlapping(map, blocks: blocks, "list item with nested block")     }      @Test("Nested blockquote: runs are ordered and non-overlapping")@@ -109,8 +143,8 @@ struct WebStructuredSourceMapInvariantTests {         >         > > inner quoted line         """-        let map = sourceMap(markdown)-        expectRunsOrderedNonOverlapping(map, "nested blockquote")+        let (map, blocks) = emitted(markdown)+        expectRunsOrderedNonOverlapping(map, blocks: blocks, "nested blockquote")     }      @Test("Representative block table: every block's runs are ordered and non-overlapping")@@ -132,21 +166,36 @@ struct WebStructuredSourceMapInvariantTests {          > > inner level         """-        let map = sourceMap(markdown)-        expectRunsOrderedNonOverlapping(map, "representative table")+        let (map, blocks) = emitted(markdown)+        expectRunsOrderedNonOverlapping(map, blocks: blocks, "representative table")     }      @Test("Parity footnote fixture: runs are ordered and non-overlapping (T-1876)")     func parityFootnoteFixture() throws {         // This generic invariant is what would have caught T-1876: pre-fix, a paragraph-        // with an interior reference emitted runs [0,6] then [1,5] — non-monotonic. The-        // fixture is pointed at deliberately rather than sweeping the whole corpus, since-        // list-item and table-cell fixtures still violate the invariant under T-1941.+        // with an interior reference emitted runs [0,6] then [1,5] — non-monotonic.         let markdown = try ParityFixtureSupport.load("Parity/footnotes.md")-        let map = sourceMap(markdown)-        expectRunsOrderedNonOverlapping(map, "parity footnotes fixture")+        let (map, blocks) = emitted(markdown)+        expectRunsOrderedNonOverlapping(map, blocks: blocks, "parity footnotes fixture")         #expect(!map.runs.isEmpty, "the footnote fixture's paragraphs should map runs")     }++    @Test("Every parity fixture: runs are ordered and non-overlapping (T-1941)",+          arguments: ParityFixtureSupport.fixtureNames(inGroup: "Parity"))+    func parityCorpusRunsMonotonic(name: String) throws {+        // Once every sub-span call site states its coordinate space (T-1941), the whole+        // corpus satisfies the invariant — the list and table fixtures included. Before the+        // fix, `lists.md` and `table.md` emitted item/cell-local offsets that restart at 0+        // per item and per cell, so the recorded runs were non-monotonic.+        let markdown = try ParityFixtureSupport.load("Parity/\(name).md")+        let (map, blocks) = emitted(markdown)+        expectRunsOrderedNonOverlapping(map, blocks: blocks, "parity fixture \(name)")+        // The invariant above is vacuous over an empty map, so a regression that stopped+        // recording runs entirely would pass it. Every fixture in the corpus carries mapped+        // prose (a paragraph or a heading), so none of them legitimately maps zero runs — if a+        // future fixture does, exclude it by name here rather than dropping the guard.+        #expect(!map.runs.isEmpty, "parity fixture \(name) should map runs for its prose")+    } }  // MARK: - Safe-decline selection behaviour (Decision 8)
prismTests/WebRendering/WebSelectionNoteTests.swift Tests +54 / -0
diff --git a/prismTests/WebRendering/WebSelectionNoteTests.swift b/prismTests/WebRendering/WebSelectionNoteTests.swiftindex 9ae65b7..5751698 100644--- a/prismTests/WebRendering/WebSelectionNoteTests.swift+++ b/prismTests/WebRendering/WebSelectionNoteTests.swift@@ -320,6 +320,60 @@ struct WebSelectionNoteTests {         #expect(coordinator.addNoteTextRange?.selectedText == "gamma")     } +    // MARK: - Selections in non-first list items / table cells (T-1941)++    /// Opens the add-note editor for `range` in `block` and returns the quoted text.+    private func quotedText(+        for block: MarkdownBlock, range: InboundBridgeMessage.SourceRange+    ) -> String? {+        let session = DocumentSession(clipboardContent: "x")+        session.parsedBlocks = [block]+        let coordinator = DocumentLayoutCoordinator()+        let router = WebDocumentMessageRouter(+            session: session, coordinator: coordinator,+            notesManager: NotesManager.makeForTesting(store: MockNotesStore())+        )+        router.createSelectionNote(blockID: domID(block), range: .init(start: range.start, length: range.length))+        return coordinator.addNoteTextRange?.selectedText+    }++    @Test("A live selection in a non-first list item resolves to that item's text")+    func liveSelectionInNonFirstListItem() async throws {+        // The list's runs are consumed against `textContent` — the items joined with "\n" —+        // so item 3's run must carry its offset in that joined string, not 0 (T-1941).+        let list = MarkdownBlock.list(ordered: false, start: 1, items: [+            ListItem(content: "alpha one", checkbox: nil),+            ListItem(content: "beta two", checkbox: nil),+            ListItem(content: "gamma three", checkbox: nil),+        ])+        let harness = try await harness([list])+        let resolved = try await resolveWholeRun(harness, domID: domID(list), runIndex: 2)+        let start = (resolved?["start"] as? NSNumber)?.intValue ?? -1+        let length = (resolved?["length"] as? NSNumber)?.intValue ?? -1+        #expect(resolved?["domID"] as? String == domID(list))+        #expect(start == (list.textContent as NSString).range(of: "gamma three").location)+        #expect(quotedText(for: list, range: .init(start: start, length: length)) == "gamma three")+    }++    @Test("A live selection in a non-first table cell resolves to that cell's text")+    func liveSelectionInNonFirstTableCell() async throws {+        // Cells are joined with " | " and rows with "\n" in `textContent`; the last cell of+        // the last row is the worst case for cell-local offsets (T-1941).+        let table = MarkdownBlock.table(+            headers: ["Name", "Role"],+            rows: [["Ada", "Engineer"], ["Bob", "Writer"]],+            alignments: [.leading, .leading]+        )+        let harness = try await harness([table])+        // Runs in emission order: Name, Role, Ada, Engineer, Bob, Writer.+        let resolved = try await resolveWholeRun(harness, domID: domID(table), runIndex: 5)+        let start = (resolved?["start"] as? NSNumber)?.intValue ?? -1+        let length = (resolved?["length"] as? NSNumber)?.intValue ?? -1+        #expect(resolved?["domID"] as? String == domID(table))+        #expect(start == (table.textContent as NSString).range(of: "Writer").location)+        #expect(quotedText(for: table, range: .init(start: start, length: length)) == "Writer")+    }+     @Test("A cross-block selection is reported as declined (crossBlock), not a range")     func liveCrossBlockDeclined() async throws {         let first = MarkdownBlock.paragraph(markdown: "first block")
CHANGELOG.md Modified +3 / -2
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 45fa2fc..c4cf913 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,20 +18,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Adding a note to text selected inside a table cell or a list item now quotes the text you actually selected (T-1941). Selecting a word in the second cell of a row, in any row after the first, or in any list item after the first quoted text from the start of the table or list instead — and saving stored a wrong source range, which the note then carried into relocation and inline-note export. Only the very first cell and the very first list item behaved correctly. The rendered document's text-to-source map now records every cell and every item at its real position within the block's text, so a note anchors where you put it. A few places where the map used to record an anchor that could only ever be wrong now record none at all: a nested list's items, a list nested inside a quote or another list item, a list inside a collapsible `<details>` section, the summary of a `<details>` nested inside another, and the rare quote the parser cannot break into parts. Selecting text in one of those and reaching for **Add note** now declines quietly instead of quoting text from elsewhere in the block — the block's own **+** button still adds a note, as does the **+** beside each item of a nested list. Anchoring a selection in those places is tracked separately (T-2032). This was the same fault as the footnote-selection fix below (T-1876) on a different path; every place in the renderer that draws part of a block must now state where that part sits, so the next one cannot repeat it. - Footnote popovers now use the same text settings as the document (T-1978). A footnote's content ignored the **Body Font** you chose, the in-app **Text Size** slider, and the system text size (iOS Larger Text / macOS Text Size), always rendering in the system font at the default reading size — so footnote text could be noticeably smaller than the document it belongs to, and at accessibility text sizes it stayed small while everything around it grew. Footnote content now follows the same font, scale, and system text size as document text. A font that is no longer installed still falls back to the system font, and a font name is applied as text only, so it cannot alter the popover's styling. The system **Increase Contrast** setting had been missed in the same way and now reaches popovers too, though the only footnote content that looks different for it today is an HTML comment shown inline, which kept the standard low-contrast grey. A popover already open when you change these settings updates in place, as of the fix below (T-1979). - An open footnote popover now keeps up with the document instead of freezing at the moment it opened (T-1979). Switching to dark mode, turning **Increase Contrast** on, changing the **Body Font**, moving the **Text Size** slider, changing the system text size, or toggling **Show HTML comments** all left a popover that was already on screen showing the settings it opened with — and if the file changed on disk (or a URL document was refreshed) while the popover was open, the "Footnote N" heading could update to the new document while the footnote text below it still showed the old definition. The popover now re-renders whenever anything it displays changes, including replaced footnote content under the same reference, and leaves itself alone when nothing did, so reading is not interrupted by needless redraws. Changes arriving in quick succession settle on the newest one rather than whichever finished last. - Search and reading-position restore no longer lose their place to content the document hides (T-1944). Three faults shared one cause: hidden content — the body of a collapsed section, or the carrier holding a document's YAML frontmatter — measures as a zero-size box sitting exactly at the top of the window, and several parts of the app read that as "visible". Stepping to a search match inside a collapsed section silently did nothing: the section expanded, but the app had already treated the invisible match as on screen and skipped the scroll; the match is now brought to its usual resting place a third of the way down the window as soon as the section opens. Reopening a document after collapsing the section you were reading left the page at the top instead of anywhere near your place; the restore now lands on the collapsed heading that hides the saved block — and a stale saved position pointing at the hidden frontmatter carrier now falls back to the top of the document instead of doing nothing. Hidden matches also no longer count as on-screen when deciding which highlights to draw, and expanding a section lights its highlights up immediately instead of waiting for the next scroll. The zero-size test now lives in one shared check used by every reader of section layout, so a future feature cannot quietly reintroduce the assumption. Matches inside nested collapsed disclosure blocks (`<details>`) are tracked separately (T-1930). - Stepping to a search match now scrolls there in one motion and leaves the match itself in view (T-1918). Two parts of the app both moved the page on the same step — one lining the top of the match's block up with the top of the window, the other placing the match about a third of the way down — so the page could visibly jump twice, and which position stuck depended on timing. When the block was taller than the window, the block-top alignment could even settle with the match below the fold. One owner scrolls now, resting the match about a third of the way down the window every time. Two more gaps closed with it: stepping to a match far outside the part of the document being highlighted found no highlight to scroll to and relied on the other, block-based jump to get anywhere near it; and with a single match found, pressing next or previous after scrolling away to read something else did nothing — it now brings the match back into view. A match inside a collapsed section initially still did not scroll; the hidden-content fix above (T-1944) closed that gap. - 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. - 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 (`&#91;^1&#93;`) 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.+- 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 (`&#91;^1&#93;`) 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 previously carried a separate note-anchoring limitation, fixed above (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. - Footnotes keep working after a one-line block of raw HTML (T-1877). Writing something like `<div>Text</div>` or `<details><summary>More</summary>Text</details>` on a single line silently switched off every footnote from that point on: references stayed as plain `[^1]` text instead of becoming badges, no popover was available, and the definitions themselves showed up as ordinary paragraphs. Footnote preprocessing was waiting for a closing tag that had already gone past on the same line, so it treated the rest of the document as raw HTML. Opening and closing tags are now balanced within the line, including nesting (`<div><div>x</div></div>`) and self-closing forms (`<div/>`). A tag genuinely left open — `<div>x</div><section>`, or a mismatched `<div>Text</section>` — still starts an HTML block, so definitions written inside raw HTML are still left alone. A tag name that only appears inside a quoted attribute value (`<table title="<div>">Content</table>`) no longer counts as real markup; it used to leave a phantom block open and switch off the following footnotes in exactly the same way, and so did a stray or deliberately-escaped quote inside such a value (`<div title="a"b">`). A slash inside an unquoted attribute value (`<div data-url=https://example.com/>`) is read as part of the address rather than as a self-closing tag, so definitions written under such a tag are still left alone. Checking a line is also faster than it was: where a line used to be searched up to eleven times over, once per recognised tag name, it is now walked once — several times quicker on a line that contains a `<` but opens no block — and balancing a line's tags costs time in proportion to the number of tags rather than its square. Two ways footnote pre-processing can still disagree with the real parser about where a raw-HTML block ends are tracked separately: a blank line after an unclosed tag, and a block tag name written inside an HTML comment (`<!-- comment with <div> -->`), which still switches off the footnotes that follow it. - Changing the theme while viewing raw source no longer brings back the previous version of the document (T-1759). If the file changed on disk — or a URL document was refreshed — at the same moment the colours changed, the recolour worked from a snapshot taken before the reload and, finishing last, put the old text back on screen, where it stayed until the next reload or raw/rendered toggle. Recolouring now only applies while the lines it started from are still the ones being shown. - The system **Increase Contrast** accessibility setting applies to the document again (T-1829). Since the WebKit rendering cutover, turning it on changed the app's own interface but left the document body untouched: search highlights and footnote badges stayed translucent and tertiary text stayed low-contrast. The rendered document now uses the same higher-contrast search, footnote, and tertiary colours as the rest of the app, on every theme, and the deliberately-faded "add note" button beside each block is shown at full strength. It follows the setting live — toggling it while a document is open updates immediately, without a reload and without losing your reading position, and the styling survives a WebKit process recovery. Footnote popovers were left on the standard palette at the time; they follow the setting as of the footnote-popover fix above (T-1978). - Jumping to a specific place in a document you have read before now lands where you asked instead of at your saved reading position (T-1775). This covers every way you can ask: following a link to a heading, picking a table-of-contents entry, opening a note, and stepping to a search match. The requested target was queued while the document's HTML was being prepared in the background, and the saved position — restored a moment later — replaced it, so the page settled where you last stopped reading. An explicit target now takes precedence for that load. Reading position is otherwise untouched: an ordinary reopen, returning from raw source, and a reload after the file changes on disk or a URL refresh all still restore where you left off, even with a search active.-- Adding a note to text selected after a footnote badge now quotes the text you actually selected (T-1876). In a paragraph like `before[^1] after`, selecting `after` quoted words from near the start of the paragraph instead, and saving stored a wrong source range, which the note then carried into relocation and inline-note export. The rendered document's text-to-source map now keeps its offsets in the block's own coordinates across any number of footnote badges. Footnotes inside list items and table cells are tracked separately.+- Adding a note to text selected after a footnote badge now quotes the text you actually selected (T-1876). In a paragraph like `before[^1] after`, selecting `after` quoted words from near the start of the paragraph instead, and saving stored a wrong source range, which the note then carried into relocation and inline-note export. The rendered document's text-to-source map now keeps its offsets in the block's own coordinates across any number of footnote badges. List items and table cells were fixed separately, above (T-1941). - The saved reading position is no longer corrupted by content the document does not show (T-1851). Hidden content measures as sitting exactly at the top of the window, which beat every genuinely visible block, so the document reported a block the reader could not see as the block being read. That happened with any heading collapsed, and — because the carrier holding a document's YAML frontmatter is hidden the same way — on every document that starts with frontmatter, collapsed or not. Reopening the document, returning from raw source, or recovering from a rendering-process restart then landed on the wrong block or did nothing at all. Content the document does not lay out is now skipped when working out the reading position. - Images written in raw HTML with unquoted attribute values (`<img src=cat.png>`, `<source srcset=hero.webp>`) now render like their quoted equivalents (T-1655). Only quoted `src`/`srcset` values were routed through Prism's image handler, so an unquoted relative address was stripped as unrecognised and the image disappeared, while an unquoted remote address reached the page without going through the handler at all — the content security policy still blocked that fetch, so nothing was ever loaded off-device. Every quoting style is now mediated, and text that merely looks like `src=` inside another attribute's value is left untouched. Two scans in that mediation step also no longer bog down on malformed markup — an address whose opening quote is never closed, or a run of half-written tags — where they used to cost time in proportion to the square of the tag's length: a 16 KB broken address now takes a fraction of a millisecond instead of eight seconds. Malformed markup can still slow other steps of the rendering pipeline; that is tracked separately. - Opening or reloading a large or HTML-heavy document no longer freezes the UI (T-1681). Since the WebKit rendering cutover, the full document HTML — including a SwiftSoup sanitisation pass per raw-HTML block and per inline HTML run — was built synchronously on the main thread on every serve, so a big or markup-dense document blocked the app while it rendered, and re-built the HTML on every reload. The build now runs off the main thread and its result is cached per parse: the UI stays responsive, and reloads (external file change, URL refresh, WebContent-process recovery, the iOS folder-access retry) reuse the cached HTML instead of re-emitting. Very HTML-dense documents can still take a noticeable moment to appear; making that incremental is tracked separately.
specs/webview-rendering/decision_log.md Modified +2 / -1
diff --git a/specs/webview-rendering/decision_log.md b/specs/webview-rendering/decision_log.mdindex 08c1289..36f282b 100644--- a/specs/webview-rendering/decision_log.md+++ b/specs/webview-rendering/decision_log.md@@ -4,7 +4,8 @@  | ID | Date | Decision | Rationale | |----|------|----------|-----------|-| Q1 | 2026-07-26 | Rebase only `renderInline`'s footnote-split segments (T-1876); leave the other sub-span callers that record runs — `renderListMarkup` (item content), `tableHTML` (header/cell) and `emitDetails`' recursive nested summary — for T-1941 rather than switching them to safe-by-decline (`recordRuns: false`) | Declining would regress selection notes that anchor correctly today in item 0 / cell 0 / the top-level summary; rebasing those callers needs each sub-span's offset in the block source, which is a separate change |+| Q1 | 2026-07-26 | Rebase only `renderInline`'s footnote-split segments (T-1876); leave the other sub-span callers that record runs — `renderListMarkup` (item content), `tableHTML` (header/cell) and `emitDetails`' recursive nested summary — for T-1941 rather than switching them to safe-by-decline (`recordRuns: false`) | Declining would regress selection notes that anchor correctly today in item 0 / cell 0 / the top-level summary; rebasing those callers needs each sub-span's offset in the block source, which is a separate change — superseded by Q2 |+| Q2 | 2026-07-31 | Replace `renderInline`'s `recordRuns: Bool` with a REQUIRED `InlineSpan` (`.wholeBlock` / `.subspan(offset:)` / `.unmapped`) and perform the rebase inside `renderInline`; derive sub-span offsets from `MarkdownBlock.tableCellTextOffsets` / `listItemTextOffsets`, which sit beside `textContent` and share its separator constants (T-1941) | A per-site rebase would have been the fourth occurrence of the class (T-1673 → T-1876 → T-1941). With no default argument a new call site cannot append un-rebased runs by omission, and with `renderInline` the only writer of `context.blockRuns` it cannot bypass the rebase either; keeping the offset helpers in the same file as the join they mirror stops the emitter and `textContent` drifting. Sub-spans absent from `textContent` (nested list items, continuation paragraphs, nested blocks, nested `<details>` summaries) are `.unmapped` — declined, never mis-anchored (Decision 8). Five of those sites previously recorded a base-0 (always wrong) run and now record none: nested list items, a `renderInnerBlock` list, a `<details>` child list, a nested `<details>` summary, and the child-less blockquote fallback. Mapping them properly — which needs `textContent` widened to include nested text — is deliberately out of scope, tracked as T-2032 |  ## Decision 1: Full replacement, both platforms, one phased spec 
docs/agent-notes/webview-rendering-status.md Modified +1 / -1
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex 4dce529..ff9f2a4 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -89,7 +89,7 @@ Resolved this session: **build warnings** (now zero, `f8b9480`/`3594bc0`) and ** - **Horizontal rubber-band in the WebView is NOT fixed by CSS `overflow-x: hidden`** (that only stops content overflow) — the scroll-view elasticity needs `.scrollBounceBehavior(.basedOnSize, axes: .horizontal)` on the SwiftUI `WebView`. - **`.fileImporter` / pickers present reliably at LAYOUT level, not from the nested DocumentScrollContent** — drive folder pickers from the layout (coordinator-flag) or from a sheet's own context. - **CSS `:has()` does not reliably re-evaluate on a `td` when a child is inserted live** (e.g. a note dot added during the session) — for "hide X when sibling Y appears" driven by live DOM mutation, suppress explicitly in JS, not via `:has()`.-- **`InlineHTMLRenderer` run offsets are always relative to the string it is handed** (its source cursor restarts at 0 on every call). Any `BlockHTMLEmitter` caller that renders a *sub-span* of a block with `recordRuns: true` must rebase the returned runs by that sub-span's UTF-16 start, or the block's `DocumentSourceMap` runs overlap and selection notes anchor to the wrong text. The defect is invisible in the emitted HTML — it only surfaces when run offsets are compared against `block.textContent`. `renderInline`'s footnote-split segments are rebased (T-1876); `renderListMarkup` (item content), `tableHTML` (header/cell) and `emitDetails`' recursive **nested** summary still record sub-span-local offsets into the enclosing block's runs — **T-1941**. Related, same root cause: because each footnote-split segment is *block*-parsed independently, a segment starting with 4+ spaces becomes an indented code block and `InlineHTMLRenderer.Walker` has no `visitCodeBlock`, so `word[^1]     more` silently drops `more`. The structural fix for all of it is to move badge substitution into the Walker (its `footnotes` property is already stored but never read) so there is one source cursor.+- **`InlineHTMLRenderer` run offsets are always relative to the string it is handed** (its source cursor restarts at 0 on every call), and the runs are consumed against `MarkdownBlock.textContent`, which JOINS the sub-spans (cells with `" | "`, rows/items with `"\n"`). A sub-span caller that appended its runs unrebased mis-anchored every selection notes made past the first cell/item — invisible in the emitted HTML, visible only when run offsets are compared against `textContent`. Closed structurally in **T-1941**: `BlockHTMLEmitter.renderInline` takes a **required** `InlineSpan` (`.wholeBlock` / `.subspan(offset:)` / `.unmapped`), is the only place that appends to `context.blockRuns`, and does the rebase itself — so a new caller cannot omit it, only state it wrongly. Offsets come from `MarkdownBlock.tableCellTextOffsets` / `listItemTextOffsets`, which live beside `textContent` and use the same separator constants, so the two sides cannot drift. Anything absent from `textContent` (nested list items, continuation paragraphs, nested blocks, a `<details>` child list, a nested `<details>` summary, the child-less blockquote fallback) is `.unmapped` → selection declined (Decision 8), never mis-anchored; mapping the nested cases would need `textContent` widened to contain them, tracked as **T-2032** — it is a scoped-out limitation, not a defect. `EmittedDocument.badgeSourceStarts` is deliberately NOT rebased: it is keyed by the inline source string and matched against that string's own occurrence scan in `SearchStateFeeder` (T-1853). Regression guard: `WebStructuredSourceMapInvariantTests.parityCorpusRunsMonotonic` sweeps the whole parity fixture corpus asserting runs are monotonic, non-overlapping AND within their block's `textContent` UTF-16 length — the upper bound is what catches a Character-count offset, which stays monotonic and would otherwise pass. - **`FootnotePopoverWebPage.reset()` must reload** to actually clear the live page (updating the served-HTML box alone leaves the prior content in the WebContent process). - **Live-WebPage test harness wedges intermittently** (launchservicesd / XPC / "Sandbox restriction"). Stale `prism.app`/`xctest`/`xcodebuild`/`testmanagerd` processes are a cause — `pkill -9` before a run. `livePresentAndReplace` passing while another live test fails means the harness is fine and it's a real assertion. - **`xcodebuild test` hangs** in post-test xcresult finalization (it builds prismUITests). Use `build-for-testing` then `test-without-building` with `NSUnbufferedIO=YES`; the process **exit code is authoritative**. Run targeted classes with `-only-testing:prismTests/<Class>` to avoid the hang.
(working tree) editorial fixes applied during this review Review edits comment-only
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex ff9f2a4..f12752b 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -57,7 +57,7 @@ Full spec + make-it-so implementation this session. Spec: `specs/web-markdown-fi - **Three gaps fixed**: rich blocks (tables/images/mermaid/code) inside list items render fully; ordered-list `start` preserved (`<ol start="N">`, incl. nested + in-`<details>`); blockquotes render structured `children` (multi-paragraph → separate `<p>`, nested quotes nested). - **Model**: `.list(ordered:start:items:)` + `NestedList.start`; `.blockquote(content:children:)` where `content` is the **flat hash/identity seed** (kept for note-anchor stability) and `children` drives rendering. `start` + blockquote nesting **excluded from `contentForHashing`** (the `.details`/`isOpenByDefault` precedent) so existing notes stay anchored — pinned by `BlockIdentityStabilityTests`. - **Emitter**: one recursive section-less `renderInnerBlock` shared by list-item children and blockquote children; `emitTable/Image/Mermaid/Code` refactored into section-less `xHTML` inner builders.-- **Safe-by-decline selection (Decision 8)**: `renderInline(recordRuns:)` — only a block's primary prose records source-map runs; selection in a secondary paragraph / nested block resolves to no run and is **declined**, never mis-anchored. Verified by `WebStructuredSelectionTests` (live + source-map invariant).+- **Safe-by-decline selection (Decision 8, web-markdown-fidelity)**: only a block's primary prose records source-map runs; selection in a secondary paragraph / nested block resolves to no run and is **declined**, never mis-anchored. Verified by `WebStructuredSelectionTests` (live + source-map invariant). The mechanism was `renderInline(recordRuns:)` at the time; T-1941 replaced it with the required `InlineSpan` (`.unmapped` is the decline case) — see the `InlineHTMLRenderer` bullet below. - **Strict-CommonMark list merge (Decision 6)**: blank-line-separated same-delimiter ordered lists merge into one renumbered `<ol>` (no split). Tight/loose + `1)` delimiter are accepted ceilings. - **Verification suite**: `prismTests/WebRendering/SamplesComplianceTests.swift` (samples/-driven, hard assertions) + an image-in-list fixture. diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex b79c7a9..3d772e6 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -436,7 +436,7 @@ nonisolated enum BlockHTMLEmitter {             // single-paragraph quote (today's common case) keeps its selection-note behaviour             // exactly as before. blockquote.textContent is derived from children, so the first             // paragraph's runs index into the textContent prefix and resolve correctly. All-            // other children render section-lessly via renderInnerBlock (recordRuns:false),+            // other children render section-lessly via renderInnerBlock (`.unmapped`),             // so a selection there is declined rather than mis-anchored (Decision 8).             if index == 0, case .paragraph(let markdown) = child {                 // blockquote.textContent joins the children, so the FIRST child sits at

Things to double-check

Merge order against PR #341.

Three textual conflicts are guaranteed. Prefer merging this PR first: it lands the corpus-wide run invariant that would catch a bad resolution of #341's inline-renderer changes, whereas the reverse order leaves #341 merging against the weaker single-fixture guard.

Live-harness tests are the ones that can wedge.

WebSelectionNoteTests drives a real WebPage and is the historical source of intermittent XPC/launchservicesd wedges on this project. It passed 15/15 here, but a CI or pre-merge failure in that class specifically is worth re-running before treating as a real assertion.

T-2032 is a user-visible limitation, not a silent one.

Selecting text on a nested list item and reaching for Add note now shows no overlay at all. That is the intended, safe outcome, but it is a change a user can notice. The CHANGELOG entry describes it in user terms and the ticket carries the scope analysis — worth confirming the release notes keep that framing.

Downstream stale references to <code>recordRuns</code>.

Three test-file comments in WebStructuredSelectionTests.swift (lines 126, 233, 305) still say recordRuns:false. Left alone deliberately — the review does not edit test files — but worth sweeping when that file is next touched, along with the specs/web-markdown-fidelity/ references above.