A block whose inline text is exactly ---/***/___ re-parsed as a block-level ThematicBreak that InlineHTMLRenderer.Walker had no visitor for, so the content rendered empty. Third instance of the same defect class after T-1640 and T-1641.
visitThematicBreak renders the literal source line as a mapped run instead of reconstructing it from a node that carries no text and no children — the same shape as the T-1640 list-marker fix, and the only shape available for a childless leaf.***/___, not ---. Discovered by the end-to-end test this review added: swift-markdown parses with smart punctuation on, so a --- table cell reaches the block model already folded to an em dash and never re-parses as a break. The ticket's headline spelling is largely unreachable from real markdown; the other two are not."\n \n" or "\n\t\n" left the capture on the space, rendered that space as the "break line", and dropped the content — the T-1669 symptom one blank-line spelling further out. Fixed by reusing the existing isWhitespaceUnit helper, which also deleted 11 lines and the whole steppedOverGap/progress-guard apparatus.webview-rendering-status.md, including the residual: the Walker still has no visitHeading, visitBlockQuote, visitCodeBlock or visitHTMLBlock, so instances four and five already live in the code.make test-quick on the branch as pushed — 4269/4310, with 2 live-WebKit timing failures that pass 25/25 in isolation. After the review fixes: 46/46 across the emitter and source-map classes, then 34/34 covering ThematicBreakInlineTests, BlockHTMLEmitterTests, WebParityFixtureTests (the <hr> negative control) and InlineRenderCorpusEquivalenceTests. SwiftLint 0 violations across 529 files. Every result read from the xcresult bundle, never from console output.Ready to push
The fix is correct, minimal in the right place, and now genuinely proven end-to-end. All four review agents agreed the production behaviour is right; the one substantive defect they converged on — a hole in the defensive branch added by review round 2 — is fixed here, along with the coverage gap that let the branch ship without a single parse-driven test.
Two follow-ups the author must decide on before merge, neither a code blocker: (1) specs/bugfixes/inline-reparse-drops-thematic-break/ exists on disk and is empty — the fix-bug workflow ran and stopped before writing report.md. The last six bugfix merges all landed one, including the direct sibling T-1641; T-1640 did not, so precedent is mixed. (2) The branch is behind origin/main and CHANGELOG.md conflicts on rebase — a trivial adjacent-line resolution, but it must be done.
e93a8e8 T-1669: Add visitThematicBreak so bare ---/***/___ inline content survives re-parse 1b5706e Address PR #365 review: defend thematic-break cursor invariant 27ffbbd Address PR #365 review round 2: step past the whole newline gap working-tree Fixes applied in this review Prism renders markdown by first breaking a document into blocks (paragraphs, headings, tables, lists), and then, for each block, re-reading that block's own text as if it were a tiny markdown document of its own. That second pass is where formatting like italics and bold gets recognised.
The problem: markdown says a line containing only three dashes — --- — means "draw a horizontal rule here". So when a block's text happened to be exactly that, the second pass didn't see three dash characters; it saw a horizontal rule. And the code doing the reading only knew how to handle inline things like bold and links. Faced with a horizontal rule, it did nothing at all — and the text disappeared. A table cell containing *** rendered as an empty cell.
Content silently vanishing is the worst kind of rendering bug: nothing errors, nothing looks broken, the characters are just gone. A reader has no way to know they are missing something.
--- is just three characters until a parser decides it means "horizontal rule".prism/Services/WebRendering/InlineHTMLRenderer.swift gains visitThematicBreak plus a literalThematicBreak(from:) helper on the Walker struct. prismTests/WebRendering/ThematicBreakInlineTests.swift is new (14 tests). CHANGELOG.md gains a user-facing entry; this review added a gotcha bullet to docs/agent-notes/webview-rendering-status.md.
InlineHTMLRenderer.render calls Document(parsing: source) on each block's inline text and walks the result. A source string satisfying a block grammar therefore comes back as block structure. MarkupWalker's default behaviour is to descend into children; a ThematicBreak is a childless leaf, so the default emitted nothing.
The fix follows the T-1640 precedent exactly: recover the literal source text and emit it as a normal mapped run, rather than trying to reconstruct a rendering from the node. That matters because a ThematicBreak node carries no marker character, no repeat count and no spacing — - - - and --- are indistinguishable once parsed. Capturing the raw line is the only way to render it verbatim, and it is exact, because CommonMark guarantees a thematic break occupies a whole line of rule and spacing characters only.
The cursor is not advanced explicitly. appendVisible → locate already searches forward from the cursor and sets it past the match, and the skipped gap is whitespace that the literal cannot begin with, so the first match is always the break's own line. That is what let the review delete round 2's manual stepping loop entirely.
The alternative — validating that the captured line matches the thematic-break grammar and scanning forward to the next line that does — closes a residual where a cursor stranded mid-line renders the wrong line. It was rejected: that branch is unreachable from every current call site, and the validation costs ~30 lines on dead code while introducing a new silent-drop path of its own (a line that fails validation returns nothing). The residual is documented in the code instead.
Ordering is the subtle part. closeRun() flushes the open run's <span data-prism-run> into html, so the gap space must be appended after that call and before appendVisible opens the next run — otherwise the space would land inside the previous run's span. The space is deliberately outside every run: it is layout, exactly as visitSoftBreak treats the newline it consumes, and putting it in a run would map a rendered character to source it did not come from.
Cursor monotonicity is a hard precondition of this file, not a convention: locate's provedAbsent memo is only sound because the cursor never moves backwards, which is why locate takes no from: parameter and reads the cursor itself. literalThematicBreak(from:) does take a from:, but it is a pure read that performs no location and mutates nothing, so the memo is unaffected. Both cursor mutations in the new path (via appendVisible) are forward.
Efficiency is pay-per-use and cannot feed the quadratic-blowup machinery this file defends against (T-1966/T-2034): the literal is read from the source at the cursor, so locate's at-cursor fast path matches on the first probe — the full forward scan, the provedAbsent insert and the lazy UnitSet build are all unreachable from here. Repeated breaks in one block sum to O(source), since each capture advances the cursor past the line it read.
This is the third instance of one defect class and the class is not closed. The Walker has no visitHeading, visitBlockQuote, visitCodeBlock, visitHTMLBlock or visitTable, so a table cell spelled # Title drops its # today (T-1640 shape), and a re-parsed code or HTML block would drop entirely (T-1669 shape). T-1641 is the one instance closed structurally rather than by adding a visitor — by not passing .parseBlockDirectives, matching the main document parser. That asymmetry is the real lesson: the durable fix is making the two parse passes agree on grammar, not enumerating block visitors on the inline walker.
--- in inline position folds to an em dash before the block model sees it. The end-to-end test proves the fix on *** and ___; --- is exercised only at the emitter level, on hand-built blocks. This was invisible until a parse-driven test existed.`x`\n\n--- would leave it on the closing backtick) still renders that character as the "break line" and maps a markup character into a run, violating the file's own run invariant. Unreachable from every current call site; deliberately left documented rather than closed.InlineHTMLRenderer.swift
Why it matters. This is the fix. A ThematicBreak reaching the inline walker is a childless leaf, so MarkupWalker's default descend emitted nothing and the block's entire content vanished with no error and no fallback.
What to look at. InlineHTMLRenderer.swift:750-770 (visitThematicBreak), :780-795 (literalThematicBreak)
InlineHTMLRenderer.swift
Why it matters. Round 2 added a loop to stop a stranded cursor from silently dropping the break. It stepped only over 0x0A/0x0D, so a blank line spelled "\n \n" or "\n\t\n" — blank by CommonMark, which treats a whitespace-only line as blank — left the capture on the space and dropped the content anyway. Three of four review agents found this independently.
What to look at. InlineHTMLRenderer.swift:760-766, :783-786; test at ThematicBreakInlineTests.swift:236-245
ThematicBreakInlineTests.swift
Why it matters. The file's first section was headed "End-to-end (parse + emit) — the exact shape from the ticket" but nothing in it called MarkdownBlockParser.parse; both sibling fixes (T-1640, T-1641) open with a real parse. Adding one immediately failed, and the failure was informative rather than cosmetic.
What to look at. ThematicBreakInlineTests.swift:35-62
webview-rendering-status.md
Why it matters. Three instances of one shape (T-1640, T-1641, T-1669) and no note existed. The sibling T-1641 bugfix report literally predicted this bug — "a silently-unhandled block node drops content rather than erroring" — and nothing acted on it.
What to look at. docs/agent-notes/webview-rendering-status.md, Gotchas section
A ThematicBreak node exposes no marker character, no repeat count and no interior spacing, so - - - and --- are the same node. Only the raw line preserves what the author wrote. Capturing the whole line is exact rather than approximate because CommonMark guarantees a break occupies a full line of rule and spacing characters only.
Two agents proposed validating the captured line against the thematic-break grammar and scanning forward to the next line that matches, which would close the remaining "stranded mid-line renders the wrong line" residual. Rejected in this review: every call site that can produce this node passes a single-block string, so the branch is unreachable; the validation costs roughly thirty lines on dead code and adds a new silent-drop path when a line fails validation. The residual is stated in the code comment instead, so the next reader inherits the reasoning rather than the surprise.
appendVisible → locate already searches forward from the cursor and sets it past the match. The skipped gap is whitespace, and the literal cannot begin with whitespace, so the first match is always the break's own line. This is what allowed round 2's manual stepping loop to be deleted rather than fixed in place.
Given the smart-punctuation finding, "---, ***, or ___" overstates how reachable the first spelling is from real markdown. The entry is still accurate about the renderer's behaviour, and the class is what a user-facing note should describe, so it was left alone — flagged here rather than reworded.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | InlineHTMLRenderer.swift:760-766 — defensive gap skip | The round-2 loop stepped only over line terminators (0x0A/0x0D). A blank line containing a space or tab — blank by CommonMark — left the capture positioned on that whitespace, which was then captured and rendered as the "break line", dropping the content. The exact T-1669 symptom the branch was added to prevent, one blank-line spelling further out. Found independently by three of the four review agents. | Moved the skip into literalThematicBreak using the existing isWhitespaceUnit helper (the same helper literalListMarker already uses for this purpose). The loop, the steppedOverGap flag and the progress guard were deleted; net -11 lines. Regression test added for both the space and tab spellings. |
| major | ThematicBreakInlineTests.swift — missing end-to-end test | The section headed "End-to-end (parse + emit) — the exact shape from the ticket" contained no parse: nothing in the file called MarkdownBlockParser.parse, unlike both sibling fixes. The ticket's own reproduction was therefore never proven from markdown source. | Added a parse-driven table test. It failed on first run and exposed that swift-markdown's smart punctuation folds a `---` cell to an em dash before the block model sees it, so `***`/`___` are the spellings that reach the defect from real markdown. Test now asserts those two, plus that no `<hr>` is emitted. |
| major | docs/agent-notes — no note for the defect class | Third instance of one shape (T-1640, T-1641, T-1669) with nothing recorded anywhere in docs/. The T-1641 bugfix report predicted this bug in its Prevention section and nothing acted on it. | Added a Gotchas bullet to webview-rendering-status.md covering the mechanism, the three instances, how each was closed (T-1641 structurally, the other two by visitor), and the open residual — no visitHeading/visitBlockQuote/visitCodeBlock/visitHTMLBlock, so instances four and five are already latent. |
| major | specs/bugfixes/inline-reparse-drops-thematic-break/ — empty | The directory exists on disk and is empty; git tracks no empty directories, so the working tree looks clean and the diff shows nothing. The fix-bug workflow creates report.md at its investigation checkpoint, so this is evidence the workflow ran and stopped short. The last six bugfix merges all landed a report, including the direct sibling T-1641. | Not written — a bugfix report is the author's artifact from the investigation, and precedent is mixed (T-1640 shipped without one). Raised for the author to decide before merge. |
| minor | InlineHTMLRenderer.swift — stranded mid-line cursor | literalThematicBreak does not verify that the line it captured IS a thematic break, so a cursor stranded on trailing markup (a closing backtick, an emphasis delimiter) renders that character as the break's text and maps a markup character into a run, violating the file's own invariant that a run's source span never includes markup characters. | Left open deliberately. Unreachable from every current call site (all pass single-block strings), and closing it costs ~30 lines of grammar validation on dead code plus a new silent-drop path. Now stated explicitly in the visitor's comment so the next reader inherits the reasoning. |
| minor | CHANGELOG.md:22 — spelling list | "in a paragraph, a heading, a list item, or a table cell alike" — a top-level paragraph whose entire text is `---` cannot occur in a real document (MarkdownBlockParser produces .thematicBreak, pinned by MarkdownBlockParserTests). With the smart-punctuation finding, the `---` spelling is the least reachable of the three. Blockquote and <details> children, which the fix does cover, go unmentioned. | Left unchanged — the entry is accurate about the renderer's behaviour and describes the class correctly for a user-facing note. Flagged rather than reworded to avoid churn. |
| minor | ThematicBreakInlineTests.swift — assertion strength | Content assertions use text.contains("---"). The emitter is total and falls back to an escaped-source <pre> on failure, which would also contain the string, so contains() cannot distinguish correct rendering from the fallback. | Partly addressed: the new end-to-end test asserts no <hr> is emitted, pinning that a rule never leaks into a cell as block structure. The emitter-level tests were left as-is (the <pre> fallback is itself covered elsewhere, and rewriting passing tests is outside a pre-push review's remit). |
| minor | Walker — defect class not closed | No visitHeading, visitBlockQuote, visitCodeBlock, visitHTMLBlock or visitTable. A table cell or list item spelled `# Title` drops its `#` today (T-1640 shape); a re-parsed code block or HTML block would drop entirely (T-1669 shape). | Out of scope for this bugfix, but recorded in the new agent note with the guidance that a durable fix means making the two parse passes agree on grammar (as T-1641 did by not passing .parseBlockDirectives) rather than enumerating block visitors on the inline walker. |
| minor | Test suite — three flakes across two full sweeps | make test-quick reported 4269/4310 passed, 2 failed: WebScrollabilityReportingTests.reportArrivesWithinMaxWaitDuringTriggerBurst (18.4s) and WebScrollNavigationTests.visibleBlockSuppressedDuringProgrammaticScroll (19.1s), both live-WebKit timing tests hitting their max wait. A second sweep failed a different, unrelated test instead — StatePersistenceTests.loadRetrievesState — and then wedged in the live-WebKit harness before finishing. | All three confirmed flakes, not regressions: the two scroll classes pass 25/25 in isolation and StatePersistenceTests.loadRetrievesState passes in isolation; none touches the inline walker. The cause is machine contention — another session was running concurrent xcodebuild builds and tests throughout, which is the documented trigger for this project's harness wedge. The wedged sweep was replaced with build-for-testing + test-without-building per the project's own agent notes, which ran clean. |
| nit | Efficiency and reuse — checked, clean | Reviewed for hot-path cost, quadratic risk against this file's documented T-1966/T-2034 history, and duplicated helpers. | Efficiency-neutral and strictly pay-per-use: static dispatch on a struct witness, no new stored property, and locate's at-cursor fast path matches on the first probe so the provedAbsent memo and the lazy UnitSet build are unreachable from this code. One nit left: the literal round-trips String → Array(text.utf16) when the source span is already known, matching literalListMarker's existing idiom — not worth diverging for one call site. |
Click to expand.
diff --git a/prism/Services/WebRendering/InlineHTMLRenderer.swift b/prism/Services/WebRendering/InlineHTMLRenderer.swiftindex 9896536..9df0aa4 100644--- a/prism/Services/WebRendering/InlineHTMLRenderer.swift+++ b/prism/Services/WebRendering/InlineHTMLRenderer.swift@@ -735,6 +735,64 @@ nonisolated struct InlineHTMLRenderer { unit == 0x20 || unit == 0x09 || unit == 0x0A || unit == 0x0D } + // MARK: Block-level thematic breaks produced by the inline re-parse (T-1669)++ /// A block-level thematic break reaching this walker means the block's inline+ /// source text itself re-parses as one: a table cell, heading, list item, or+ /// paragraph whose text is exactly `---`, `***`, or `___` yields a+ /// `ThematicBreak` — block structure, not a `Text` node — so the default descend+ /// (a leaf with no children) used to drop the content entirely. Same class as+ /// T-1640 (list markers reaching this walker) and T-1641 (`@`-directives): any+ /// renderInline call site can hit it. A `ThematicBreak` carries no text of its+ /// own (unlike a list, it has no children to descend into either), so render the+ /// literal source line verbatim as a mapped run rather than reconstructing one+ /// from the node's (nonexistent) properties.+ mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) {+ // The cursor normally arrives at the start of the break's own line: every+ // call site that can produce this node passes a single-block string, so no+ // sibling left the cursor on a preceding line. Where a future text+ // extraction breaks that, `literalThematicBreak` skips the whole gap —+ // whitespace of any kind, since a blank line is two terminators and may+ // hold spaces or tabs besides — so the break degrades to a stray space+ // rather than the silent drop that IS the T-1669 defect. It cannot verify+ // that the line it lands on is the break, so a cursor stranded mid-line+ // renders that line instead; that is the same class of soft failure.+ if cursor < sourceUTF16.count, isWhitespaceUnit(sourceUTF16[cursor]) {+ // One space for the whole gap, matching soft-break handling: the space+ // is layout, appended outside any run so it never enters the source map.+ closeRun()+ html += " "+ }+ // No cursor step of its own: `appendVisible` locates the literal forward+ // from the cursor, and the gap it skipped is whitespace the literal cannot+ // start with, so the first match is the break's own line.+ if let literal = literalThematicBreak(from: cursor) {+ appendVisible(literal)+ }+ }++ /// The literal thematic-break line appearing next in the source at or after+ /// `from`: leading whitespace skipped (as `literalListMarker` does), then+ /// everything up to the next newline, or the end of source. A thematic break+ /// always occupies a whole line by CommonMark's grammar (rule/spacing+ /// characters only), so capturing the raw line is exact — no need to re-derive+ /// the marker character, its repeat count, or interior spacing, unlike+ /// `literalListMarker`, which only needs a list item's leading marker before+ /// descending into the rest of the item.+ private func literalThematicBreak(from: Int) -> String? {+ var index = from+ while index < sourceUTF16.count, isWhitespaceUnit(sourceUTF16[index]) {+ index += 1+ }+ let lineStart = index+ while index < sourceUTF16.count,+ sourceUTF16[index] != 0x0A, sourceUTF16[index] != 0x0D {+ index += 1+ }+ guard index > lineStart else { return nil }+ return String(decoding: sourceUTF16[lineStart..<index], as: UTF16.self)+ }+ // MARK: Inline wrappers — emphasis, strong, links mutating func visitEmphasis(_ emphasis: Emphasis) {
diff --git a/prismTests/WebRendering/ThematicBreakInlineTests.swift b/prismTests/WebRendering/ThematicBreakInlineTests.swiftnew file mode 100644index 0000000..4fb8d7a--- /dev/null+++ b/prismTests/WebRendering/ThematicBreakInlineTests.swift@@ -0,0 +1,199 @@+//+// ThematicBreakInlineTests.swift+// prismTests+//+// Regression tests for T-1669: inline content that re-parses as a thematic+// break renders empty.+//+// `InlineHTMLRenderer.render` re-parses each block's inline source string+// with `Document(parsing:)`. A source string that is exactly `---`, `***`,+// or `___` (optionally with interior spacing) re-parses as a `ThematicBreak`+// — block structure, not a `Text` node — and the Walker had no visitor for+// it, so the default descend (a leaf with no children) dropped the content+// entirely. Same defect class as T-1640 (list markers) and T-1641+// (`@`-prefixed directives): any renderInline call site can hit it — list+// items, headings, paragraphs, and table cells.+//+// Expected: any inline content that re-parses as a thematic break renders+// the literal source line as visible text, with identity source-run+// mapping, exactly as authored (`---` stays `---`, not canonicalised).+//++import Foundation+import Testing+@testable import prism++struct ThematicBreakInlineTests {++ private func normalisedText(_ blocks: [MarkdownBlock]) -> String {+ let doc = BlockHTMLEmitterTestSupport.emit(blocks)+ return BlockHTMLEmitterTestSupport.normalisedText(doc.html)+ }++ // MARK: End-to-end (parse + emit) — the exact shape from the ticket++ @Test("Markdown table with a break-shaped body cell renders its text (T-1669)")+ func parsedTableCellExactlyAsterisksRendersText() {+ // Driven from markdown source rather than a hand-built block, so a+ // parser-side surprise in the cell text surfaces here rather than passing+ // unnoticed — and one does: swift-markdown parses with smart punctuation+ // on, so a `---` cell reaches the block model already folded to an em dash+ // and never re-parses as a break. `***` and `___` are not smart-punctuated,+ // so they arrive verbatim and are the spellings that reach the defect from+ // real markdown.+ let source = """+ | Value | Meaning |+ | --- | --- |+ | *** | asterisk spelling |+ | ___ | underscore spelling |+ """+ let blocks = MarkdownBlockParser.parse(source)+ let doc = BlockHTMLEmitterTestSupport.emit(blocks)+ let text = BlockHTMLEmitterTestSupport.normalisedText(doc.html)+ // Expected: the literal `***` renders as text.+ // Actual (bug): an empty cell — the whole node is dropped.+ #expect(text.contains("***"), "rendered text: \(text)")+ #expect(text.contains("___"), "rendered text: \(text)")+ #expect(text.contains("asterisk spelling"), "rendered text: \(text)")+ #expect(text.contains("underscore spelling"), "rendered text: \(text)")+ // A rule found this way is TEXT, never block structure: the cell must not+ // emit an `<hr>`. (A real document-level `---` still does — pinned by+ // WebParityFixtureTests and BlockHTMLEmitterTests.thematicBreak.)+ #expect(!doc.html.contains("<hr"), "emitted html: \(doc.html)")+ }++ @Test("Table row whose only cell is `---` keeps the literal text (T-1669)")+ func tableCellExactlyDashesRendersText() {+ let blocks: [MarkdownBlock] = [+ .table(headers: ["Value"], rows: [["---"]], alignments: [.leading])+ ]+ let text = normalisedText(blocks)+ // Expected: the literal `---` renders as text.+ // Actual (bug): an empty cell — the whole node is dropped.+ #expect(text.contains("---"), "rendered text: \(text)")+ }++ // MARK: Emitter-level content that is exactly a thematic break++ @Test("Paragraph that is exactly `---` keeps its text")+ func paragraphExactlyDashesRendersText() {+ let text = normalisedText([.paragraph(markdown: "---")])+ #expect(text.contains("---"), "rendered text: \(text)")+ }++ @Test("Paragraph that is exactly `***` keeps its text")+ func paragraphExactlyAsterisksRendersText() {+ let text = normalisedText([.paragraph(markdown: "***")])+ #expect(text.contains("***"), "rendered text: \(text)")+ }++ @Test("Paragraph that is exactly `___` keeps its text")+ func paragraphExactlyUnderscoresRendersText() {+ let text = normalisedText([.paragraph(markdown: "___")])+ #expect(text.contains("___"), "rendered text: \(text)")+ }++ @Test("Spaced form `- - -` keeps its text exactly as authored")+ func paragraphSpacedDashesRendersText() {+ // CommonMark allows interior spacing in a thematic break line;+ // the whole-line capture must preserve it verbatim, not canonicalise.+ let text = normalisedText([.paragraph(markdown: "- - -")])+ #expect(text.contains("- - -"), "rendered text: \(text)")+ }++ @Test("List item content that is exactly `---` keeps the text")+ func listItemExactlyDashesRendersText() {+ let items = [ListItem(content: "---", checkbox: nil)]+ let text = normalisedText([.list(ordered: false, start: 1, items: items)])+ #expect(text.contains("---"), "rendered text: \(text)")+ }++ @Test("Heading text that is exactly `---` keeps the text")+ func headingExactlyDashesRendersText() {+ let text = normalisedText([.heading(level: 2, text: "---")])+ #expect(text.contains("---"), "rendered text: \(text)")+ }++ @Test("Table cell text that is exactly `***` keeps the text")+ func tableCellExactlyAsterisksRendersText() {+ let text = normalisedText([.table(headers: ["Value"],+ rows: [["***"]],+ alignments: [.leading])])+ #expect(text.contains("***"), "rendered text: \(text)")+ }++ // MARK: The defended broken-invariant path — a gap before the break line++ @Test("A blank-line gap before the break still renders the literal line (PR #365)")+ func brokenInvariantBlankLineGapKeepsBreakText() {+ // The visitor's invariant is that the cursor arrives at the start of the+ // break's line. Its defensive branch must skip the WHOLE gap: a blank line is+ // two newlines, and skipping only one leaves the capture starting on a+ // terminator, where it scans nothing and returns nil — dropping the content+ // exactly as the T-1669 defect did, just with a stray space. One space stands+ // in for the gap; the `---` survives.+ let text = normalisedText([.paragraph(markdown: "x\n\n---")])+ #expect(text.contains("---"), "rendered text: \(text)")+ #expect(text.contains("x"), "rendered text: \(text)")+ }++ @Test("A CRLF blank-line gap before the break still renders the literal line")+ func brokenInvariantCRLFGapKeepsBreakText() {+ // Same shape with CRLF terminators: all four units are gap, so the capture+ // must land on the break line rather than mid-pair.+ let text = normalisedText([.paragraph(markdown: "x\r\n\r\n---")])+ #expect(text.contains("---"), "rendered text: \(text)")+ }++ @Test("A whitespace-bearing gap before the break still renders the literal line")+ func brokenInvariantWhitespaceGapKeepsBreakText() {+ // A blank line is blank when it holds only spaces or tabs, so the gap is a run+ // of WHITESPACE, not of terminators. Skipping terminators alone stops on the+ // space, captures it as the "break line", and drops the `---` — the T-1669+ // symptom one blank-line spelling further out (pre-push review).+ let spaced = normalisedText([.paragraph(markdown: "x\n \n---")])+ #expect(spaced.contains("---"), "rendered text: \(spaced)")+ let tabbed = normalisedText([.paragraph(markdown: "x\n\t\n---")])+ #expect(tabbed.contains("---"), "rendered text: \(tabbed)")+ }++ @Test("A gap before the break leaves the source map's runs ordered and in bounds")+ func brokenInvariantGapKeepsSourceMapConsistent() {+ // The space emitted for the gap is layout, not mapped source: it must not+ // become a run, and the runs that do exist must stay ordered, non-overlapping+ // and inside the source, or selection-anchored notes would resolve wrongly.+ let source = "x\n\n---"+ let doc = BlockHTMLEmitter.emit(blocks: [.paragraph(markdown: source)],+ footnotes: .empty, settings: RenderSettings())+ let runs = (doc.sourceMap.runs.values.first ?? []).sorted { $0.sourceStart < $1.sourceStart }+ #expect(!runs.isEmpty, "runs: \(runs)")+ var previousEnd = 0+ for run in runs {+ #expect(run.sourceStart >= previousEnd, "runs must be ordered and non-overlapping")+ #expect(run.sourceStart + run.length <= source.utf16.count,+ "runs must stay inside the source: \(runs)")+ previousEnd = run.sourceStart + run.length+ }+ }++ // MARK: Source-map identity for the thematic-break run++ @Test("Thematic-break run maps identity: total run length equals the content length")+ func thematicBreakRunMapsIdentity() {+ let source = "---"+ let doc = BlockHTMLEmitter.emit(blocks: [.paragraph(markdown: source)],+ footnotes: .empty, settings: RenderSettings())+ let runs = (doc.sourceMap.runs.values.first ?? []).sorted { $0.sourceStart < $1.sourceStart }+ // A dropped node yields total 0 ≠ the content length: the runs must+ // tile the whole source string (ordered, non-overlapping, starting+ // at 0) so selection-anchored notes still resolve.+ let total = runs.reduce(0) { $0 + $1.length }+ #expect(total == source.utf16.count, "runs: \(runs)")+ #expect(runs.first?.sourceStart == 0, "runs: \(runs)")+ var previousEnd = 0+ for run in runs {+ #expect(run.sourceStart >= previousEnd, "runs must be ordered and non-overlapping")+ previousEnd = run.sourceStart + run.length+ }+ }+}
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex fd8b2a8..a60518b 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -240,6 +240,7 @@ take down unrelated applications' web content. Always PID-verify, never pattern- - **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), 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. - **`InlineHTMLRenderer.Walker.locate` is a naive forward scan, and it is only affordable because failed scans are recorded** (T-1966). The search cannot be BOUNDED in the general case — text legitimately sits far ahead of the cursor whenever the walk skipped source it does not account for (a long image `src`, a long raw-HTML span), so only the pre-badge segment (PR #326) and `claimOccurrences` (T-1992) get bounds. Without a record, a text node whose rendered text does not occur verbatim in the source — the entity/escape family, `A` spelled `A` — scanned to the end of the block, failed, left the cursor where it was, and the next such node re-derived the same scan: `*A* ` x 3200 took 12s. Two exact rejections fix it: `provedAbsent` (a text an unbounded scan proved absent stays absent, capped at 256 entries so the T-2034 class cannot grow it with the block) and a lazily-built `sourceUnits` bitset (a text holding a unit the source does not hold cannot occur in it anywhere; a bitset over the 16-bit domain rather than a `Set`, because the probe runs once per unit of every later text and hashing dominated it). A match at the cursor is tested BEFORE either memo, so the common case — text sitting exactly where the walk expects it — pays for neither. **`provedAbsent` rests on `cursor` never moving backwards**, which is why `locate` takes NO `from:` parameter and reads `cursor` itself: the precondition is structural, not documented. Every mutation of `cursor` is forward, but one of them is only forward *because of the pre-badge bound* — `appendFootnoteBadge` steps to the occurrence's end, and the `appendVisible` before it must stay bounded by `occurrence.sourceStart` or the cursor could overshoot; weakening that bound breaks the memo, not just performance. No rejection changes any output: digests over a 4000-sample generated corpus (html + every run + `badgeSourceStarts`) are byte-identical to `origin/main` at 09cd828, and that corpus is now COMMITTED (`InlineRenderCorpusEquivalenceTests`, seeded SplitMix64, chunk digests pinned, re-blessing protocol in the file header; `PRISM_INLINE_CORPUS_FULL=1` for all 4000). Residual, deliberately open: a distinct-per-node text absent from the source whose every unit is present still costs a scan each — **T-2034**, needs a source index to close. Guards: `InlineSourceMapScanGrowthTests` (G1-G8 growth over the shared `GrowthRatioGuard`, O1-O6 rendered-text-in-order + run invariants, O7 exact pre-fix run coordinates on memo-HIT fixtures, O8 the recording gate — a bounded miss must record nothing or a later unbounded match is silently suppressed).+- **The inline re-parse can hand `InlineHTMLRenderer.Walker` BLOCK nodes, and an unhandled one drops content silently** — three instances so far, all the same shape. `render` re-parses each block's inline source with `Document(parsing:)`, so a source string that satisfies a *block* grammar comes back as block structure rather than a `Text` node, and `MarkupWalker`'s default descend emits nothing for it: content vanishes with no error, no fallback, and no visible trace except an empty cell/item/heading. **T-1640** `1.`/`3)` at the start of an item (→ `OrderedList`), **T-1641** `@Observable` (→ `BlockDirective`; closed by NOT passing `.parseBlockDirectives`, matching `MarkdownBlockParser`), **T-1669** text that is exactly `---`/`***`/`___` (→ `ThematicBreak`). The two structural fixes render the LITERAL source line/marker as a mapped run (`literalListMarker`, `literalThematicBreak`) rather than reconstructing it from the node — a `ThematicBreak` has no text and no children to descend into, so there is nothing to reconstruct from. **The class is not closed**: the Walker still has no `visitHeading`, `visitBlockQuote`, `visitCodeBlock`, `visitHTMLBlock` or `visitTable`, so `# Title` in a table cell drops its `#` (T-1640 shape) and a re-parsed code block or HTML block would drop entirely (T-1669 shape). Before adding a `renderInline` call site, check what its strings can re-parse into; when one of these turns up, add the visitor rather than pre-sanitising the string. The defensive gap-skipping in `visitThematicBreak` is for a broken cursor invariant only — every call site today passes a SINGLE-BLOCK string, so a stranded cursor is unreachable and the branch is deliberately not hardened further (it degrades to a stray space, or to rendering the line it lands on). - **`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.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9938073..69428fc 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A block whose text is exactly a thematic break — `---`, `***`, or `___` — no longer renders empty (T-1669). Rendering re-parses a block's text as its own small markdown document, and that string satisfying markdown's rule for a horizontal rule made it parse as one rather than as plain text; nothing then knew how to turn a rule back into visible text, so it vanished. This is the third defect of this shape (after numbered list markers and `@`-prefixed text, T-1640/T-1641): rendering now knows a rule found this way as text too, and shows the characters as written — in a paragraph, a heading, a list item, or a table cell alike. - Arrow keys, Page Up/Down, Space, and the View menu's **Page Down**/**Page Up**/**Scroll to Top**/**Scroll to Bottom** no longer scroll the document behind an open note, footnote, add-note, reply, or document-note modal (T-1099, reopened). This was fixed once before; the WebKit rendering cutover restructured the views that carried the fix and the binding was never rebuilt, so scroll commands kept reaching the document underneath a modal that should have blocked them. The gate is restored on both the iPhone and iPad/Mac layouts, taking effect immediately when a modal is already open and staying live across every presentation and dismissal. - Replying to a document-level note is no longer silently discarded on a document that has only imported notes (T-1865). NotesPanel and SidebarNotesView create replies through a convenience method that used the document's saved user notes as its source of context; on a document where no user note had ever been created — only imported ones — that context was `nil`, so the guard returned early before the reply was ever built, leaving the tap with no visible effect and nothing written to disk. The method now falls back to the document's cached identifier, the same fallback its sibling document-note-creation method already used, so a reply always creates the note container it needs. - The safeguard that stops a broken document from reloading forever now holds when the crashes keep landing mid-load (T-2107). When a document's rendering process stops, the app reloads it, and if the reloads repeatedly fail to bring the document back it gives up after a few attempts and shows a banner offering a manual reload rather than retrying endlessly (T-1943 below). But a reload was counted as having succeeded the moment the page reported in — before it had finished laying out — so a renderer that reliably crashed in that window looked like a fresh failure each time instead of the same one continuing: the count started over on every attempt, and the document reloaded forever, which is exactly the loop the safeguard exists to prevent. A recovery now only counts as successful once the reloaded document has actually settled on screen, so crashes landing in that window accumulate toward the limit and reach the banner. Recoveries that do bring the document back still reset the count, and the banner's reload still restores everything as before.
The branch is based on 449621b; origin/main has since moved to 7a2a441. git merge-tree reports one conflict, in CHANGELOG.md only — both sides insert an entry at the top of ### Fixed. Trivial to resolve, but it must be done, and the T-1822 entry that landed on main in the meantime must survive it.
specs/bugfixes/inline-reparse-drops-thematic-break/ is an empty untracked directory, so it will not be pushed and nothing will flag its absence later. If a report is wanted, the smart-punctuation finding belongs in it — it changes which spellings the bug actually reaches — as does the residual list of missing block visitors.
swift-markdown is parsing with smart options on, which is why a --- cell becomes an em dash. That is a rendering decision nobody appears to have made deliberately, and it is invisible in every emitter-level test because those bypass the parser. Out of scope here, but worth confirming it is intended.
GitHub Actions is billing-blocked at the account level, so the full suite, the targeted re-runs and SwiftLint were all run locally on macOS. iOS was not built or tested in this review; the change is platform-agnostic Swift in a service type, so the risk is low, but make build-ios has not been run on this branch.
Another session ran concurrent xcodebuild build and xcodebuild test against this project for most of the review, which starved runs at the codesign step and wedged one sweep at RegisterWithLaunchServices — precisely the launchservicesd/XPC wedge this project's own agent notes describe. Every flake seen (two scroll classes, then StatePersistenceTests) went green when re-run in isolation. If the branch's own CI ever comes back, none of these should be treated as known-bad tests.