Scoped to the 44 commits since the T-1542 cutover (f8a0ae9..HEAD) — the work that had not had an independent code review: device-test rounds 1–6, the T-1558 web-markdown-fidelity spec, and the build-warnings cleanup. The cutover itself was gated by its own spec process and is out of scope.
start and blockquote nesting are excluded from the content hash, so existing notes stay anchored (pinned by tests).Ready to push
No Critical findings and no correctness or security bugs across all four review dimensions. Every Important finding was fixed in this review (DOM-id reuse, a note-path double-walk, dead params, stale architecture docs, two missing decision-log entries, a Req-mandated test gap). Remaining items are minor (skipped with rationale) plus one tracked doc-debt: CLAUDE.md's Mermaid/Image subsystem sections and Source-Structure file tree still list components deleted in the cutover — a separate reconciliation pass, not a push blocker.
f8b9480 resolve Swift 6 main-actor-isolation build warnings 3594bc0 remove stale localisation-catalog keys e456e23 pre-push review fixes (behaviour-preserving cleanups + test) 3fb6837 pre-push review fixes (docs + decision log) 5f1f416 reconcile CLAUDE.md footnote + document-flow notes This branch makes Prism's document viewer render Markdown more faithfully and tidies the codebase. Three rendering bugs were fixed: tables/images/diagrams inside a list now show properly (not as plain text), numbered lists that start at 3 now actually start at 3, and multi-paragraph or nested quotes render as separate, indented quotes instead of one run-on line. A batch of compiler warnings was also cleared.
These are the things you'd notice reading a real document — a quote that collapses to one line or a table that turns into a wall of text looks broken. The fixes were verified against the sample documents.
The web renderer is MarkdownBlock model → BlockHTMLEmitter → HTML in a WKWebView. T-1558 added start to the list model and children to the blockquote model, and introduced one recursive, section-less renderInnerBlock that the emitters share so nested content (in list items and blockquotes) renders with full structure. The device rounds are CSS/JS affordance work (gutter alignment of the add-note + and note dots). The warnings pass marks pure value-types/statics/globals nonisolated under the project's MainActor default isolation.
The new model fields are excluded from the content hash so note anchors stay stable. Selection notes map only a block's primary prose (decline elsewhere) to avoid mis-anchoring without rewriting the source-map machinery.
The load-bearing invariant is hash stability: contentForHashing keeps list:{ordered}:{items} (no start) and blockquote:{content} (the flat format() seed, not children), mirroring the isOpenByDefault-on-.details precedent — pinned by BlockIdentityStabilityTests. renderInline(recordRuns:) threads run-suppression through the recursive walk: only a block's first paragraph records source-map runs, so a selection in a secondary paragraph resolves to no run and is declined (never mis-indexed into block.textContent) — verified by WebStructuredSelectionTests live + source-map-invariant. Review fixes routed SearchStateFeeder through the shared BlockDOMID (removing a third copy of the b-{hash}-{i} scheme) and de-duplicated NoteStateFeeder's per-push block walk.
Blank-line-separated same-delimiter ordered lists merge per CommonMark (Decision 6, no split); convertBlockquote intentionally double-traverses (format() seed for hash + children for render); nested-list + affordances use per-level CSS offsets (levels 2–6) to reach the gutter column.
prism/Models/MarkdownBlock.swift
Why it matters. Central model change consumed by notes/search/relocation/export; the hash exclusion is what keeps existing note anchors valid.
What to look at. MarkdownBlock.swift contentForHashing (.list / .blockquote)
prism/Services/WebRendering/BlockHTMLEmitter.swift
Why it matters. Replaces the old flattening renderNestedBlock; the same inner builders (tableHTML/imageHTML/mermaidHTML/codeHTML) serve top-level and nested rendering.
What to look at. BlockHTMLEmitter.swift renderInnerBlock + xHTML builders + emitBlockquote
prism/Services/WebRendering/BlockHTMLEmitter.swift
Why it matters. Prevents the silent mis-anchor a naive structured-rendering change would introduce.
What to look at. renderInline(recordRuns:) — only primary prose records source-map runs
prism/Theme/HighContrastThemeColors.swift
Why it matters. Restores the zero-warnings bar across ~96 Swift-6 isolation warnings and 34 stale catalog keys.
What to look at. ThemeColors: Sendable + nonisolated stored props; 34 keys removed from Localizable.xcstrings
prism/Services/SearchStateFeeder.swift
Why it matters. Removes a third hand-rolled copy of the b-{hash}-{i} DOM-id scheme (drift risk) and a per-note-push double block-walk.
What to look at. SearchStateFeeder uses BlockDOMID.map; NoteStateFeeder computes the map once
Blank-line-separated same-delimiter ordered lists merge into one renumbered <ol>, matching GitHub; per-group source numbers aren't recovered. Chosen over a CommonMark-deviating split after weighing the tradeoff with the user.
content (the format() string) is retained purely as the hash/identity seed so existing blockquote notes stay anchored; children drives rendering. The intentional double-traversal is now commented.
Two device-round additions to the webview surface were undocumented; this review added decision-log entries 14 (scope-validated prism://image-access/grant flow) and 15 (reload-free mermaid re-theme).
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| important | SearchStateFeeder DOM-id | Hand-rolled b-{hash}-{i} scheme — a third copy that can drift from BlockDOMID. | Routed through BlockDOMID.map(blocks:). |
| important | NoteStateFeeder hot path | BlockDOMID.map called twice per note push (two full block walks + per-block hash/lock). | Compute the map once in payloads() and thread it into both consumers. |
| important | RenderSettings dead params | expandCode/collapseCode written but never read; two catalog lookups per emit. | Removed fields, factory population, fallback (no catalog keys existed). |
| important | CLAUDE.md architecture | Markdown Rendering / Textual Fork / Key Dependencies described the retired stack. | Rewrote to the WebKit pipeline; also fixed footnote + document-flow steps. |
| important | agent-notes staleness | markdown-parsing-gaps.md said the gaps were deferred, but T-1558 fixed them. | Rewrote the note as resolved; updated webview-rendering-status.md. |
| important | undocumented divergences | iOS image-grant flow + applyTheme mermaidConfig not in any decision log. | Added webview-rendering decision-log entries 14 and 15. |
| minor | ordered-list test coverage | No assertion for a nested <ol start=N> (Req 2.4/4.2) or start-1 omission (Req 2.2). | Added both assertions to SamplesComplianceTests. |
| minor | stringly-typed prism:// routes | Route literals duplicated producer/consumer. | Added PrismLinkRoute enum (Swift side); JS keeps its literals. |
| minor | PrismDocSchemeHandler.assetAllowlist | Lists JS files never served via the scheme; reads as a security allowlist but is neither complete nor minimal. | Added a clarifying comment (JS is injected as user scripts, only document.css is fetched). |
| minor | convertBlockquote double-traversal | format() seed + children both built; format() used only for the hash. | Intentional for note-anchor stability; added a clarifying comment. |
| minor | prism-notes.js clientRectFromDOMRect | Duplicates bridge.clientRect's rect shaping. | Skipped — 3-line local; hoisting to the bridge is churn for little gain. |
| minor | nested-list CSS magic ems | Per-level offsets (levels 2-6) hard-code base + 1.6em*level. | Skipped — acceptable (plain CSS has no selector-depth arithmetic), verified in headless WebKit, commented. |
| minor | CLAUDE.md Mermaid/Image/source-tree | Subsystem sections + file tree still list cutover-deleted components. | Flagged as doc-debt — a separate reconciliation pass (cutover-scoped, out of this review's recent-work scope). |
| minor | pushNoteState re-encode | Re-encodes/dispatches note payload even when unchanged. | Skipped — JS render is idempotent; low impact. |
Click to expand.
diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex bd4968b..022d16f 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -65,20 +65,19 @@ enum BlockHTMLEmitter { var allRuns: [String: [DocumentSourceMap.Run]] = [:] var body = "" - // Occurrence index per content hash gives each duplicate block a unique DOM id- // while keeping the content-hash identity for model lookup (Req 5.1).- var occurrence: [String: Int] = [:]-- for block in blocks {- let hash = block.id- let sourceIndex = occurrence[hash, default: 0]- occurrence[hash] = sourceIndex + 1- let domID = "b-\(hash)-\(sourceIndex)"-+ // The occurrence-qualified DOM id per block (`b-{hash}-{sourceIndex}`) is assigned+ // by the shared BlockDOMID mapper so the emitter and NoteStateFeeder cannot drift+ // on the id format (Req 5.1). Duplicate blocks keep the content-hash identity for+ // model lookup while each occurrence gets a distinct id.+ // The enumeration index is the block's absolute position in `blocks`, which equals+ // `MarkdownSection.headingSourceIndex` (BlockDOMID.map is 1:1 and in order). The+ // composite section id `{block.id}-{sourceIndex}` headings carry must match+ // `MarkdownSection.id` so the JS collapse toggles key the native section model.+ for (sourceIndex, entry) in BlockDOMID.map(blocks: blocks).enumerated() { context.blockRuns = []- let inner = emitBlock(block, domID: domID, context: context)+ let inner = emitBlock(entry.block, domID: entry.domID, sourceIndex: sourceIndex, context: context) if !context.blockRuns.isEmpty {- allRuns[domID] = context.blockRuns+ allRuns[entry.domID] = context.blockRuns } body += inner }@@ -95,15 +94,43 @@ enum BlockHTMLEmitter { static let cspMetaContent = PrismDocSchemeHandler.contentSecurityPolicy private static func wrapDocument(body: String, sourceMap: DocumentSourceMap, settings: RenderSettings) -> String {- // The source map ships as an inert JSON data island (CSP-safe: not executable- // script). prism-bridge.js reads it for selection mapping with no bridge round- // trip (design Data Models).- let island = "<script type=\"application/json\" id=\"prism-source-map\">"- + escapeForScriptElement(sourceMap.jsonString())- + "</script>"+ // The source map ships as an inert JSON data island in a hidden <div>, NOT a+ // <script type="application/json">: the document is served under script-src 'none',+ // and WebKit strips <script> data islands from the DOM on device (confirmed via the+ // on-device bridge probe), which broke selection mapping. prism-bridge.js reads it+ // via getElementById(...).textContent, which decodes the HTML-escaped JSON.+ let island = "<div id=\"prism-source-map\" hidden>"+ + HTMLEscaping.escapeText(sourceMap.jsonString())+ + "</div>"+ // The selection-anchored "Add note" affordance (Req 12.2) is a native SwiftUI+ // overlay on both platforms (T-1542): prism-notes.js reports selection state via+ // selectionCandidate and native draws the affordance from the selection rect. No+ // in-page pill is emitted — the WebKit native selection callout would cover it.+ //+ // The per-block "add note" affordance (Req 5.3) is injected by prism-notes.js as+ // icon-only chrome so iOS gets a tap target that does not depend on `contextmenu`+ // (which iOS long-press never fires). Its accessible label is catalog-resolved+ // here and carried on <main> (no user-visible string literals in the JS, Req 1.9).+ let addNoteLabel = HTMLEscaping.escapeAttribute(settings.strings.addNote)+ // iOS-only marker: a failed local image can offer a "Grant Folder Access" button in+ // its placeholder, because the grant flow (security-scoped folder picker) exists on+ // iOS. macOS has no such flow, so the marker is absent and no grant button is shown.+ #if os(iOS)+ let imageAccessGrantable = " data-prism-image-access-grantable"+ #else+ let imageAccessGrantable = ""+ #endif+ let grantAccessLabel = HTMLEscaping.escapeAttribute(settings.strings.grantFolderAccess)+ // Bake the theme key onto <html> when supplied (the footnote popover page, which+ // has no live bridge to push applyTheme) so it renders the right palette rather+ // than the light :root default in dark mode (Req 1.3, T-1542). The document surface+ // leaves it nil and lets its bridge push the theme on ready.+ let htmlOpen = settings.theme.map {+ "<html data-prism-theme=\"\(HTMLEscaping.escapeAttribute($0))\">"+ } ?? "<html>" return """ <!DOCTYPE html>- <html>+ \(htmlOpen) <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1">@@ -111,44 +138,22 @@ enum BlockHTMLEmitter { <link rel="stylesheet" href="prism-doc://document/assets/document.css"> </head> <body>- <main data-prism-document role="document">+ <main data-prism-document role="document" data-prism-add-note-label="\(addNoteLabel)" data-prism-grant-access-label="\(grantAccessLabel)"\(imageAccessGrantable)> \(body) </main>- \(selectionPill(settings: settings)) \(island) </body> </html> """ } - /// The app-owned "Add note" affordance for selection-anchored notes (Req 12.2): a- /// hidden, absolutely-positioned chrome pill that prism-notes.js shows near a- /// selection. Both labels (add + the cross-block decline) come from the catalog- /// (Req 1.9) — the JS holds no strings; it toggles `data-prism-pill-declined` and- /// the stylesheet swaps which label shows.- private static func selectionPill(settings: RenderSettings) -> String {- let strings = settings.strings- let addLabel = HTMLEscaping.escapeText(strings.addNote)- let declineLabel = HTMLEscaping.escapeText(strings.selectionAcrossBlocks)- return "<div data-prism-add-note-pill data-prism-chrome role=\"button\" hidden>"- + "<span class=\"prism-pill-add\">\(addLabel)</span>"- + "<span class=\"prism-pill-decline\">\(declineLabel)</span>"- + "</div>"- }-- /// Escapes a JSON string for safe embedding inside a `<script>` element: only the- /// `</` sequence can prematurely close the element, so neutralise it. The content is- /// inert (type="application/json") and CSP forbids execution regardless.- private static func escapeForScriptElement(_ json: String) -> String {- json.replacingOccurrences(of: "</", with: "<\\/")- }- // MARK: - Per-block emission (total: never throws, never drops) - private static func emitBlock(_ block: MarkdownBlock, domID: String, context: Context) -> String {+ private static func emitBlock(_ block: MarkdownBlock, domID: String, sourceIndex: Int, context: Context) -> String { switch block { case .heading(let level, let text):- return emitHeading(level: level, text: text, block: block, domID: domID, context: context)+ return emitHeading(level: level, text: text, block: block, domID: domID,+ sourceIndex: sourceIndex, context: context) case .paragraph(let markdown): return emitParagraph(markdown: markdown, block: block, domID: domID, context: context)@@ -160,11 +165,13 @@ enum BlockHTMLEmitter { case .mermaid(let source, _): return emitMermaid(source: source, block: block, domID: domID, context: context) - case .blockquote(let content):- return emitBlockquote(content: content, block: block, domID: domID, context: context)+ case .blockquote(let content, let children):+ return emitBlockquote(content: content, children: children,+ block: block, domID: domID, context: context) - case .list(let ordered, let items):- return emitList(ordered: ordered, items: items, block: block, domID: domID, context: context)+ case .list(let ordered, let start, let items):+ return emitList(ordered: ordered, start: start, items: items,+ block: block, domID: domID, context: context) case .table(let headers, let rows, let alignments): return emitTable(headers: headers, rows: rows, alignments: alignments,@@ -217,14 +224,32 @@ enum BlockHTMLEmitter { // MARK: Heading / paragraph / blockquote (inline-text blocks) private static func emitHeading(- level: Int, text: String, block: MarkdownBlock, domID: String, context: Context+ 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)+ // 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.+ // The glyph is CSS-drawn (no document text); not selectable (data-prism-chrome).+ let toggleLabel = HTMLEscaping.escapeAttribute(context.settings.strings.toggleSection)+ let chevron = "<button type=\"button\" class=\"prism-section-toggle\" data-prism-section-toggle"+ + " data-prism-chrome aria-expanded=\"true\" aria-label=\"\(toggleLabel)\"></button>" // <hN> already carries the heading-level semantics VoiceOver's rotor needs // (Req 10.1); no explicit role/aria-level required.- let inner = "<h\(clamped)>\(rendered)</h\(clamped)>"- return section(block: block, domID: domID, role: nil, inner: inner)+ let inner = "<h\(clamped)>\(chevron)\(rendered)</h\(clamped)>"+ // The composite section id matches MarkdownSection.id ("{hash}-{sourceIndex}"); the+ // heading level drives the JS "hide following siblings until same/higher heading".+ let sectionID = HTMLEscaping.escapeAttribute("\(block.id)-\(sourceIndex)")+ return section(+ block: block, domID: domID, role: nil,+ extraAttributes: [+ "data-prism-kind=\"heading\"",+ "data-prism-heading-level=\"\(clamped)\"",+ "data-prism-section-id=\"\(sectionID)\"",+ ],+ inner: inner+ ) } private static func emitParagraph(@@ -235,11 +260,29 @@ enum BlockHTMLEmitter { } private static func emitBlockquote(- content: String, block: MarkdownBlock, domID: String, context: Context+ content: String, children: [MarkdownBlock], block: MarkdownBlock, domID: String, context: Context ) -> String {- let rendered = renderInline(content, context: context)- return section(block: block, domID: domID, role: nil,- inner: "<blockquote><p>\(rendered)</p></blockquote>")+ var inner = "<blockquote>"+ for (index, child) in children.enumerated() {+ // The FIRST child, IFF it is a paragraph, renders via the run-recording path so a+ // 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),+ // 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>"+ } 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.+ if children.isEmpty {+ inner = "<blockquote><p>" + renderInline(content, context: context) + "</p></blockquote>"+ }+ return section(block: block, domID: domID, role: nil, inner: inner) } // MARK: Code block (task 10) — highlight.js runs in-page; emitter ships escaped source@@ -247,6 +290,16 @@ enum BlockHTMLEmitter { private static func emitCodeBlock( language: String?, code: String, block: MarkdownBlock, domID: String, context: Context ) -> String {+ section(block: block, domID: domID, role: nil,+ extraAttributes: ["data-prism-kind=\"code\""],+ inner: codeHTML(language: language, code: code, context: context))+ }++ /// Builds the inner code markup (chrome + `<pre><code class="language-…">`) WITHOUT the+ /// per-block `<section>` wrapper. `emitCodeBlock` wraps it via `section(...)` for+ /// top-level use; `renderInnerBlock` calls it directly for code nested in a list item or+ /// blockquote, so nested code carries the same `language-*` class as top-level (Req 1.4).+ private static func codeHTML(language: String?, code: String, context: Context) -> String { let strings = context.settings.strings let langClass = language.flatMap { $0.isEmpty ? nil : $0 } let languageLabel = langClass ?? strings.codeLanguagePlain@@ -260,8 +313,7 @@ enum BlockHTMLEmitter { + " aria-label=\"\(copyLabel)\">\(HTMLEscaping.escapeText(strings.copyCode))</button>" + "</div>" let pre = "<pre><code class=\"\(codeClass)\">\(HTMLEscaping.escapeText(code))</code></pre>"- return section(block: block, domID: domID, role: nil,- extraAttributes: ["data-prism-kind=\"code\""], inner: chrome + pre)+ return chrome + pre } // MARK: Mermaid (task 14) — renders in place; copy + zoom affordances@@ -269,43 +321,91 @@ enum BlockHTMLEmitter { private static func emitMermaid( source: String, block: MarkdownBlock, domID: String, context: Context ) -> String {+ section(block: block, domID: domID, role: nil,+ extraAttributes: ["data-prism-kind=\"mermaid\""],+ inner: mermaidHTML(source: source, context: context))+ }++ /// Builds the inner mermaid markup (chrome + output container + hidden source) WITHOUT+ /// the per-block `<section>` wrapper. `emitMermaid` wraps it via `section(...)`;+ /// `renderInnerBlock` calls it directly for a mermaid diagram nested in a list item or+ /// blockquote, keeping the `data-prism-mermaid` container so the page-world renderer+ /// works inside an `<li>`/`<blockquote>`.+ private static func mermaidHTML(source: String, context: Context) -> String { let strings = context.settings.strings let copyLabel = HTMLEscaping.escapeAttribute(strings.copyDiagram)+ let zoomLabel = HTMLEscaping.escapeAttribute(strings.diagramZoom)+ let viewSourceLabel = HTMLEscaping.escapeAttribute(strings.diagramViewSource) let errorTitle = HTMLEscaping.escapeText(strings.mermaidErrorTitle)+ // The detected diagram type heads the chrome bar (parity with the code-block+ // language heading); like the code language, it is derived from the source, not a+ // localised literal (Req 1.9). MermaidTypeParser returns a stable type name.+ let diagramType = HTMLEscaping.escapeText(MermaidTypeParser.parse(source)) // mermaid.js (a user script, IntersectionObserver-scheduled) reads the escaped // source from the .prism-mermaid-source node and renders into .prism-mermaid-out.- // Click on the rendered output dispatches diagramActivated -> native zoom UX.+ // The header carries: the diagram type, a "view source" toggle (in-page, reveals+ // the source <pre>), a copy button, and a "full screen" button that dispatches+ // diagramActivated -> native zoom UX (Req 3.1). Tapping the rendered output also+ // dispatches diagramActivated. let chrome = "<div class=\"prism-diagram-chrome\" data-prism-chrome>"+ + "<span class=\"prism-diagram-type\">\(diagramType)</span>"+ + "<span class=\"prism-diagram-actions\">"+ + "<button type=\"button\" class=\"prism-chrome-button\" data-prism-view-source"+ + " aria-expanded=\"false\" aria-label=\"\(viewSourceLabel)\">"+ + "\(HTMLEscaping.escapeText(strings.diagramViewSource))</button>" + "<button type=\"button\" class=\"prism-copy\" data-prism-copy=\"mermaid\"" + " aria-label=\"\(copyLabel)\">\(HTMLEscaping.escapeText(strings.copyDiagram))</button>"+ + "<button type=\"button\" class=\"prism-chrome-button prism-zoom-icon\" data-prism-zoom"+ + " aria-label=\"\(zoomLabel)\"></button>"+ + "</span>" + "</div>"- let sourceNode = "<pre class=\"prism-mermaid-source\" hidden>"- + HTMLEscaping.escapeText(source) + "</pre>" let output = "<div class=\"prism-mermaid-out\" data-prism-mermaid" + " data-prism-error-title=\"\(errorTitle)\" role=\"img\"></div>"- return section(block: block, domID: domID, role: nil,- extraAttributes: ["data-prism-kind=\"mermaid\""], inner: chrome + sourceNode + output)+ let sourceNode = "<pre class=\"prism-mermaid-source\" hidden>"+ + HTMLEscaping.escapeText(source) + "</pre>"+ return chrome + output + sourceNode } // MARK: List (task 10) — ARIA list + occurrence sub-IDs matching NoteAnchor private static func emitList(- ordered: Bool, items: [ListItem], block: MarkdownBlock, domID: String, context: Context+ ordered: Bool, start: Int, items: [ListItem], block: MarkdownBlock, domID: String, context: Context ) -> String {- let inner = renderListMarkup(ordered: ordered, items: items, subIDPrefix: "item", context: context)+ let inner = renderListMarkup(ordered: ordered, start: start, items: items,+ subIDPrefix: "item", context: context) return section(block: block, domID: domID, role: nil, inner: inner) } /// Renders `<ul>`/`<ol>` with each item carrying `data-prism-sub="item-{n}"` (top /// level) or nested form. Task list items render a disabled checkbox.+ ///+ /// `start` is the ordered-list's first-item number: the `<ol>` open tag carries+ /// `start="N"` when `N != 1`, verbatim including 0 (Req 2.1/2.4). Unordered lists ignore+ /// it. Rich blocks nested in a list item route through `renderInnerBlock` so tables /+ /// images / mermaid / code render fully in source order (Req 1.5).+ ///+ /// `subIDPrefix` is `nil` for a section-less nested list (one reached via+ /// `renderInnerBlock` — a list inside a blockquote, or a `.block` child of a list item):+ /// such a list carries NO note anchor (Req 5.3), so its items must NOT emit+ /// `data-prism-sub`. prism-notes.js injects a per-item "+" for every `li[data-prism-sub]`+ /// inside a block section and, finding any, suppresses that section's own block-level+ /// "+"; 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. private static func renderListMarkup(- ordered: Bool, items: [ListItem], subIDPrefix: String, context: Context+ ordered: Bool, start: Int, items: [ListItem], subIDPrefix: String?, context: Context ) -> String { let tag = ordered ? "ol" : "ul"- var html = "<\(tag)>"+ // <ol start="1"> is the HTML default, so it is omitted; any other value (including 0)+ // is emitted verbatim. Unordered lists never carry start.+ let startAttr = (ordered && start != 1) ? " start=\"\(start)\"" : ""+ var html = "<\(tag)\(startAttr)>" for (index, item) in items.enumerated() {- let sub = "\(subIDPrefix)-\(index)"- html += "<li data-prism-sub=\"\(sub)\">"+ // A section-less nested list (subIDPrefix == nil) omits data-prism-sub entirely;+ // its items are not anchorable and propagate nil down their own nested lists.+ let sub = subIDPrefix.map { "\($0)-\(index)" }+ let subAttr = sub.map { " data-prism-sub=\"\($0)\"" } ?? ""+ html += "<li\(subAttr)>" if let checkbox = item.checkbox { let checked = checkbox == .checked ? " checked" : "" html += "<input type=\"checkbox\" disabled\(checked) data-prism-chrome>"@@ -314,15 +414,19 @@ enum BlockHTMLEmitter { for child in item.children { switch child { case .nestedList(let nested):- html += renderListMarkup(ordered: nested.ordered, items: nested.items,- subIDPrefix: "\(sub)-item", context: context)+ html += renderListMarkup(ordered: nested.ordered, start: nested.start,+ items: nested.items,+ subIDPrefix: sub.map { "\($0)-item" },+ context: context) case .paragraph(let text):- html += "<p>" + renderInline(text, context: context) + "</p>"+ // 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>" case .block(let nestedBlock):- // Nested blocks (code, blockquote) render via their own inline text;- // a full per-block section inside a list item is avoided to keep the- // list's DOM identity at the list level.- html += renderNestedBlock(nestedBlock, context: context)+ // Rich blocks (table, image, mermaid, code, blockquote) render fully via+ // renderInnerBlock — no own <section>, inline text unrecorded — so the+ // list's DOM identity and note anchoring stay at the list level (Req 1.5).+ html += renderInnerBlock(nestedBlock, context: context) } } html += "</li>"@@ -331,18 +435,49 @@ enum BlockHTMLEmitter { return html } - /// Lightweight render for a block nested inside a list item or details body (no own- /// <section>).- private static func renderNestedBlock(_ block: MarkdownBlock, context: Context) -> String {+ /// 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-*`+ /// attributes so the page-world renderers still work.+ private static func renderInnerBlock(_ block: MarkdownBlock, context: Context) -> String { switch block {- case .codeBlock(_, let code):- return "<pre><code>\(HTMLEscaping.escapeText(code))</code></pre>"- case .blockquote(let content):- return "<blockquote><p>" + renderInline(content, context: context) + "</p></blockquote>" case .paragraph(let markdown):- return "<p>" + renderInline(markdown, context: context) + "</p>"+ return "<p>" + renderInline(markdown, context: context, recordRuns: false) + "</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)>"+ case .blockquote(_, let children):+ var inner = ""+ for child in children {+ inner += renderInnerBlock(child, context: context)+ }+ return "<blockquote>\(inner)</blockquote>"+ case .list(let ordered, let start, let items):+ // A list reached via renderInnerBlock (inside a blockquote, or a .block child of a+ // list item) is section-less and carries no note anchor (Req 5.3): pass nil so its+ // 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)+ 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)+ 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)+ return imageHTML(model, context: context)+ case .thematicBreak:+ return "<hr>" default:- return "<p>" + renderInline(block.textContent, context: context) + "</p>"+ // Other block kinds (html, comment, 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>" } } @@ -351,6 +486,20 @@ enum BlockHTMLEmitter { private static func emitTable( headers: [String], rows: [[String]], alignments: [ColumnAlignment], block: MarkdownBlock, domID: String, context: Context+ ) -> String {+ section(block: block, domID: domID, role: nil,+ extraAttributes: ["data-prism-kind=\"table\""],+ inner: tableHTML(headers: headers, rows: rows, alignments: alignments,+ context: context, recordRuns: true))+ }++ /// 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`.+ private static func tableHTML(+ headers: [String], rows: [[String]], alignments: [ColumnAlignment],+ context: Context, recordRuns: Bool ) -> String { func alignment(_ column: Int) -> String { guard column < alignments.count else { return "left" }@@ -365,20 +514,36 @@ enum BlockHTMLEmitter { html += "<thead><tr data-prism-sub=\"row-header\">" for (column, header) in headers.enumerated() { html += "<th scope=\"col\" style=\"text-align:\(alignment(column))\">"- + renderInline(header, context: context) + "</th>"+ + renderInline(header, context: context, recordRuns: recordRuns) + "</th>" } html += "</tr></thead><tbody>" for (rowIndex, row) in rows.enumerated() { html += "<tr data-prism-sub=\"row-\(rowIndex)\">" for (column, cell) in row.enumerated() { html += "<td style=\"text-align:\(alignment(column))\">"- + renderInline(cell, context: context) + "</td>"+ + renderInline(cell, context: context, recordRuns: recordRuns) + "</td>" } html += "</tr>" } html += "</tbody></table>"- return section(block: block, domID: domID, role: nil,- extraAttributes: ["data-prism-kind=\"table\""], inner: html)+ // Wrap in .prism-table-wrap with the initial display mode so the stylesheet's+ // horizontal containment applies (a bare table whose min-content exceeds the+ // viewport otherwise scrolls the whole page). prism-theme.js's setTableModes+ // toggles the same attribute on this wrapper (Req 1.5).+ let mode = tableModeAttribute(TableDisplayMode.initialMode(headers: headers, rows: rows))+ return "<div class=\"prism-table-wrap\" data-prism-table-mode=\"\(mode)\">"+ + html + "</div>"+ }++ /// Maps a `TableDisplayMode` to the `data-prism-table-mode` string the stylesheet and+ /// `prism-theme.js` use. Inverse of `WebDocumentMessageRouter.tableDisplayMode(from:)`+ /// ("scroll" ↔ `.wide`).+ private static func tableModeAttribute(_ mode: TableDisplayMode) -> String {+ switch mode {+ case .fitted: return "fitted"+ case .readable: return "readable"+ case .wide: return "scroll"+ } } // MARK: Image (task 14) — every src rewritten through prism-doc://img/@@ -397,6 +562,17 @@ enum BlockHTMLEmitter { private static func emitImage( _ model: ImageModel, block: MarkdownBlock, domID: String, context: Context ) -> String {+ section(block: block, domID: domID, role: nil,+ extraAttributes: ["data-prism-kind=\"image\""],+ inner: imageHTML(model, context: context))+ }++ /// Builds the inner image markup (`<figure>` + `<img data-prism-image>` + caption ++ /// optional link overlay) WITHOUT the per-block `<section>` wrapper. `emitImage` wraps+ /// it via `section(...)`; `renderInnerBlock` calls it directly for an image nested in a+ /// list item or blockquote, keeping the `data-prism-image` attribute so the page-world+ /// renderer and the failed-image placeholder logic work inside an `<li>`/`<blockquote>`.+ private static func imageHTML(_ model: ImageModel, context: Context) -> String { let strings = context.settings.strings let rewritten = rewriteImageSrc(model.source) var imgAttrs = "src=\"\(HTMLEscaping.escapeAttribute(rewritten))\""@@ -425,9 +601,7 @@ enum BlockHTMLEmitter { + "</div>" } - return section(block: block, domID: domID, role: nil,- extraAttributes: ["data-prism-kind=\"image\""],- inner: "<figure>\(figureInner)</figure>")+ return "<figure>\(figureInner)</figure>" } private static func dimensionAttribute(_ name: String, _ dimension: ImageDimension) -> String {@@ -492,11 +666,11 @@ enum BlockHTMLEmitter { case .details(let summary, let children, let isOpen, let depth): let nested = DetailsModel(summary: summary, children: children, isOpen: isOpen, depth: depth) childrenHTML += emitDetails(nested, block: child, domID: "\(domID)-d", context: context)- case .list(let ordered, let items):- childrenHTML += renderListMarkup(ordered: ordered, items: items,+ case .list(let ordered, let start, let items):+ childrenHTML += renderListMarkup(ordered: ordered, start: start, items: items, subIDPrefix: "item", context: context) default:- childrenHTML += renderNestedBlock(child, context: context)+ childrenHTML += renderInnerBlock(child, context: context) } } let inner = "<details\(openAttr) data-prism-depth=\"\(model.depth)\">"@@ -510,7 +684,13 @@ enum BlockHTMLEmitter { /// Renders inline markdown to HTML, accumulating the block's runs into `context` and /// substituting resolvable `[^id]` references with inert footnote badges.- static func renderInline(_ source: String, context: Context) -> String {+ ///+ /// 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 { let footnotes = context.footnotes // Footnote references are replaced with badge chrome BEFORE inline rendering by // splitting the source on resolvable [^id] tokens. Each text segment renders@@ -519,7 +699,7 @@ enum BlockHTMLEmitter { let result = InlineHTMLRenderer.render( source: source, footnotes: footnotes, runIDAllocator: &context.nextRunID )- context.blockRuns.append(contentsOf: result.runs)+ if recordRuns { context.blockRuns.append(contentsOf: result.runs) } return result.html } var html = ""@@ -532,7 +712,7 @@ enum BlockHTMLEmitter { let result = InlineHTMLRenderer.render( source: segment, footnotes: .empty, runIDAllocator: &context.nextRunID )- context.blockRuns.append(contentsOf: result.runs)+ if recordRuns { context.blockRuns.append(contentsOf: result.runs) } html += result.html } html += footnoteBadge(identifier: identifier, displayNumber: definition.displayNumber)@@ -543,7 +723,7 @@ enum BlockHTMLEmitter { let result = InlineHTMLRenderer.render( source: tail, footnotes: .empty, runIDAllocator: &context.nextRunID )- context.blockRuns.append(contentsOf: result.runs)+ if recordRuns { context.blockRuns.append(contentsOf: result.runs) } html += result.html } return html@@ -555,7 +735,7 @@ enum BlockHTMLEmitter { private static func footnoteBadge(identifier: String, displayNumber: Int) -> String { let id = HTMLEscaping.escapeAttribute(identifier) return "<a class=\"prism-footnote-badge\" data-prism-chrome"- + " data-prism-footnote=\"\(id)\" href=\"prism://footnote/\(id)\""+ + " data-prism-footnote=\"\(id)\" href=\"\(PrismLinkRoute.footnote(id))\"" + " role=\"button\">\(displayNumber)</a>" } @@ -564,7 +744,7 @@ enum BlockHTMLEmitter { /// Rewrites an image reference to `prism-doc://img/?src={encoded original}` so the /// scheme handler mediates every read. `data:` URIs pass through unchanged (Req 3.3): /// they carry their own bytes and need no mediation, and `img-src data:` permits them.- static func rewriteImageSrc(_ original: String) -> String {+ nonisolated static func rewriteImageSrc(_ original: String) -> String { let trimmed = original.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.lowercased().hasPrefix("data:") { return original
diff --git a/prism/Models/MarkdownBlock.swift b/prism/Models/MarkdownBlock.swiftindex f187370..19ce5ff 100644--- a/prism/Models/MarkdownBlock.swift+++ b/prism/Models/MarkdownBlock.swift@@ -221,6 +221,11 @@ struct ListItem: Equatable, Sendable { /// A nested list within a list item. struct NestedList: Equatable, Hashable, Sendable { let ordered: Bool+ /// The ordered-list start number (the first item's number). 1 for+ /// unordered lists (unused). Excluded from `ListItemChild.id` hashing+ /// so adding start-number support leaves nested list IDs unchanged+ /// (Req 5.1).+ let start: Int let items: [ListItem] } @@ -247,7 +252,7 @@ struct ListItem: Equatable, Sendable { // Filter out [!COMMENT] blockquote children before hashing (Req 3.6) let nonCommentChildren = children.filter { child in guard case .block(let block) = child,- case .blockquote(let content) = block else { return true }+ case .blockquote(let content, _) = block else { return true } return !CommentBlockDetector.isCommentBlock(content) } for (index, child) in nonCommentChildren.enumerated() {@@ -442,11 +447,27 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable { /// Used for window identification on macOS. Defaults to 0 for backward compatibility. case mermaid(source: String, byteOffset: Int = 0) - /// A blockquote containing markdown-formatted text.- case blockquote(content: String)+ /// A blockquote containing block-level children.+ ///+ /// - Parameters:+ /// - content: The `format()`-derived flat string of the whole quote.+ /// Retained only as the hash/identity seed (`contentForHashing`) so+ /// existing blockquote notes stay anchored across the fidelity fix+ /// (Req 5.2); it is NOT the rendered text. Visible text and search+ /// text derive from `children`.+ /// - children: The blockquote's block-level children (paragraphs,+ /// nested quotes, lists, code), rendered recursively in source order.+ case blockquote(content: String, children: [MarkdownBlock]) /// A list (ordered or unordered) with structured items supporting nesting.- case list(ordered: Bool, items: [ListItem])+ ///+ /// - Parameters:+ /// - ordered: Whether the list is ordered.+ /// - start: The ordered list's start number (its first item's number);+ /// 1 for unordered lists (unused). Excluded from `contentForHashing`+ /// so existing list IDs stay stable (Req 5.1, Decision 3).+ /// - items: The list's items.+ case list(ordered: Bool, start: Int, items: [ListItem]) /// A table with headers, rows, and per-column alignments. case table(headers: [String], rows: [[String]], alignments: [ColumnAlignment])@@ -541,9 +562,13 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable { return "code:\(language ?? ""):\(code)" case .mermaid(let source, let byteOffset): return "mermaid:\(source):\(byteOffset)"- case .blockquote(let content):+ case .blockquote(let content, _):+ // Hash on `content` only, NOT `children` (Decision 8 / Req 5.2):+ // keeps existing blockquote IDs stable so notes stay anchored. return "blockquote:\(content)"- case .list(let ordered, let items):+ case .list(let ordered, _, let items):+ // `start` is excluded (Decision 3 / Req 5.1): existing list IDs+ // must not move when start-number support is added. return "list:\(ordered):\(items.map { $0.contentForHashing }.joined(separator: "|"))" case .table(let headers, let rows, let alignments): let rowsStr = rows.map { $0.joined(separator: ",") }.joined(separator: ";")@@ -594,7 +619,7 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable { case .codeBlock: return "Code" case .mermaid: return "Mermaid" case .blockquote: return "Quote"- case .list(let ordered, _): return ordered ? "OL" : "UL"+ case .list(let ordered, _, _): return ordered ? "OL" : "UL" case .table: return "Table" case .thematicBreak: return "HR" case .image: return "Img"@@ -613,7 +638,7 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable { /// - Parameter index: The zero-based index of the item in the list. /// - Returns: A stable ID for the list item, or nil if this is not a list block. func listItemId(at index: Int) -> String? {- guard case .list(_, let items) = self, index >= 0, index < items.count else {+ guard case .list(_, _, let items) = self, index >= 0, index < items.count else { return nil } // Delegate to the canonical dotted-id helper. (T-1144)@@ -625,7 +650,7 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable { /// - Parameter index: The zero-based index of the item in the list. /// - Returns: The item text, or nil if this is not a list block or index is invalid. func listItemText(at index: Int) -> String? {- guard case .list(_, let items) = self, index >= 0, index < items.count else {+ guard case .list(_, _, let items) = self, index >= 0, index < items.count else { return nil } return items[index].content@@ -640,7 +665,7 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable { /// /// Used by `RelocationEngine` to index nested item IDs for exact-match relocation. func allListItemIds() -> [(id: String, text: String)] {- guard case .list(_, let items) = self else { return [] }+ guard case .list(_, _, let items) = self else { return [] } return Self.collectItemIds(items: items, baseId: id, parentPath: []) } @@ -728,9 +753,12 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable { return code case .mermaid(let source, _): return source- case .blockquote(let content):- return content- case .list(_, let items):+ case .blockquote(_, let children):+ // Derive from children so the text matches the rendered DOM (no+ // leading `>` markers carried from the source). The first child+ // 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") case .table(let headers, let rows, _): let headerStr = headers.joined(separator: " | ")@@ -787,13 +815,15 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable { case .mermaid(let source, _): return source - case .blockquote(let content):- return Self.combinedSearchableText(- forInlineText: content,- context: context- )+ case .blockquote(_, let children):+ // Derive from children so search text matches the rendered DOM+ // (the leading `>` was never visible text). Mirrors how `.list`+ // builds combined searchable text from its items.+ return children+ .map { $0.searchableText(in: context) }+ .joined(separator: "\n") - case .list(_, let items):+ case .list(_, _, let items): return items .map { Self.listItemSearchableText($0, in: context) } .joined(separator: "\n")@@ -1040,9 +1070,9 @@ extension MarkdownBlock { static func ordinalOffsetForList(at blockIndex: Int, in blocks: [MarkdownBlock]) -> Int { guard blockIndex >= 2 else { return 0 } let block = blocks[blockIndex]- guard case .list(true, _) = block else { return 0 }+ guard case .list(true, _, _) = block else { return 0 } guard case .htmlComment = blocks[blockIndex - 1] else { return 0 }- guard case .list(true, let previousItems) = blocks[blockIndex - 2] else { return 0 }+ guard case .list(true, _, let previousItems) = blocks[blockIndex - 2] else { return 0 } return previousItems.count } }
diff --git a/prism/Services/MarkdownBlockParser.swift b/prism/Services/MarkdownBlockParser.swiftindex 24d0d6e..ac1729f 100644--- a/prism/Services/MarkdownBlockParser.swift+++ b/prism/Services/MarkdownBlockParser.swift@@ -112,7 +112,7 @@ enum MarkdownBlockParser: Sendable { /// Precompiled bracketed-label matcher used by ``stripCommentsInsideLinkLabels``. /// Matches `[...]` runs; the comment regex is then anchored inside the /// captured label only.- private static let linkLabelRegex: NSRegularExpression = {+ nonisolated private static let linkLabelRegex: NSRegularExpression = { // swiftlint:disable:next force_try try! NSRegularExpression(pattern: #"\[([^\]]*)\]"#) }()@@ -504,7 +504,7 @@ enum MarkdownBlockParser: Sendable { return convertCodeBlock(codeBlock, sourceContent: sourceContent) case let blockquote as BlockQuote:- return convertBlockquote(blockquote)+ return convertBlockquote(blockquote, sourceContent: sourceContent) case let list as UnorderedList: return convertUnorderedList(list, sourceContent: sourceContent)@@ -686,26 +686,38 @@ enum MarkdownBlockParser: Sendable { return byteOffset } - nonisolated static func convertBlockquote(_ blockquote: BlockQuote) -> MarkdownBlock {+ nonisolated static func convertBlockquote(_ blockquote: BlockQuote, sourceContent: String) -> MarkdownBlock { // Format the blockquote content to preserve formatting. // Trim leading/trailing whitespace because format() adds inter-block // spacing (e.g. "\n\n") when the blockquote follows other nodes in the AST. // Strip HTML comments from inside markdown link labels before storing // so the Textual inline extension never renders a glyph inside a `[…]` // link label (Req 4.5, Decision 18). Outside-link comments are kept.+ //+ // `content` (the flat `format()` string) is retained SOLELY as the+ // hash/identity seed that keeps note anchors stable across re-parses+ // (web-markdown-fidelity Decision 8) — the web emitter never renders it.+ // The rendered structure comes from `children`, built by recursing the+ // blockquote's block-level child nodes through the shared dispatch so+ // nested paragraphs, quotes, lists, and code survive (Req 3). Both are+ // built because each serves a distinct purpose: `content` for identity,+ // `children` for rendering. let formatted = stripCommentsInsideLinkLabels(blockquote.format()) let content = formatted.trimmingCharacters(in: .whitespacesAndNewlines)- return .blockquote(content: content)+ let children = blockquote.children.compactMap { convert($0, sourceContent: sourceContent) }+ return .blockquote(content: content, children: children) } nonisolated static func convertUnorderedList(_ list: UnorderedList, sourceContent: String) -> MarkdownBlock { let items = Array(list.listItems.map { convertListItem($0, sourceContent: sourceContent) })- return .list(ordered: false, items: items)+ // Unordered lists carry start: 1 (unused by the renderer).+ return .list(ordered: false, start: 1, items: items) } nonisolated static func convertOrderedList(_ list: OrderedList, sourceContent: String) -> MarkdownBlock { let items = Array(list.listItems.map { convertListItem($0, sourceContent: sourceContent) })- return .list(ordered: true, items: items)+ // Capture the first-item number from the AST (Req 2.1/2.3).+ return .list(ordered: true, start: Int(list.startIndex), items: items) } /// Converts a swift-markdown ListItem to our ListItem model.@@ -750,14 +762,14 @@ enum MarkdownBlockParser: Sendable { } else if let unorderedList = child as? UnorderedList { // Handle nested unordered list let nestedItems = Array(unorderedList.listItems.map { convertListItem($0, sourceContent: sourceContent) })- children.append(.nestedList(ListItem.NestedList(ordered: false, items: nestedItems)))+ children.append(.nestedList(ListItem.NestedList(ordered: false, start: 1, items: nestedItems))) } else if let orderedList = child as? OrderedList {- // Handle nested ordered list+ // Handle nested ordered list; capture its start number (Req 2.4) let nestedItems = Array(orderedList.listItems.map { convertListItem($0, sourceContent: sourceContent) })- children.append(.nestedList(ListItem.NestedList(ordered: true, items: nestedItems)))+ children.append(.nestedList(ListItem.NestedList(ordered: true, start: Int(orderedList.startIndex), items: nestedItems))) } else if let blockquote = child as? BlockQuote { // Handle blockquotes inside list items- children.append(.block(convertBlockquote(blockquote)))+ children.append(.block(convertBlockquote(blockquote, sourceContent: sourceContent))) } else if let table = child as? Table { // Handle tables inside list items children.append(.block(convertTable(table)))@@ -861,7 +873,7 @@ enum MarkdownBlockParser: Sendable { extension ColumnAlignment { /// Creates a `ColumnAlignment` from a `swift-markdown` `Table.ColumnAlignment`. /// Returns `.leading` for `nil` or `.left` to match GFM default behavior.- init(from tableAlignment: Table.ColumnAlignment?) {+ nonisolated init(from tableAlignment: Table.ColumnAlignment?) { switch tableAlignment { case .center: self = .center case .right: self = .trailing
diff --git a/prism/Services/WebRendering/NoteStateFeeder.swift b/prism/Services/WebRendering/NoteStateFeeder.swiftnew file mode 100644index 0000000..f2d60c5--- /dev/null+++ b/prism/Services/WebRendering/NoteStateFeeder.swift@@ -0,0 +1,306 @@+//+// NoteStateFeeder.swift+// prism+//+// Computes the note payloads the rendered document needs (webview-rendering spec,+// Req 5.2/5.6) and is the missing native-side feeder that closes the cutover gap:+// prism-notes.js had the render functions and the controller had the+// setNoteIndicators / setInlineNotes commands, but nothing mapped NotesManager state+// onto those commands, so notes did not appear in the web-rendered document.+//+// Two pure products, derived from (parsed blocks + NotesManager + settings):+// - indicatorsJSON: `[{domID, rowOrdinal?}]` — one entry per block / table row that+// carries a note (Req 5.2). prism-notes.js draws the indicator dot.+// - inlineNotesJSON: `{ bubbles: [{domID, html}], banner: {html, placement} }` — the+// native-rendered (escaped) bubble/banner HTML the JS injects as chrome (Req 5.6).+// Bubbles are produced only when `showInlineNotes` is on; the banner only when there+// are active document-level notes, placed per the banner-placement setting.+//+// DOM id mapping: a note's anchor key is a content hash (block note) or a sub-block id+// (`{hash}-row-{n}`, `{hash}-row-header`, `{hash}-item-{n}`). The content hash maps to+// occurrence-qualified DOM ids via the shared `BlockDOMID` logic — the SAME logic the+// emitter writes into the page. A hash with multiple occurrences yields an entry per+// occurrence, matching the SwiftUI path's per-block model lookup (which keyed notes by+// content hash and so surfaced them on every matching block).+//+// Localisation (Req 1.9): the banner count label is catalog-resolved natively and+// passed in via `Strings`; note text/author/timestamp are user/document data (escaped),+// not catalog strings.+//++import Foundation++/// Catalog-resolved, localisation-owned text the rendered note chrome needs (Req 1.9).+/// A top-level `Sendable` value (not nested in the `@MainActor` feeder) so it and its+/// `fallback` can be used from default arguments / tests without MainActor isolation.+struct NoteRenderStrings: Sendable {+ /// Pluralised "N document notes" banner summary (catalog plural key).+ var documentNotesCount: @Sendable (Int) -> String+ /// Label for the banner's "add a document note" affordance (Req 5.6 entry point).+ var addDocumentNote: String++ /// A neutral default for tests/previews; production resolves from the catalog.+ /// `nonisolated` (the initialiser holds a `@Sendable` closure, no actor state) so it+ /// is usable from default arguments and tests off the main actor.+ nonisolated static let fallback = NoteRenderStrings(+ documentNotesCount: { count in "\(count) document notes" },+ addDocumentNote: "Add a document note"+ )+}++/// The two payloads pushed to the controller. `inlineNotesJSON` is nil when there is+/// nothing inline to show (no bubbles and no banner), so the controller leaves the+/// page's note chrome empty rather than pushing an empty object.+struct NotePayloads: Equatable {+ var indicatorsJSON: String+ var inlineNotesJSON: String?+}++@MainActor+enum NoteStateFeeder {++ // MARK: - Inputs++ /// Backwards-compatible spelling so call sites can write `NoteStateFeeder.Strings`.+ typealias Strings = NoteRenderStrings+ typealias Payloads = NotePayloads++ // MARK: - Entry point++ /// Builds both payloads for the current document.+ static func payloads(+ blocks: [MarkdownBlock],+ notesManager: NotesManager,+ showInlineNotes: Bool,+ bannerPlacement: DocumentNotesBannerPlacement,+ exportUsername: String,+ strings: Strings = .fallback+ ) -> Payloads {+ // Walk the blocks ONCE for the (block, domID) pairing and share it with both+ // payload builders — both used to call BlockDOMID.map independently, two full+ // walks per push on the note-change hot path.+ let mapped = BlockDOMID.map(blocks: blocks)+ return Payloads(+ indicatorsJSON: indicatorsJSON(mapped: mapped, notesManager: notesManager),+ inlineNotesJSON: inlineNotesJSON(+ mapped: mapped,+ notesManager: notesManager,+ showInlineNotes: showInlineNotes,+ bannerPlacement: bannerPlacement,+ exportUsername: exportUsername,+ strings: strings+ )+ )+ }++ // MARK: - Indicators (Req 5.2)++ /// An indicator target: a block DOM id, optionally a table-row ordinal within it.+ private struct Indicator: Encodable {+ let domID: String+ let rowOrdinal: Int?+ }++ /// Builds the `[{domID, rowOrdinal?}]` JSON for every block / table row carrying an+ /// active note. Block-level notes (and list-item notes, which have no per-item+ /// indicator slot in the JS contract) surface a block-level indicator; table-row+ /// notes surface a row indicator. A header-row note (`-row-header`, no JS ordinal)+ /// surfaces as a block-level indicator on the table so the note is still visible.+ ///+ /// Maps the blocks to their DOM ids and delegates; `payloads` uses the `mapped:`+ /// overload to share one walk across both payloads.+ static func indicatorsJSON(blocks: [MarkdownBlock], notesManager: NotesManager) -> String {+ indicatorsJSON(mapped: BlockDOMID.map(blocks: blocks), notesManager: notesManager)+ }++ /// As `indicatorsJSON(blocks:notesManager:)` but takes the pre-computed+ /// `(block, domID)` mapping so `payloads` walks the blocks only once.+ static func indicatorsJSON(+ mapped: [(block: MarkdownBlock, domID: String)], notesManager: NotesManager+ ) -> String {+ var indicators: [Indicator] = []+ // De-dup per (domID, rowOrdinal) so a block with several notes (or both a list-item+ // note and a block note) yields a single indicator.+ var seen: Set<String> = []++ for (block, domID) in mapped {+ // Block-level (and list-item) notes → one indicator on the block itself.+ let blockLevel = notesActive(notesManager, for: block.id)+ || hasActiveListItemNote(notesManager, block: block)+ if blockLevel {+ appendIndicator(domID: domID, rowOrdinal: nil, into: &indicators, seen: &seen)+ }++ // Table-row notes → a row indicator per row ordinal; header-row → block-level.+ if case .table = block {+ for ordinal in activeTableRowOrdinals(notesManager, block: block) {+ appendIndicator(domID: domID, rowOrdinal: ordinal, into: &indicators, seen: &seen)+ }+ if hasActiveHeaderRowNote(notesManager, block: block) {+ appendIndicator(domID: domID, rowOrdinal: nil, into: &indicators, seen: &seen)+ }+ }+ }++ return encodeJSON(indicators) ?? "[]"+ }++ private static func appendIndicator(+ domID: String, rowOrdinal: Int?,+ into indicators: inout [Indicator], seen: inout Set<String>+ ) {+ let key = "\(domID)#\(rowOrdinal.map(String.init) ?? "-")"+ guard seen.insert(key).inserted else { return }+ indicators.append(Indicator(domID: domID, rowOrdinal: rowOrdinal))+ }++ // MARK: - Inline notes (Req 5.6)++ /// A bubble payload entry: the target block DOM id and the native-rendered HTML.+ private struct Bubble: Encodable {+ let domID: String+ let html: String+ }++ private struct Banner: Encodable {+ let html: String+ let placement: String+ }++ private struct InlineNotes: Encodable {+ let bubbles: [Bubble]+ let banner: Banner?+ }++ /// Maps the blocks to their DOM ids and delegates; `payloads` uses the `mapped:`+ /// overload to share one walk across both payloads.+ static func inlineNotesJSON(+ blocks: [MarkdownBlock],+ notesManager: NotesManager,+ showInlineNotes: Bool,+ bannerPlacement: DocumentNotesBannerPlacement,+ exportUsername: String,+ strings: Strings+ ) -> String? {+ inlineNotesJSON(+ mapped: BlockDOMID.map(blocks: blocks),+ notesManager: notesManager,+ showInlineNotes: showInlineNotes,+ bannerPlacement: bannerPlacement,+ exportUsername: exportUsername,+ strings: strings+ )+ }++ /// Builds the inline-notes JSON. Bubbles are emitted only when `showInlineNotes` is on+ /// (one bubble host per block with active notes, carrying all that block's notes); the+ /// banner is emitted only when there are active document-level notes. Returns nil when+ /// neither is present so the controller pushes nothing. Takes the pre-computed+ /// `(block, domID)` mapping so `payloads` walks the blocks only once.+ static func inlineNotesJSON(+ mapped: [(block: MarkdownBlock, domID: String)],+ notesManager: NotesManager,+ showInlineNotes: Bool,+ bannerPlacement: DocumentNotesBannerPlacement,+ exportUsername: String,+ strings: Strings+ ) -> String? {+ let bubbles = showInlineNotes+ ? buildBubbles(mapped: mapped, notesManager: notesManager, exportUsername: exportUsername)+ : []++ let banner = buildBanner(+ notesManager: notesManager,+ bannerPlacement: bannerPlacement,+ exportUsername: exportUsername,+ strings: strings+ )++ guard !bubbles.isEmpty || banner != nil else { return nil }+ return encodeJSON(InlineNotes(bubbles: bubbles, banner: banner))+ }++ /// One bubble host per block with active notes, mapped to every DOM-id occurrence of+ /// that block (so duplicated blocks each show the notes). The host HTML stacks each+ /// note (thread-aware order), and each note carries `data-prism-note-id` so a tap+ /// routes to `inlineNoteTapped` (Req 5.6).+ private static func buildBubbles(+ mapped: [(block: MarkdownBlock, domID: String)],+ notesManager: NotesManager,+ exportUsername: String+ ) -> [Bubble] {+ var bubbles: [Bubble] = []+ for (block, domID) in mapped {+ let notes = NoteGrouping.sortNotesThreadAware(notesManager.allNotes(for: block.id))+ .filter { $0.status == .active }+ guard !notes.isEmpty else { continue }+ let html = NoteHTMLBuilder.inlineNotesHost(notes, exportUsername: exportUsername)+ bubbles.append(Bubble(domID: domID, html: html))+ }+ return bubbles+ }++ /// The document-level notes banner. Always present when document notes can be added+ /// (iCloud available) so the reading view keeps an "add a document note" entry point+ /// (Req 5.6) — the legacy GlobalNotesBanner showed an empty-state add button too.+ /// When notes can't be added and none exist, returns nil (no empty box).+ private static func buildBanner(+ notesManager: NotesManager,+ bannerPlacement: DocumentNotesBannerPlacement,+ exportUsername: String,+ strings: Strings+ ) -> Banner? {+ let documentNotes = notesManager.documentLevelNotes // already active + thread-sorted+ let canAdd = notesManager.iCloudAvailable+ guard !documentNotes.isEmpty || canAdd else { return nil }+ let html = NoteHTMLBuilder.banner(+ documentNotes,+ countLabel: strings.documentNotesCount(documentNotes.count),+ addLabel: canAdd ? strings.addDocumentNote : nil,+ exportUsername: exportUsername+ )+ return Banner(html: html, placement: bannerPlacement.rawValue)+ }++ // MARK: - Note lookup helpers++ private static func notesActive(_ notesManager: NotesManager, for blockId: String) -> Bool {+ notesManager.allNotes(for: blockId).contains { $0.status == .active }+ }++ /// Whether any active note targets a list item of `block` (`{hash}-item-…`).+ private static func hasActiveListItemNote(_ notesManager: NotesManager, block: MarkdownBlock) -> Bool {+ guard case .list = block else { return false }+ return block.allListItemIds().contains { notesActive(notesManager, for: $0.id) }+ }++ /// Active body-row ordinals (`{hash}-row-{n}`) of a table block, sorted ascending.+ private static func activeTableRowOrdinals(_ notesManager: NotesManager, block: MarkdownBlock) -> [Int] {+ guard case .table = block else { return [] }+ let prefix = "\(block.id)-row-"+ var ordinals: [Int] = []+ for entry in block.allTableRowIds() {+ guard entry.id.hasPrefix(prefix),+ let ordinal = Int(entry.id.dropFirst(prefix.count)),+ notesActive(notesManager, for: entry.id) else { continue }+ ordinals.append(ordinal)+ }+ return ordinals.sorted()+ }++ private static func hasActiveHeaderRowNote(_ notesManager: NotesManager, block: MarkdownBlock) -> Bool {+ guard case .table = block else { return false }+ return notesActive(notesManager, for: "\(block.id)-row-header")+ }++ // MARK: - JSON++ private static func encodeJSON<T: Encodable>(_ value: T) -> String? {+ let encoder = JSONEncoder()+ // Deterministic key order so the payload is stable (golden-fixture testable) and a+ // re-push of unchanged notes does not churn the snapshot.+ encoder.outputFormatting = [.sortedKeys]+ guard let data = try? encoder.encode(value) else { return nil }+ return String(data: data, encoding: .utf8)+ }+}
diff --git a/prism/Services/SearchStateFeeder.swift b/prism/Services/SearchStateFeeder.swiftindex 454476c..077fe3c 100644--- a/prism/Services/SearchStateFeeder.swift+++ b/prism/Services/SearchStateFeeder.swift@@ -20,9 +20,10 @@ // equivalent, the current badge match is addressed by id, never a text ordinal // (design `setSearchState` row). //-// The DOM ids are occurrence-qualified exactly as the emitter assigns them-// (`b-{contentHash}-{sourceIndex}`) so the payload keys line up with the rendered-// sections without a round-trip.+// The DOM ids are occurrence-qualified via the shared `BlockDOMID.map(blocks:)`+// (`b-{contentHash}-{sourceIndex}`) — the same source of truth the emitter and+// NoteStateFeeder use — so the payload keys line up with the rendered sections+// without a round-trip. // import Foundation@@ -74,13 +75,13 @@ enum SearchStateFeeder { let coordinator = SearchHighlightCoordinator() var states: [BlockSearchState] = []- var occurrence: [String: Int] = [:] - for (blockIndex, block) in blocks.enumerated() {- let hash = block.id- let sourceIndex = occurrence[hash, default: 0]- occurrence[hash] = sourceIndex + 1- let domID = "b-\(hash)-\(sourceIndex)"+ // The occurrence-qualified DOM id comes from the shared BlockDOMID mapper — the+ // single source of truth the emitter and NoteStateFeeder also use — so the+ // payload keys line up with the rendered sections without a round-trip.+ for (blockIndex, entry) in BlockDOMID.map(blocks: blocks).enumerated() {+ let block = entry.block+ let domID = entry.domID let totalCount: Int if blockIndex < matchCountsPerBlock.count {@@ -220,9 +221,9 @@ enum SearchStateFeeder { return [text] case .paragraph(let markdown): return [markdown]- case .blockquote(let content):- return [content]- case .list(_, let items):+ case .blockquote(_, let children):+ return children.flatMap { inlineSources(of: $0) }+ case .list(_, _, let items): return items.flatMap { listItemSources($0) } case .table(let headers, let rows, _): return headers + rows.flatMap { $0 }
diff --git a/prism/Services/WebRendering/PrismLinkRoute.swift b/prism/Services/WebRendering/PrismLinkRoute.swiftnew file mode 100644index 0000000..0fca0ee--- /dev/null+++ b/prism/Services/WebRendering/PrismLinkRoute.swift@@ -0,0 +1,32 @@+//+// PrismLinkRoute.swift+// prism+//+// The `prism://` link routes the rendered document emits as in-page hrefs and the+// native router matches on activation (webview-rendering spec, Req 2.4/5.6/7.1).+//+// These hrefs are app-owned chrome links (footnote badge, add-note affordances). They+// are dispatched as `linkActivated` and matched natively in WebDocumentMessageRouter —+// never opened externally. This enum is the single Swift-side source of truth for the+// literals so the producers (BlockHTMLEmitter, NoteHTMLBuilder) and the consumer+// (WebDocumentMessageRouter) cannot drift on the string. The bundled JS keeps its own+// literals (it cannot import Swift); the contract is documented here.+//++import Foundation++/// App-owned `prism://` link routes shared by the HTML emitters and the message router.+enum PrismLinkRoute {+ /// The document-notes banner's "add a document note" affordance.+ static let documentNoteAdd = "prism://document-note/add"+ /// The failed-image placeholder's "grant folder access" affordance (iOS).+ static let imageAccessGrant = "prism://image-access/grant"+ /// The prefix a footnote badge href carries: `prism://footnote/{id}`.+ static let footnotePrefix = "prism://footnote/"++ /// The footnote badge href for a footnote identifier (`prism://footnote/{id}`).+ /// `identifier` must already be attribute-escaped by the caller.+ static func footnote(_ identifier: String) -> String {+ footnotePrefix + identifier+ }+}
diff --git a/prism/Theme/HighContrastThemeColors.swift b/prism/Theme/HighContrastThemeColors.swiftindex e90ed60..ecb1563 100644--- a/prism/Theme/HighContrastThemeColors.swift+++ b/prism/Theme/HighContrastThemeColors.swift@@ -7,12 +7,12 @@ import SwiftUI /// See `specs/a11y-increase-contrast/smolspec.md` for the per-theme /// override table and the rationale (audit finding H5, ticket T-1045). struct HighContrastThemeColors: ThemeColors {- let themeIdentity: PrismTheme- let base: any ThemeColors+ nonisolated let themeIdentity: PrismTheme+ nonisolated let base: any ThemeColors /// Constructs the high-contrast wrapper for `theme`, deriving the base /// `ThemeColors` from the same theme identity so the two can never drift.- init(theme: PrismTheme) {+ nonisolated init(theme: PrismTheme) { self.themeIdentity = theme self.base = theme.colors() }
diff --git a/prism/Theme/ThemeColors.swift b/prism/Theme/ThemeColors.swiftindex 8c50208..ceb15ab 100644--- a/prism/Theme/ThemeColors.swift+++ b/prism/Theme/ThemeColors.swift@@ -2,7 +2,7 @@ import SwiftUI /// Protocol defining all color properties that a theme must provide. /// Organized by semantic purpose for extensibility.-protocol ThemeColors {+protocol ThemeColors: Sendable { // MARK: - Backgrounds nonisolated var background: Color { get }
diff --git a/prism/Resources/WebRenderer/document.css b/prism/Resources/WebRenderer/document.cssindex 6322df4..d06aa9e 100644--- a/prism/Resources/WebRenderer/document.css+++ b/prism/Resources/WebRenderer/document.css@@ -208,11 +208,18 @@ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%;+ /* No page-level horizontal scroll: the document never scrolls sideways (wide tables /+ * code / diagrams scroll within their own containers). Stops the iOS rubber-band+ * "give" when there is no horizontal content. Gutter affordances sit inside the+ * viewport, so clipping page overflow does not hide them. */+ overflow-x: hidden; } body { margin: 0;- padding: calc(var(--prism-spacing) * 1.5) var(--prism-spacing);+ max-width: 100%;+ overflow-x: hidden;+ padding: var(--prism-spacing) var(--prism-spacing); background-color: var(--prism-background); color: var(--prism-text-primary); font-family: var(--prism-font-family);@@ -227,8 +234,15 @@ body { main { max-width: var(--prism-content-max-width); margin: 0 auto;+ /* A leading gutter the block-level note affordances ("+" / indicator dot) sit in,+ * so they read as beside the block rather than breaking onto a line above it (T-1542).+ * The affordances are absolutely positioned into this gutter (see "add note" below). */+ padding-left: 1.7em; } +/* Every block section is a positioning context so its gutter affordances anchor to it. */+section[data-prism-block-id] { position: relative; }+ /* ---- Block elements ---- */ h1, h2, h3, h4, h5, h6 { color: var(--prism-text-primary);@@ -266,7 +280,9 @@ hr { } ul, ol { margin: 0 0 var(--prism-spacing); padding-left: 1.6em; }-li { margin: 0.2em 0; }+/* Tight item spacing (the "+" is positioned out of flow below, so it never inflates the+ * line). Each li is a positioning context so its gutter "+" anchors to it. */+li { margin: 0.1em 0; position: relative; } /* Task list items (Req 1.1). */ li.prism-task { list-style: none; margin-left: -1.4em; }@@ -305,6 +321,128 @@ pre code { .hljs-comment, .hljs-quote { color: var(--prism-syntax-comment); font-style: italic; } .hljs { color: var(--prism-syntax-plain); } +/* ---- Code block header (Req 1.1) ---- */+/* The emitter ships a header bar above each <pre>: the language label sits on the+ * leading edge as a heading; the copy button sits on the trailing edge. Flex with+ * space-between keeps the copy button right-aligned regardless of language width. */+.prism-code-chrome {+ display: flex;+ align-items: center;+ justify-content: space-between;+ gap: 0.8em;+ margin: var(--prism-spacing) 0 0;+ padding: 0.35em 0.8em;+ background-color: var(--prism-code-header);+ border: 1px solid var(--prism-code-border);+ border-bottom: none;+ border-radius: 8px 8px 0 0;+}+.prism-code-lang {+ font-size: 0.8em;+ font-weight: 600;+ color: var(--prism-text-secondary);+}+/* The <pre> sits flush beneath its header so the two read as one card. */+section[data-prism-kind="code"] pre {+ margin-top: 0;+ border-top-left-radius: 0;+ border-top-right-radius: 0;+}++/* Pill buttons shared by the code + diagram headers (copy / view source / full screen). */+.prism-copy,+.prism-chrome-button {+ -webkit-appearance: none;+ appearance: none;+ flex: none;+ font-family: var(--prism-font-family);+ font-size: 0.8em;+ font-weight: 500;+ color: var(--prism-link);+ background-color: transparent;+ border: 1px solid var(--prism-border);+ border-radius: 6px;+ padding: 0.2em 0.6em;+ cursor: pointer;+ transition: background-color 0.15s ease, color 0.15s ease;+}+.prism-copy:hover,+.prism-copy:focus-visible,+.prism-chrome-button:hover,+.prism-chrome-button:focus-visible {+ background-color: var(--prism-surface);+ color: var(--prism-accent);+ outline: none;+}++/* ---- Mermaid diagram header (Req 3.1/3.2) ---- */+/* A header bar mirrors the code block: diagram type on the leading edge, the action+ * buttons (view source / copy / full screen) on the trailing edge. */+.prism-diagram-chrome {+ display: flex;+ align-items: center;+ justify-content: space-between;+ gap: 0.8em;+ margin: var(--prism-spacing) 0 0;+ padding: 0.35em 0.8em;+ background-color: var(--prism-code-header);+ border: 1px solid var(--prism-code-border);+ border-bottom: none;+ border-radius: 8px 8px 0 0;+}+/* The type label takes the slack and truncates so the action buttons never clip. */+.prism-diagram-type {+ flex: 1 1 auto;+ min-width: 0;+ overflow: hidden;+ text-overflow: ellipsis;+ white-space: nowrap;+ font-size: 0.8em;+ font-weight: 600;+ color: var(--prism-text-secondary);+}+.prism-diagram-actions {+ flex: 0 0 auto;+ display: flex;+ align-items: center;+ gap: 0.4em;+}+/* Full-screen affordance: an icon (CSS glyph) rather than a word, so it never clips on+ * a narrow header (Req 3.1). The accessible label still comes from the catalog (aria-label). */+.prism-zoom-icon {+ padding: 0.2em 0.45em;+ font-size: 0.95em;+ line-height: 1;+}+.prism-zoom-icon::before { content: "\2922"; } /* ⤤/⤢ expand arrows */+/* The rendered diagram sits flush beneath its header; tapping it opens the zoom view. */+section[data-prism-kind="mermaid"] .prism-mermaid-out {+ margin: 0;+ padding: 0.9em 1em;+ background-color: var(--prism-code-background);+ border: 1px solid var(--prism-code-border);+ border-radius: 0 0 8px 8px;+ overflow-x: auto;+ cursor: zoom-in;+}+section[data-prism-kind="mermaid"] .prism-mermaid-out svg {+ max-width: 100%;+ height: auto;+}+/* "View source" reveals the diagram source as a code block beneath the diagram. */+.prism-mermaid-source {+ margin: 0.5em 0 var(--prism-spacing);+ padding: 0.9em 1em;+ background-color: var(--prism-code-background);+ border: 1px solid var(--prism-code-border);+ border-radius: 8px;+ overflow-x: auto;+ font-family: var(--prism-font-mono);+ font-size: 0.85em;+ white-space: pre;+ color: var(--prism-syntax-plain);+}+ sup, sub { line-height: 0; font-size: 0.75em; } img { max-width: 100%; height: auto; }@@ -329,18 +467,23 @@ th { } /* A wide table is wrapped in a scroll container; the mode toggles which behaviour- * applies, matching the existing per-table display-mode button (state held natively). */-.prism-table-wrap { margin: var(--prism-spacing) 0; }--/* Horizontal-scroll mode: the table keeps its natural width and scrolls sideways. */-.prism-table-wrap[data-prism-table-mode="scroll"] {+ * applies, matching the existing per-table display-mode button (state held natively).+ * The wrapper always contains a too-wide table horizontally so the page itself never+ * scrolls sideways, in ANY mode — the table scrolls within the wrapper instead. */+.prism-table-wrap {+ margin: var(--prism-spacing) 0;+ max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }++/* Horizontal-scroll mode: the table keeps its natural content width and scrolls+ * sideways within the wrapper. */ .prism-table-wrap[data-prism-table-mode="scroll"] table { width: max-content; } -/* Readable mode: columns wrap to a max width so the table fits the column (300pt rule). */-.prism-table-wrap[data-prism-table-mode="readable"] { overflow-x: visible; }+/* Readable mode: columns cap at a max width so the table fits the column (300pt rule);+ * a capped table still wider than the viewport scrolls within the wrapper (inherited+ * overflow-x: auto) rather than overflowing the page. */ .prism-table-wrap[data-prism-table-mode="readable"] th, .prism-table-wrap[data-prism-table-mode="readable"] td { max-width: 300px;@@ -348,13 +491,50 @@ th { word-break: break-word; } -/* ---- Collapsible sections (Req 1.6) ---- */-/* A heading section the user collapsed hides its following content until the next- * heading of the same/higher level. State is held natively; setSectionState toggles- * this attribute (prism-theme.js). The collapsed block's own heading stays visible. */-section[data-prism-collapsed="true"] > *:not(:first-child) {+/* Fitted mode: the base table rule (max-width: 100%) shares width across columns. */++/* ---- Collapsible heading sections (Req 1.6) ---- */+/* Each block is its own flat top-level <section>, so a collapsed heading hides the+ * FOLLOWING sibling sections (until the next heading of the same/higher level), not its+ * own children. prism-theme.js computes that span from the heading levels and marks the+ * hidden sections; this rule collapses them. State is held natively (setSectionState). */+section[data-prism-section-hidden="true"] { display: none; }+/* The disclosure chevron sits inline at the heading's leading edge; the glyph is CSS-drawn+ * (no document text) and rotates with the collapse state. */+.prism-section-toggle {+ -webkit-appearance: none;+ appearance: none;+ /* Sit in the leading gutter, in the same column as the note affordances (device round,+ * T-1542). A fixed-width box pulled left by the same width keeps the heading text+ * starting at the content edge for every heading level, and left-aligns the glyph into+ * the gutter. The box stays IN FLOW (not absolute) so it auto-aligns to the heading's+ * first line without per-level vertical tuning. rem keeps the gutter offset constant+ * regardless of the heading's (level-dependent) font-size. */+ display: inline-block;+ width: 1.5rem;+ margin-left: -1.5rem;+ padding: 0;+ border: none;+ background: transparent;+ text-align: left;+ color: var(--prism-text-tertiary);+ /* A solid (non-"small") triangle: the previous small ▾/▸ read as a tiny dot next to a+ * large heading. The solid glyph carries far more ink at the same box. */+ font-size: 0.8em;+ line-height: 1;+ vertical-align: middle;+ cursor: pointer;+ transition: color 0.15s ease;+}+.prism-section-toggle::before { content: "\25BC"; } /* ▼ expanded */+section[data-prism-collapsed="true"] .prism-section-toggle::before { content: "\25B6"; } /* ▶ collapsed */+.prism-section-toggle:hover,+.prism-section-toggle:focus-visible {+ color: var(--prism-accent);+ outline: none;+} /* ---- <details> (Req 1.6) ---- */ details {@@ -412,6 +592,7 @@ html[data-prism-comments="visible"] .prism-comment { display: block; } } /* ---- Inline notes (Req 5.6) ---- */+.prism-inline-notes { margin: 0.4em 0; } .prism-inline-note { background-color: var(--prism-inline-note-bg); border-radius: 8px;@@ -419,21 +600,43 @@ html[data-prism-comments="visible"] .prism-comment { display: block; } margin: 0.4em 0; font-size: 0.95em; }+/* Imported notes get a leading accent to distinguish them from the user's own,+ * matching the retired in-flow note views. */+.prism-inline-note.prism-note-imported { border-left: 3px solid var(--prism-accent); }+/* Replies indent under their parent so a thread reads as a thread. */+.prism-note-reply { margin-left: 1.2em; }+.prism-note-author {+ font-weight: 600;+ font-size: 0.85em;+ color: var(--prism-text-secondary);+ margin-bottom: 0.15em;+}+.prism-note-timestamp {+ font-size: 0.8em;+ color: var(--prism-text-tertiary);+ margin-top: 0.2em;+} /* ---- Note indicators (Req 5.2) ---- */ .prism-note-indicator { color: var(--prism-accent); cursor: pointer; }-/* Block-level indicator dot rendered by prism-notes.js into the leading margin. */-section > .prism-note-indicator[data-prism-note-indicator] {- display: inline-block;+/* Block-level indicator dot: absolutely positioned in the leading gutter beside the+ * block's first line (T-1542), not in flow above the block. */+section[data-prism-block-id] > .prism-note-indicator[data-prism-note-indicator] {+ position: absolute;+ left: -1.4em;+ top: 0.5em; width: 0.5em; height: 0.5em;- margin-right: 0.4em;+ margin: 0; border-radius: 50%; background-color: var(--prism-accent);- vertical-align: middle;+}+/* Headings carry a 1.4em top margin; drop the gutter dot down to meet the heading text. */+section[data-prism-block-id]:has(> :is(h1, h2, h3, h4, h5, h6)) > .prism-note-indicator[data-prism-note-indicator] {+ top: 1.7em; } /* Table-row indicator sits at the row's leading edge. */ td > .prism-note-indicator[data-prism-note-indicator],@@ -447,37 +650,221 @@ th > .prism-note-indicator[data-prism-note-indicator] { vertical-align: middle; } -/* ---- Global notes banner (Req 5.6) ---- */+/*+ * ---- Per-block "add note" affordance (Req 5.3) ----+ * Injected by prism-notes.js as icon-only chrome so iOS gets a tap target that does not+ * depend on the `contextmenu` gesture (which iOS long-press never fires). Subtle by+ * default; the glyph is drawn in CSS (no document-derived text). The button posts+ * `blockContextRequested`, opening the native add-note editor.+ */+.prism-add-note {+ -webkit-appearance: none;+ appearance: none;+ border: none;+ background: transparent;+ padding: 0;+ margin: 0;+ /* Inherit the content font-size: a <button>'s UA default is ~13.3px, which would make+ * every em offset (and the chip) compute against a different unit than the note dot+ * (a <span> at the content's 17px), so the "+" and the dot never shared a column. With+ * 1em they use the same unit and scale together with the text-size setting (T-1542). */+ font-size: 1em;+ color: var(--prism-text-tertiary);+ cursor: pointer;+ line-height: 1;+ opacity: 0.35;+ transition: opacity 0.15s ease, color 0.15s ease;+}+/* The glyph: a centred "+" in a small rounded chip. Drawn from CSS, not document text. */+.prism-add-note::before {+ content: "+";+ display: inline-flex;+ align-items: center;+ justify-content: center;+ width: 1.25em;+ height: 1.25em;+ font-size: 0.85em;+ font-weight: 600;+ border-radius: 50%;+ border: 1px solid var(--prism-border);+ background-color: var(--prism-surface-2);+}+.prism-add-note:hover,+.prism-add-note:focus-visible {+ opacity: 1;+ color: var(--prism-accent);+ outline: none;+}+/* Block-level affordance: absolutely positioned in the leading gutter beside the+ * block's first line (T-1542), not in flow above the block. */+section[data-prism-block-id] > .prism-add-note[data-prism-add-note] {+ position: absolute;+ left: -1.55em;+ top: 0.1em;+ margin: 0;+}+/* Headings carry no "+" — they are collapse targets, not note anchors (T-1542). The+ * disclosure chevron is their only leading affordance; prism-notes.js skips heading+ * sections when injecting the add-note "+". */+/*+ * When a block / table-row already carries a note-indicator dot, the dot's tap manages+ * the existing notes — so that container's "+" is redundant and hidden (Req 5.3, T-1542).+ * prism-notes.js sets data-prism-suppressed on the direct-child "+" of any container that+ * gets a dot — done in JS, not via :has(), which does not reliably re-evaluate on a cell+ * when the dot is inserted live (a note added during the session).+ */+[data-prism-add-note][data-prism-suppressed] {+ display: none;+}+/* List-item affordance: positioned out of flow so it reads as beside the item and does+ * not inflate the line height (which spaced items too far apart). It sits in the SAME+ * leading gutter column as the block-level note dot, so a list's "+" and its note+ * indicator share one column (device feedback, T-1542). The base offset is the marker+ * gutter (nested / fallback); top-level items are pulled out to the main gutter below. */+li > .prism-add-note[data-prism-add-note] {+ position: absolute;+ left: -1.5em;+ top: 0.1em;+ margin: 0;+}+/* Top-level items: pull the "+" out to the main gutter, CENTRED on the block note dot so+ * the "+" and the dot share one column. The dot is a 0.5em circle at section -1.4em+ * (centre -1.15em); the 1.25em "+" chip centres there when its left edge is at section+ * -1.775em, i.e. -3.375em from a top-level li (1.6em list indent). */+section[data-prism-block-id] > ul > li > .prism-add-note[data-prism-add-note],+section[data-prism-block-id] > ol > li > .prism-add-note[data-prism-add-note] {+ left: -3.375em;+}+/* Nested list items: pull each level's "+" out to the SAME main-gutter column (verified+ * with headless WebKit), so it never sits on the nested bullet/number. Each nesting level+ * adds one list indent (1.6em), so the offset grows by 1.6em per level: -3.375, -4.975,+ * -6.575, -8.175, -9.775 … (levels 2-6; deeper lists fall back to the marker-gutter base). */+section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > .prism-add-note[data-prism-add-note] {+ left: -4.975em;+}+section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > .prism-add-note[data-prism-add-note] {+ left: -6.575em;+}+section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > .prism-add-note[data-prism-add-note] {+ left: -8.175em;+}+section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > .prism-add-note[data-prism-add-note] {+ left: -9.775em;+}+/* Task-list items carry no bullet and are shifted left 1.4em; centre their "+" on the dot. */+section[data-prism-block-id] > ul > li.prism-task > .prism-add-note[data-prism-add-note] {+ left: -1.975em;+}+/* Table-row affordance stays inline before the cell content (cells are boxes, no marker). */+td > .prism-add-note[data-prism-add-note],+th > .prism-add-note[data-prism-add-note] {+ margin-right: 0.3em;+ vertical-align: middle;+}+/*+ * On touch devices (no hover), keep the affordance discoverable but unobtrusive so it+ * stays a reliable tap target — iOS long-press selects text rather than opening a menu.+ */+@media (hover: none) {+ .prism-add-note { opacity: 0.5; }+}++/* ---- Document-notes banner (Req 5.6) ---- */+/* Reproduces the retired GlobalNotesBanner card (T-1542): a raised surface card, an+ * empty-state "add a document note" row, and a with-notes state with a collapse chevron,+ * a count, an add button, and the document notes as bubbles. */ [data-prism-notes-banner] { margin: var(--prism-spacing) 0; }-[data-prism-notes-banner][data-prism-placement="top"] {- order: -1;+/* The card: a raised surface panel (white in light themes), distinct from the page. */+.prism-notes-banner {+ background-color: var(--prism-surface);+ border: 1px solid var(--prism-border-subtle);+ border-radius: 12px;+ padding: 0.9em 1.1em;+ box-shadow: 0 1px 3px color-mix(in srgb, var(--prism-text-primary) 10%, transparent);+}++/* Empty state: a single tappable row — note icon + "Add a document note". */+.prism-notes-banner-empty { padding: 0; }+.prism-notes-add-empty {+ display: flex;+ align-items: center;+ gap: 0.7em;+ padding: 0.9em 1.1em;+ color: var(--prism-text-primary);+ text-decoration: none;+ cursor: pointer; }--/* ---- Selection add-note pill (Req 12.2/12.4) ---- */-[data-prism-add-note-pill] {- position: fixed;- z-index: 50;- transform: translateY(-100%);- padding: 0.3em 0.7em;- border-radius: 999px;- background-color: var(--prism-accent);- color: #ffffff;- font-size: 0.85em;- font-weight: 600;+.prism-notes-add-empty .prism-notes-add-text { font-weight: 500; }+.prism-notes-add-empty:hover .prism-notes-add-text,+.prism-notes-add-empty:focus-visible .prism-notes-add-text {+ color: var(--prism-accent);+ outline: none;+}++/* With-notes header: collapse toggle (chevron + count) on the leading edge, add button on+ * the trailing edge. */+.prism-notes-banner-header {+ display: flex;+ align-items: center;+ gap: 0.6em;+ margin-bottom: 0.6em;+}+/* The toggle wraps the chevron + count and collapses/expands the bubbles below. Its text+ * (the count) is its accessible name, so no extra label string is needed (Req 1.9). */+.prism-notes-toggle {+ -webkit-appearance: none;+ appearance: none;+ flex: 1 1 auto;+ display: flex;+ align-items: center;+ gap: 0.4em;+ border: none;+ background: transparent;+ padding: 0;+ /* A <button>'s UA default font-size (~13.3px) is smaller than the content (17px), which+ * made the count read smaller than the empty-state "add" text; 1em matches them. */+ font-size: 1em;+ color: var(--prism-text-primary); cursor: pointer;- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);+ text-align: left; }-[data-prism-add-note-pill] .prism-pill-decline { display: none; }-[data-prism-add-note-pill] .prism-pill-add { display: inline; }-/* When the selection spans blocks the pill shows the decline feedback (Req 12.4). */-[data-prism-add-note-pill][data-prism-pill-declined="true"] {- background-color: var(--prism-surface-2);- color: var(--prism-text-secondary);+.prism-notes-chevron {+ color: var(--prism-text-tertiary);+ font-size: 0.75em;+ line-height: 1;+}+.prism-notes-chevron::before { content: "\25BC"; } /* ▼ expanded */+.prism-notes-banner[data-prism-notes-collapsed="true"] .prism-notes-chevron::before { content: "\25B6"; } /* ▶ */+/* Match the empty-state "Add a document note" text exactly (size inherited at 1em, weight,+ * and accent colour) so the banner reads the same with or without notes (T-1542). */+.prism-notes-banner-title { font-weight: 500; color: var(--prism-accent); }++/* The add button: a filled accent "+" disc (matches the retired plus.circle.fill). The SVG+ * inherits the accent via currentColor; the "+" stroke is drawn light so it reads on the+ * disc in every theme. Icon-only — its accessible name is the link's aria-label. */+.prism-notes-add {+ flex: 0 0 auto;+ display: inline-flex;+ align-items: center;+ color: var(--prism-accent);+ text-decoration: none;+ cursor: pointer;+}+.prism-notes-icon { display: block; color: var(--prism-accent); }++/* Collapsed: hide the bubbles, keep the header. */+.prism-notes-banner[data-prism-notes-collapsed="true"] .prism-notes-bubbles { display: none; }+/* Document-note bubbles always carry the leading accent (parity with the retired banner). */+.prism-notes-bubbles .prism-inline-note { border-left: 3px solid var(--prism-accent); }++/* The top-placed banner is the document's first element, so its own top margin is just+ * dead space above it — drop it (the body padding already provides the top gap). T-1542. */+[data-prism-notes-banner][data-prism-placement="top"] {+ margin-top: 0;+ order: -1; }-[data-prism-add-note-pill][data-prism-pill-declined="true"] .prism-pill-add { display: none; }-[data-prism-add-note-pill][data-prism-pill-declined="true"] .prism-pill-decline { display: inline; } /* ---- Media affordances (zoom/copy overlays, error placeholders) ---- */ .prism-mermaid, .prism-image-block { position: relative; margin: var(--prism-spacing) 0; }@@ -489,6 +876,17 @@ th > .prism-note-indicator[data-prism-note-indicator] { background-color: var(--prism-surface-2); font-size: 0.9em; }+/* Failed-image placeholder: replaces the broken-image glyph with a readable box that can+ * carry a "Grant Folder Access" button (Req 3.x). */+.prism-image-placeholder {+ display: flex;+ flex-direction: column;+ align-items: flex-start;+ gap: 0.5em;+}+.prism-image-placeholder-title { font-weight: 600; color: var(--prism-text-primary); }+.prism-image-placeholder-alt { color: var(--prism-text-tertiary); font-size: 0.9em; }+.prism-grant-access { align-self: flex-start; margin-top: 0.2em; } /* * ---- Chrome is not selectable (Req 4.3) ----
diff --git a/prism/Resources/WebRenderer/prism-notes.js b/prism/Resources/WebRenderer/prism-notes.jsindex abc43de..d912029 100644--- a/prism/Resources/WebRenderer/prism-notes.js+++ b/prism/Resources/WebRenderer/prism-notes.js@@ -16,9 +16,11 @@ * styled user-select:none by document.css, so a Select All never grabs note text and * note elements are not wrapped in source runs. *- * No user-visible string literals here (Req 1.9): indicator glyph + the add-note pill- * labels are emitter-supplied (data attributes / emitter-rendered template); this- * script only positions/toggles structural elements.+ * No user-visible string literals here (Req 1.9): the indicator glyph is emitter-supplied+ * (data attributes / styling), the selection "Add note" affordance is a native SwiftUI+ * overlay, and the per-block "add note" affordance is icon-only with its accessible label+ * read from <main data-prism-add-note-label> (catalog-resolved natively). This script only+ * positions/toggles structural elements, injects that chrome, and reports selection state. */ (function () { "use strict";@@ -54,6 +56,24 @@ for (var i = 0; i < existing.length; i++) { existing[i].parentNode.removeChild(existing[i]); }+ // Restore any add-note "+" suppressed by a previous render (so a removed note+ // brings its "+" back).+ var suppressed = document.querySelectorAll("[data-prism-add-note][data-prism-suppressed]");+ for (var j = 0; j < suppressed.length; j++) {+ suppressed[j].removeAttribute("data-prism-suppressed");+ }+ }++ // A block / table-row with a note dot hides its own "+" — done explicitly here rather+ // than via CSS :has(), which does not reliably re-evaluate on a cell when the dot is+ // inserted live (a note added during the session). Scoped to the direct-child "+" so a+ // block dot only suppresses the block "+" and a row dot only the row "+".+ function suppressAddNote(container) {+ var add = container.querySelector(":scope > [data-prism-add-note]");+ // Lists carry no direct-child "+" (theirs live on each item); the block dot sits in+ // the gutter at the first item, so suppress that item's "+" to avoid overlapping it.+ if (!add) { add = container.querySelector("li > [data-prism-add-note]"); }+ if (add) { add.setAttribute("data-prism-suppressed", ""); } } function makeIndicator(blockID, rowOrdinal) {@@ -87,8 +107,10 @@ if (!row) { return; } var cell = row.querySelector("td, th") || row; cell.insertBefore(makeIndicator(entry.domID, entry.rowOrdinal), cell.firstChild);+ suppressAddNote(cell); } else { section.insertBefore(makeIndicator(entry.domID, null), section.firstChild);+ suppressAddNote(section); } }); }@@ -168,6 +190,27 @@ }); }, true); + // ---- Document-notes banner collapse (Req 5.6) ------------------------+ // The banner's chevron toggle collapses/expands its bubbles. The banner is re-injected+ // on every note change, so this is a delegated listener; the collapse state lives in+ // data-prism-notes-collapsed on the .prism-notes-banner (reset on re-render, by design).+ document.addEventListener("click", function (event) {+ var toggle = event.target.closest("[data-prism-notes-toggle]");+ if (!toggle) { return; }+ var banner = toggle.closest(".prism-notes-banner");+ if (!banner) { return; }+ event.preventDefault();+ event.stopPropagation();+ var collapsed = banner.getAttribute("data-prism-notes-collapsed") === "true";+ if (collapsed) {+ banner.removeAttribute("data-prism-notes-collapsed");+ toggle.setAttribute("aria-expanded", "true");+ } else {+ banner.setAttribute("data-prism-notes-collapsed", "true");+ toggle.setAttribute("aria-expanded", "false");+ }+ }, true);+ // ---- Block context gesture (Req 5.3) --------------------------------- // Right-click / long-press on a block (or list item / table row) requests the // existing note context flow with the matching sub-target. macOS dispatches@@ -199,12 +242,103 @@ bridge.post("blockContextRequested", fields); }, true); + // ---- Per-block "add note" affordance (Req 5.3, iOS-reachable) --------+ // `contextmenu` is right-click only; iOS long-press triggers text selection, not+ // contextmenu, so it never fires there. To give every platform a working way to add a+ // block / list-item / table-row note, inject a subtle icon-only "+" button as chrome+ // (data-prism-chrome, user-select:none; document.css reveals it on hover/tap). The+ // button posts the same `blockContextRequested` the contextmenu handler does, with the+ // sub-target derived from where the affordance sits — so it routes through the existing+ // native handleBlockContext (which opens the add-note editor). The accessible label is+ // catalog-resolved natively and carried on <main> (no string literals here, Req 1.9).++ function addNoteLabel() {+ var main = document.querySelector("main[data-prism-document]");+ return (main && main.getAttribute("data-prism-add-note-label")) || "";+ }++ // Builds the icon-only add affordance for a given block id and optional sub-target.+ function makeAddNoteControl(blockID, subTarget) {+ var label = addNoteLabel();+ var button = document.createElement("button");+ button.type = "button";+ button.className = "prism-add-note";+ button.setAttribute("data-prism-add-note", "");+ button.setAttribute("data-prism-chrome", "");+ if (label) { button.setAttribute("aria-label", label); button.title = label; }+ button.addEventListener("click", function (event) {+ event.preventDefault();+ event.stopPropagation();+ var fields = { blockID: blockID, rect: bridge.clientRect(button) };+ if (subTarget) { fields.subTarget = subTarget; }+ bridge.post("blockContextRequested", fields);+ }, false);+ return button;+ }++ // Removes any previously injected add affordances so a re-render is idempotent.+ function clearAddNoteControls() {+ var existing = document.querySelectorAll("[data-prism-add-note]");+ for (var i = 0; i < existing.length; i++) {+ existing[i].parentNode.removeChild(existing[i]);+ }+ }++ // Injects one block-level affordance per section, plus per list-item and per table+ // body-row affordances so the sub-target (NoteAnchor) matches the gesture's anchor.+ function renderAddNoteControls() {+ clearAddNoteControls();+ var sections = document.querySelectorAll("main[data-prism-document] section[data-prism-block-id]");+ for (var i = 0; i < sections.length; i++) {+ var section = sections[i];+ // Skip structural-only blocks that have no note anchor (separators, metadata).+ if (section.getAttribute("role") === "separator") { continue; }+ if (section.hasAttribute("data-prism-metadata")) { continue; }+ // Headings are collapse targets, not note anchors — they carry the disclosure+ // chevron only, never a "+" (T-1542).+ if (section.getAttribute("data-prism-kind") === "heading") { continue; }++ // Per list-item / table-row affordances first, tracking whether any exist.+ var hasChildAffordance = false;++ var items = section.querySelectorAll("li[data-prism-sub^='item-']");+ for (var j = 0; j < items.length; j++) {+ var itemSub = subTargetOf(items[j]);+ if (itemSub) {+ items[j].insertBefore(makeAddNoteControl(section.id, itemSub), items[j].firstChild);+ hasChildAffordance = true;+ }+ }++ var rows = section.querySelectorAll("tr[data-prism-sub^='row-']");+ for (var k = 0; k < rows.length; k++) {+ var rowSub = subTargetOf(rows[k]);+ if (rowSub) {+ var firstCell = rows[k].querySelector("td, th") || rows[k];+ firstCell.insertBefore(makeAddNoteControl(section.id, rowSub), firstCell.firstChild);+ hasChildAffordance = true;+ }+ }++ // Block-level "+" only when the block has no per-child affordances. Lists and+ // tables expose per-item / per-row "+" instead — a parent "+" on top of those+ // crowds the block and misaligns (T-1542).+ if (!hasChildAffordance) {+ section.insertBefore(makeAddNoteControl(section.id, null), section.firstChild);+ }+ }+ }++ renderAddNoteControls();+ // ---- Selection-anchored notes (Req 12) ------------------------------- // Resolve the live selection's endpoints through the anchor-based source map to a- // UTF-16 source-offset range, present the app-owned "Add note" pill near the- // selection, and post selectionNote on activation. A selection spanning blocks is- // declined with feedback (Req 12.4): the pill shows its decline label and does not- // post a note (the controller owns the block source text and the conversion).+ // UTF-16 source-offset range and report the selection state to native via+ // selectionCandidate. Native draws the "Add note" affordance as a SwiftUI overlay on+ // both platforms (T-1542): the WebKit native selection callout would cover an in-page+ // pill, so the affordance lives outside the WebView. A selection spanning blocks is+ // declined (Req 12.4): native shows no create action. The controller owns the block+ // source text and the UTF-16 -> NoteTextRange conversion. var sourceMap = bridge.sourceMap || { runs: {} }; @@ -290,66 +424,58 @@ return { domID: startEndpoint.domID, start: lo, length: hi - lo }; } - // The app-owned add-note pill. The emitter ships a hidden template carrying the- // localized labels (Req 1.9); this script positions/shows it. If the template is- // absent (older emitter), selection notes are simply unavailable — no JS strings.- var pill = document.querySelector("[data-prism-add-note-pill]");- var pillState = null; // the resolved single-block range to create on activation.-- function hidePill() {- if (pill) { pill.hidden = true; }- pillState = null;- }-- function showPill(rect, declined) {- if (!pill) { return; }- pill.hidden = false;- pill.setAttribute("data-prism-pill-declined", declined ? "true" : "false");- // Position near the selection's end; clamp into the viewport.- var top = Math.max(0, rect.top - 8);- var left = Math.max(0, rect.left);- pill.style.top = top + "px";- pill.style.left = left + "px";+ // ---- Native selection candidate (Req 12.2) -------------------------+ // The selection state is reported to native, which shows the "Add note" affordance as+ // a SwiftUI overlay above the WebView (both platforms, T-1542). State is one of:+ // "available" — single-block resolvable: { state, blockID, range:{start,length}, rect }+ // "declined" — cross-block selection: { state, rect } (no create action, Req 12.4)+ // "cleared" — collapsed/none/unmapped: { state } (hide the overlay)+ // Dedup so a stream of selectionchange events for an unchanged selection does not+ // spam the bridge.+ var lastCandidateKey = null;++ function clientRectFromDOMRect(rect) {+ return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; } - if (pill) {- pill.setAttribute("data-prism-chrome", "");- pill.addEventListener("click", function (event) {- event.preventDefault();- event.stopPropagation();- if (pillState) {- bridge.post("selectionNote", {- blockID: pillState.domID,- range: { start: pillState.start, length: pillState.length },- rect: bridge.clientRect(pill),- });- }- // A declined (cross-block) pill simply dismisses — the decline label was- // the feedback (Req 12.4).- hidePill();- var sel = window.getSelection();- if (sel) { sel.removeAllRanges(); }- }, false);+ function postSelectionCandidate(fields) {+ // Build a stable dedup key from the meaningful fields (rect rounded to the+ // nearest pixel so sub-pixel jitter does not re-post).+ var key = fields.state;+ if (fields.blockID) { key += "|" + fields.blockID; }+ if (fields.range) { key += "|" + fields.range.start + "," + fields.range.length; }+ if (fields.rect) {+ key += "|" + Math.round(fields.rect.x) + "," + Math.round(fields.rect.y)+ + "," + Math.round(fields.rect.width) + "," + Math.round(fields.rect.height);+ }+ if (key === lastCandidateKey) { return; }+ lastCandidateKey = key;+ bridge.post("selectionCandidate", fields); } function handleSelectionChange() { var selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {- hidePill();+ postSelectionCandidate({ state: "cleared" }); return; } var range = selection.getRangeAt(0); var resolved = resolveSelectionRange(range);- if (!resolved) { hidePill(); return; }- var rect = range.getBoundingClientRect();+ if (!resolved) {+ postSelectionCandidate({ state: "cleared" });+ return;+ }+ var rect = clientRectFromDOMRect(range.getBoundingClientRect()); if (resolved.crossBlock) {- // Decline with feedback: show the pill in its declined state; no note range.- pillState = null;- showPill(rect, true);+ postSelectionCandidate({ state: "declined", rect: rect }); return; }- pillState = resolved;- showPill(rect, false);+ postSelectionCandidate({+ state: "available",+ blockID: resolved.domID,+ range: { start: resolved.start, length: resolved.length },+ rect: rect,+ }); } document.addEventListener("selectionchange", handleSelectionChange, false);
diff --git a/prismTests/WebRendering/SamplesComplianceTests.swift b/prismTests/WebRendering/SamplesComplianceTests.swiftnew file mode 100644index 0000000..199592f--- /dev/null+++ b/prismTests/WebRendering/SamplesComplianceTests.swift@@ -0,0 +1,400 @@+//+// SamplesComplianceTests.swift+// prismTests+//+// Samples-driven compliance suite for the web renderer (web-markdown-fidelity spec,+// task 1, Req 4.1–4.4 + the rendering ACs 1.x/2.x/3.x).+//+// The suite sweeps the repo-root `samples/` directory and a committed image-in-list+// fixture, parsing each via `MarkdownBlockParser.parseWithFootnotes` and emitting via+// `BlockHTMLEmitter.emit`, then asserts the structural markers each targeted CommonMark+// construct must produce in the emitted HTML.+//+// RED BASELINE (Decision 5): these are HARD assertions, no `withKnownIssue`. The gap+// assertions (nested blockquotes, multi-paragraph quotes, `<ol start>`, rich blocks+// inside list items, no leaked markers) are EXPECTED TO FAIL until the emitter and model+// changes land in later tasks — the suite is the test-first measure of the gaps. Only+// the "every sample parses+emits" guard (Req 4.1) is green today.+//++import Foundation+import Testing+@testable import prism++@Suite("Web Markdown Fidelity — Samples Compliance")+struct SamplesComplianceTests {++ // MARK: - Helpers++ /// Render settings used throughout: neutral fallback strings so the suite is a pure+ /// function of the sample content and not of any localisation catalog state.+ private static let settings = RenderSettings(showHTMLComments: false, strings: .fallback)++ /// Parses a sample by file name (with or without the `.md` extension) from the repo-root+ /// `samples/` directory and emits its HTML. The trailing `.md` is normalised so callers+ /// may pass either `"lists-edge-cases"` or `"lists-edge-cases.md"`.+ private func emitSample(_ name: String) throws -> String {+ let base = name.hasSuffix(".md") ? String(name.dropLast(3)) : name+ let url = ParityFixtureSupport.samplesDirectory()+ .appendingPathComponent("\(base).md")+ let markdown = try String(contentsOf: url, encoding: .utf8)+ return emit(markdown)+ }++ /// Emits a fixture loaded via `ParityFixtureSupport` (used for the committed+ /// image-in-list fixture absent from `samples/`).+ private func emitFixture(_ relativePath: String) throws -> String {+ let markdown = try ParityFixtureSupport.load(relativePath)+ return emit(markdown)+ }++ private func emit(_ markdown: String) -> String {+ let parsed = MarkdownBlockParser.parseWithFootnotes(markdown)+ return BlockHTMLEmitter.emit(+ blocks: parsed.blocks,+ footnotes: parsed.footnoteData,+ settings: Self.settings+ ).html+ }++ /// All `samples/*.md` file names (without extension), sorted, so a new sample file is+ /// covered automatically.+ private static func sampleNames() -> [String] {+ let dir = ParityFixtureSupport.samplesDirectory()+ let contents = (try? FileManager.default.contentsOfDirectory(+ at: dir, includingPropertiesForKeys: nil+ )) ?? []+ return contents+ .filter { $0.pathExtension == "md" }+ .map { $0.deletingPathExtension().lastPathComponent }+ .sorted()+ }++ /// Finds whether some `marker` substring occurs inside any `<li>…</li>` element of the+ /// emitted HTML — i.e. an `<li>` open tag appears before the marker and the matching+ /// `</li>` appears after it, with no intervening `</li>` between the open `<li>` and the+ /// marker (a robust, parser-free containment check used for the Req 1.x list-embed ACs).+ private func liContains(_ html: String, _ marker: String) -> Bool {+ var searchStart = html.startIndex+ while let liOpen = html.range(of: "<li", range: searchStart..<html.endIndex) {+ // Find the end of this <li> element. Lists can nest, so balance <li>/</li>.+ guard let firstClose = html.range(+ of: "</li>", range: liOpen.upperBound..<html.endIndex+ ) else { break }+ let liBody = html[liOpen.upperBound..<firstClose.lowerBound]+ if liBody.contains(marker) {+ return true+ }+ searchStart = liOpen.upperBound+ }+ return false+ }++ // MARK: - 1. Every sample parses + emits without error, non-empty (Req 4.1)++ @Test("Every samples/*.md parses and emits non-empty HTML without error")+ func everySampleEmits() throws {+ let names = Self.sampleNames()+ #expect(!names.isEmpty, "no samples/*.md files found")+ for name in names {+ let html = try emitSample(name)+ #expect(+ !html.isEmpty,+ "sample '\(name)': emitted HTML is empty"+ )+ // A real emitted document always carries the document scaffold.+ #expect(+ html.contains("data-prism-document"),+ "sample '\(name)': emitted HTML missing the document scaffold"+ )+ }+ }++ // MARK: - 2. Nested + multi-paragraph blockquotes (Req 3.1/3.3)++ @Test("blockquotes-and-nesting.md emits a nested <blockquote> (Req 3.3)")+ func nestedBlockquote() throws {+ let html = try emitSample("blockquotes-and-nesting.md")+ // A nested quote produces a <blockquote> directly containing another <blockquote>.+ // Robust check: either the open tags are adjacent, or a blockquote open appears+ // before its matching close with a second open in between (nesting), and never+ // merely two sibling top-level quotes.+ let directlyNested = html.contains("<blockquote><blockquote>")+ let nestedWithinBody = blockquoteNestsWithinBody(html)+ #expect(+ directlyNested || nestedWithinBody,+ "expected a nested <blockquote> within a <blockquote>"+ )+ }++ @Test("blockquotes-and-nesting.md emits multiple <p> inside one <blockquote> (Req 3.1)")+ func multiParagraphBlockquote() throws {+ let html = try emitSample("blockquotes-and-nesting.md")+ #expect(+ blockquoteWithMultipleParagraphs(html),+ "expected a <blockquote> containing more than one <p>"+ )+ }++ /// True when some `<blockquote>` opens, then a second `<blockquote>` opens before the+ /// first one's matching `</blockquote>` — structural nesting, not two siblings.+ private func blockquoteNestsWithinBody(_ html: String) -> Bool {+ var depth = 0+ var maxDepth = 0+ var index = html.startIndex+ while index < html.endIndex {+ if html[index...].hasPrefix("<blockquote>") {+ depth += 1+ maxDepth = max(maxDepth, depth)+ index = html.index(index, offsetBy: "<blockquote>".count)+ } else if html[index...].hasPrefix("</blockquote>") {+ depth = max(0, depth - 1)+ index = html.index(index, offsetBy: "</blockquote>".count)+ } else {+ index = html.index(after: index)+ }+ }+ return maxDepth >= 2+ }++ /// True when some `<blockquote>…</blockquote>` span (innermost, non-nesting) contains+ /// two or more `<p>` tags.+ private func blockquoteWithMultipleParagraphs(_ html: String) -> Bool {+ var searchStart = html.startIndex+ while let open = html.range(of: "<blockquote>", range: searchStart..<html.endIndex) {+ guard let close = html.range(+ of: "</blockquote>", range: open.upperBound..<html.endIndex+ ) else { break }+ let body = html[open.upperBound..<close.lowerBound]+ // Skip spans that themselves contain a nested blockquote — count <p> only in a+ // quote span without a further nested quote so the count is per-level.+ if !body.contains("<blockquote>") {+ let paragraphCount = body.components(separatedBy: "<p>").count - 1+ if paragraphCount >= 2 { return true }+ }+ searchStart = open.upperBound+ }+ return false+ }++ // MARK: - 3. Ordered-list start number, top-level and nested (Req 2.1/2.4)++ @Test("lists-edge-cases.md emits <ol start=\"N\"> for top-level and nested lists (Req 2.1/2.4)")+ func orderedListStart() throws {+ let html = try emitSample("lists-edge-cases.md")+ // Top-level: the "Maximum valid starting number" item (`999999999.`, a single-item+ // list) carries a non-1 start. The "Starts at zero"/"Starts at three" groups do NOT —+ // per Decision 6 they merge with the leading `1.` group into one list renumbered from+ // the first item's start of 1 (no start attribute).+ #expect(+ html.contains("<ol start=\""),+ "expected a top-level <ol start=\"N\"> for a non-1 ordered list"+ )+ // Nested: "Inner ordered item 1/2" inside an unordered outer item. Its <ol> open+ // tag must occur inside an <li>.+ #expect(+ liContains(html, "<ol"),+ "expected a nested <ol> inside an <li> (nested ordered list)"+ )+ }++ @Test("A nested ordered list with first item != 1 emits <ol start=\"N\"> inside its <li> (Req 2.4)")+ func nestedOrderedListStartNonOne() throws {+ // An outer ordered list whose item nests an ordered list starting at 5. The nested+ // <ol> must carry start="5" (Req 2.4) and that start must appear INSIDE an <li> (it is+ // the nested list), not just at top level. CommonMark forbids a non-1 ordered list from+ // interrupting a paragraph, so the blank line + 4-space indent makes the `5.`/`6.` a+ // real nested ordered list (verified against swift-markdown: OrderedList startIndex=5).+ let markdown = """+ 1. Outer item++ 5. Nested five+ 6. Nested six+ """+ let html = emit(markdown)+ #expect(+ html.contains("<ol start=\"5\">"),+ "expected the nested ordered list to emit <ol start=\"5\">"+ )+ #expect(+ liContains(html, "<ol start=\"5\">"),+ "expected the nested <ol start=\"5\"> to appear inside an <li>"+ )+ }++ @Test("A nested ordered list starting at 1 omits the start attribute (Req 2.4)")+ func nestedOrderedListStartOneOmitted() throws {+ // The nested ordered list starts at 1, the HTML default, so its <ol> open tag must+ // NOT carry a start attribute (Req 2.4 — only non-1 starts are emitted).+ let markdown = """+ 1. Outer item+ 1. Nested one+ 2. Nested two+ """+ let html = emit(markdown)+ // No <ol> tag anywhere carries a start attribute (neither the outer start-1 list nor+ // the nested start-1 list).+ #expect(+ !html.contains("<ol start="),+ "expected no start attribute on a start-1 ordered list (nested or top-level)"+ )+ // Sanity: the nested ordered list is still emitted inside an <li>.+ #expect(+ liContains(html, "<ol>"),+ "expected the nested ordered list (<ol>) inside an <li>"+ )+ }++ // MARK: - 4. Rich blocks inside list items (Req 1.1/1.3/1.4)++ @Test("A sample with a table in a list item emits <table> inside an <li> (Req 1.1)")+ func tableInsideListItem() throws {+ // mixed-complex.md item 1 and tables-and-gfm.md both embed a table in a list item.+ let mixed = try emitSample("mixed-complex.md")+ let tables = try emitSample("tables-and-gfm.md")+ #expect(+ liContains(mixed, "<table>") || liContains(tables, "<table>"),+ "expected a <table> inside an <li> (table in a list item)"+ )+ }++ @Test("list-with-code-blocks.md emits a code block and a mermaid container inside an <li> (Req 1.3/1.4)")+ func codeAndMermaidInsideListItem() throws {+ let html = try emitSample("list-with-code-blocks.md")+ #expect(+ liContains(html, "<pre><code class=\"language-"),+ "expected a <pre><code class=\"language-…\"> inside an <li> (code block in a list item)"+ )+ #expect(+ liContains(html, "data-prism-mermaid"),+ "expected a mermaid container (data-prism-mermaid) inside an <li> (mermaid in a list item)"+ )+ }++ // MARK: - 5. Mixed-content list item ordering (Req 1.5)++ @Test("mixed-complex.md item 1 embeds blockquote + pre + table + nested list in one <li> (Req 1.5)")+ func mixedContentListItem() throws {+ let html = try emitSample("mixed-complex.md")+ // The "Lists with Complex Content" item 1 contains, in source order, a blockquote,+ // a code block, a table, and a nested unordered list — each must survive inside an+ // <li>.+ #expect(liContains(html, "<blockquote>"), "expected a <blockquote> inside an <li>")+ #expect(liContains(html, "<pre>"), "expected a <pre> inside an <li>")+ #expect(liContains(html, "<table>"), "expected a <table> inside an <li>")+ #expect(liContains(html, "<ul>"), "expected a nested <ul> inside an <li>")+ }++ // MARK: - 6. Image inside a list item (committed fixture, Req 1.2/4.3)++ @Test("image-in-list fixture emits an image element inside an <li> (Req 1.2/4.3)")+ func imageInsideListItem() throws {+ let html = try emitFixture("Samples/image-in-list.md")+ // A top-level image emits `data-prism-image` with a `prism-doc://img/?src=` rewrite;+ // the same element must appear inside an <li> for the list-embedded image.+ #expect(+ liContains(html, "data-prism-image"),+ "expected an image element (data-prism-image) inside an <li>"+ )+ #expect(+ liContains(html, "prism-doc://img/?src="),+ "expected the rewritten image src (prism-doc://img/?src=) inside an <li>"+ )+ }++ // MARK: - 7. No leaked Markdown markers in rendered text (Req 4.4)++ @Test("Inner blockquote levels do not leak a literal > marker into rendered text (Req 4.4)")+ func noLeakedQuoteMarker() throws {+ let html = try emitSample("blockquotes-and-nesting.md")+ let text = BlockHTMLEmitterTestSupport.normalisedText(html)+ // Specific inner-quote lines from the sample. Their rendered text must NOT carry a+ // leading `>` carried from the source nesting markers (e.g. "> Level 2").+ for marker in ["Level 2", "Level 3", "Inner quote"] {+ #expect(+ !text.contains("> \(marker)"),+ "inner quote text '\(marker)' leaked a literal '>' marker into rendered text"+ )+ }+ }++ @Test("Ordered-list items do not leak their N. marker into rendered text (Req 4.4)")+ func noLeakedOrderedMarker() throws {+ let html = try emitSample("lists-edge-cases.md")+ let text = BlockHTMLEmitterTestSupport.normalisedText(html)+ // Known ordered-list item texts whose rendered visible text must not be prefixed by+ // the source numeral marker (e.g. "0. Starts at zero").+ let leaked = [+ "0. Starts at zero",+ "3. Starts at three",+ "1. Starts at one"+ ]+ for marker in leaked {+ #expect(+ !text.contains(marker),+ "ordered-list item leaked its source marker '\(marker)' into rendered text"+ )+ }+ }++ // MARK: - 8. Section-less nested lists carry no note anchor (Req 5.3)++ @Test("A list nested in a blockquote emits no data-prism-sub item anchor, but a top-level list does (Req 5.3)")+ func nestedListInBlockquoteHasNoItemAnchor() throws {+ // A blockquote containing a list (rendered via renderInnerBlock) is section-less and+ // must carry no note anchor (Req 5.3): its <li> items must NOT emit data-prism-sub,+ // else prism-notes.js injects spurious per-item "+" affordances and suppresses the+ // blockquote's own block-level "+". A TOP-LEVEL list keeps its per-item anchors.+ let markdown = """+ Top-level list:++ - Top item one+ - Top item two++ > A quote with a list:+ >+ > - Quoted item one+ > - Quoted item two+ """+ let html = emit(markdown)++ // The top-level list still carries per-item note anchors.+ #expect(+ html.contains("data-prism-sub=\"item-0\""),+ "expected a top-level list item to carry data-prism-sub=\"item-0\""+ )++ // No item anchor appears inside any <blockquote>…</blockquote> span.+ #expect(+ !blockquoteContainsItemAnchor(html),+ "a list nested inside a <blockquote> must not carry data-prism-sub item anchors (Req 5.3)"+ )+ }++ @Test("blockquotes-and-nesting.md: no list item inside a <blockquote> carries an item anchor (Req 5.3)")+ func sampleNestedListInBlockquoteHasNoItemAnchor() throws {+ // The "With Lists" section nests `- Item one` / `1. First` lists inside blockquotes.+ let html = try emitSample("blockquotes-and-nesting.md")+ #expect(+ !blockquoteContainsItemAnchor(html),+ "a list nested inside a <blockquote> must not carry data-prism-sub item anchors (Req 5.3)"+ )+ }++ /// True when any `<blockquote>…</blockquote>` span (at any nesting depth) contains a+ /// `data-prism-sub="item-` attribute — i.e. a section-less nested list leaked an item+ /// note anchor.+ private func blockquoteContainsItemAnchor(_ html: String) -> Bool {+ var searchStart = html.startIndex+ while let open = html.range(of: "<blockquote>", range: searchStart..<html.endIndex) {+ guard let close = html.range(+ of: "</blockquote>", range: open.upperBound..<html.endIndex+ ) else { break }+ if html[open.upperBound..<close.lowerBound].contains("data-prism-sub=\"item-") {+ return true+ }+ searchStart = open.upperBound+ }+ return false+ }+}
diff --git a/CLAUDE.md b/CLAUDE.mdindex aef2ce4..9d7a9fa 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -40,23 +40,32 @@ xcodebuild test -project prism.xcodeproj -scheme prism \ 1. `PrismApp` uses `WindowGroup` with a manual file importer (not `DocumentGroup` due to iOS 26 issues) 2. `MarkdownDocument` (FileDocument) validates and loads markdown content (10MB limit, UTF-8 only) 3. For remote URLs, `URLDocumentLoader` downloads content via ephemeral URLSession with streaming (10MB limit), and `GitHubURLTransformer` converts GitHub blob URLs to raw URLs-4. `DocumentReaderView` selects a layout (`CompactDocumentLayout` or `RegularDocumentLayout`) that parses content into blocks via `MarkdownBlockParser` and renders them in a container chosen by `DocumentRenderPolicy` (see Rendering note below)+4. `DocumentReaderView` selects a layout (`CompactDocumentLayout` or `RegularDocumentLayout`) that parses content into blocks via `MarkdownBlockParser` and renders them through the WebKit document path (`WebDocumentView`/`WebDocumentController` over `BlockHTMLEmitter`; see Markdown Rendering below) 5. `FileChangeObserver` (NSFilePresenter) monitors files and shows `ReloadBanner` on external changes; URL-sourced documents use a manual Refresh button instead -### Markdown Rendering-- **Parsing**: `swift-markdown` parses into AST, converted to `MarkdownBlock` enum variants-- **Rendering**: Textual fork renders standard blocks with search highlighting; custom views for code blocks, mermaid, and images-- **Search highlighting**: Uses `SearchHighlightApplicator` from Textual to apply background colors to matching text-- **Block container**: `DocumentScrollContent` picks the container via `DocumentRenderPolicy` (T-1531, Decision 6). macOS: documents ≤ 250 parsed blocks render in an eager `VStack` (lazy re-measurement stalls scrolling on documents made of a few huge rows — giant nested lists — selection on or off, Release included); larger documents render in a `LazyVStack` (eager layout made them unloadable, PR #286). iOS is always lazy. Selection is enabled once at the container in both cases (Textual's `textSelection` environment defaults to disabled; the `<details>` summary row pins `.disabled` to stay a pure tap target), and on macOS the container is wrapped in the Textual fork's `.textual.textSelectionScope()` (T-1513): selection machinery is hosted once at document level (one model, one AppKit interaction overlay, one highlight canvas), because per-block selection machinery inside a `LazyVStack` never reached a layout fixed point. iOS keeps per-block selection (that loop was AppKit-specific). Design and history: `specs/selection-layout-decoupling/design.md`; scripted regression check: `Tools/convergence-probe.sh` (including a `--manual` mode that arms the stall watchdog while a human scrolls). Blocks keep stable content-based IDs.+### Markdown Rendering (WebKit document path, T-1542)++The document is rendered by WebKit-for-SwiftUI (`WebView`/`WebPage`). The SwiftUI/Textual in-flow renderer was retired in the T-1542 cutover — there is no legacy path or render-policy switch any more (see the CHANGELOG `[Unreleased]` "Changed" entry). Native stays the source of truth: parsing, search counting, notes logic, and persistence all run natively; the web view only renders HTML and reports interactions back over the bridge.++1. **Parse**: `swift-markdown` → AST → `MarkdownBlock` enum variants (`MarkdownBlockParser`), unchanged from before. T-1558 made the model lossless for nested blockquotes, ordered-list `start`, and rich blocks inside list items (see `specs/web-markdown-fidelity/`).+2. **Emit**: `BlockHTMLEmitter` (`prism/Services/WebRendering/`) is a pure, deterministic function of `[MarkdownBlock]` + `FootnoteData` + `RenderSettings`. It emits one `<section>` per block carrying the content-hash block identity and an occurrence-qualified DOM id (`b-{hash}-{sourceIndex}`, allocated via the shared `BlockDOMID`), escapes by default, and is total (a block that fails to emit falls back to escaped-source `<pre>`, never dropped). `InlineHTMLRenderer` wraps mappable text runs in `<span data-prism-run>` and records a `DocumentSourceMap` (UTF-16 offsets, shipped as an inert `<div hidden>` data island) for selection-anchored notes.+3. **Serve**: `PrismDocSchemeHandler` (`prism-doc://` `URLSchemeHandler`) is the single audited I/O path — it serves the document HTML, `document.css` (the only asset fetched through the scheme), and mediates every image subresource through `/img/?src=` (rewritten absolute/relative URLs routed via `ImagePathResolver`/`ImageLoader`/`SVGSourceLoader`). It serves the verbatim CSP (`script-src 'none'`, `connect-src 'none'`, …) as a response header. The document is loaded via the scheme, never `loadHTMLString`.+4. **Host + bridge**: `WebDocumentController`/`WebDocumentView` (`prism/ViewModels/`, `prism/Views/`) own one `WebPage` per session (non-persistent store, `allowsContentJavaScript = false`, all JS injected as user scripts via `WebDocumentControllerFactory.userScripts()`). The native↔JS bridge runs in a dedicated isolated `WKContentWorld`; every message in both directions carries a generation tag (sessionID, parseRevision, processGeneration). `BridgeMessageRouter` validates inbound messages against an enumerated allowlist + exact-generation match and drops forged/stale/malformed ones; `WebDocumentMessageRouter` routes accepted messages onto the existing native session/coordinator state. Outbound commands queue until `ready`; scroll restore waits for `layoutSettled`; on WebContent termination the controller bumps the process generation, reloads, and replays one coalesced state snapshot.+5. **Notes**: `NoteStateFeeder` (`prism/Services/WebRendering/`) maps `NotesManager` state onto `setNoteIndicators`/`setInlineNotes` payloads; `NoteHTMLBuilder` renders the (escaped) bubble/banner HTML natively; `prism-notes.js` (isolated world) injects it as `data-prism-chrome` and posts interaction messages back.+6. **Search**: counts and navigation order stay in `SearchService`/`SearchCoordinator`. `SearchStateFeeder` translates that into a per-block `setSearchState` payload; `prism-search.js` re-finds the query in each block's rendered text and registers ranges on two named **CSS Custom Highlights** (`prism-search`, `prism-search-current`), windowed to the viewport. The web view's built-in find navigator stays disabled so Cmd+F routes to Prism's search.+7. **Security**: `HTMLSanitizer` (over SwiftSoup) reduces raw HTML embedded in markdown to an allowlist subset on load (Req 1.8/8.1); its `plainText` feeds searchable text. Combined with `allowsContentJavaScript = false` and the served CSP, active-content vectors are blocked by construction.+8. **In-page render libraries**: mermaid.js and highlight.js (+ their thin Prism drivers) run in the **page world** (they only render DOM, never post bridge messages). The bundled JS lives in `prism/Resources/WebRenderer/`.++Implementation history and gotchas (the `<script>` data-island CSP trap, the native selection overlay, the live-test harness): `docs/agent-notes/webview-rendering-status.md`. Spec: `specs/webview-rendering/`. ### Footnote System 1. `FootnotePreprocessor` extracts `[^id]: content` definitions from raw markdown before `swift-markdown` parsing, producing cleaned source + `FootnoteData` sidecar 2. `MarkdownBlockParser.parseWithFootnotes()` integrates the preprocessor into the parsing pipeline; `parseFragment()` renders footnote content in popovers without the full pipeline 3. `DocumentSession` stores `FootnoteData` alongside parsed blocks; both are bundled in `ParseResult`-4. `HighlightedInlineText` receives `FootnoteData` via environment and configures a `.footnoteReferences(provider:)` syntax extension on Textual's `InlineText`-5. Textual's `PatternProcessor` replaces `[^id]` tokens with styled pill badges (via `FootnoteReferenceAttribute` + `FootnoteProperty`) and link attributes for tap handling-6. Badge taps produce `prism://footnote/{id}` URLs intercepted by the `openURL` handler in `DocumentReaderView`, which routes to `DocumentLayoutCoordinator` popover state-7. `FootnotePopoverView` displays rendered footnote content (paragraphs, lists, blockquotes only) in a popover (iPad/macOS) or sheet (iPhone)+4. `BlockHTMLEmitter` replaces each resolvable `[^id]` reference in the emitted HTML with a styled pill-badge anchor (`prism://footnote/{id}`, the display number from `FootnoteData`) — footnote chrome, not document text+5. The badge anchor carries `data-prism-chrome` so it is not selectable; the badge's display number and id come from the `FootnoteData` sidecar threaded into the emitter+6. Badge taps produce `prism://footnote/{id}` links → `linkActivated` over the bridge → `WebDocumentMessageRouter` → `DocumentLayoutCoordinator` popover state (`PrismLinkRoute.footnotePrefix`)+7. `FootnotePopoverView` displays rendered footnote content (paragraphs, lists, blockquotes only) over a shared, non-persistent `FootnotePopoverWebPage` (emitter-rendered fragment) in a popover (iPad/macOS) or sheet (iPhone) 8. `FootnoteStripping` removes `[^id]` references from heading text used in ToC entries, anchor IDs, and window titles 9. Search integration: `searchableText(with:)` appends footnote content to host blocks; badge highlighting indicates matches @@ -110,26 +119,15 @@ WKWebView on macOS requires the `com.apple.security.network.client` entitlement ## Key Dependencies -- **swift-markdown** (0.7.3): Markdown parsing to AST-- **Textual** (fork at [github.com/arjenschwarz/textual](https://github.com/arjenschwarz/textual)): SwiftUI markdown rendering with search highlighting. Pinned to a specific commit for reproducibility.-- **HighlightSwift** (1.1.0): Syntax highlighting for code blocks-- **mermaid.js** (11.12.2): Bundled for offline diagram rendering+SPM dependencies (the Textual fork and HighlightSwift were dropped in the T-1542 cutover): -### Textual Fork Details--The Textual dependency is a fork of [gonzalezreal/textual](https://github.com/gonzalezreal/textual) with extensions for search highlighting and footnote rendering:--- **SearchHighlightAttribute**: Custom AttributedString attribute for marking search match ranges-- **SearchHighlightApplicator**: Utility to search rendered text and apply highlight attributes-- **SearchHighlightProperty**: TextProperty that applies background colors based on theme-- **FootnoteReferenceAttribute**: Custom AttributedString attribute marking footnote reference ranges (identifier, displayNumber, hasSearchMatch)-- **FootnoteProperty**: TextProperty that applies pill-badge styling (background, foreground, baselineOffset) from environment-- **FootnoteEnvironment**: Environment keys for badge colours (`footnoteBadgeBackground`, `footnoteBadgeForeground`)-- **SyntaxExtension.footnoteReferences(provider:)**: Factory that replaces `[^id]` with styled, tappable badges via `FootnoteDataProvider` protocol+- **swift-markdown** (0.7.3): Markdown parsing to AST+- **SwiftSoup** (2.13.x): HTML sanitization for raw HTML embedded in markdown (`HTMLSanitizer`) -These extensions enable word-level search highlighting and footnote badge rendering that work correctly across markdown formatting boundaries.+Bundled (offline) web-render assets in `prism/Resources/` — injected as user scripts in the page world, never fetched from the network: -The fork is pinned to a specific commit (not `branch: main`) to ensure reproducible builds.+- **highlight.js** (11.9.0): in-page syntax highlighting for code blocks+- **mermaid.js** (classic bundle, 11.x, `securityLevel: 'strict'`): in-page diagram rendering ## Source Structure
The full make test-quick / make test-locales wedge the test daemon on this machine (they run the suite ×4 locales). Targeted per-class runs pass; the full suite + on-device verification should run in CI / a free machine before/after push.
The Mermaid Diagram System, Image Rendering System, and Source Structure sections of CLAUDE.md still describe deleted cutover components. Worth a dedicated reconciliation pass.