prism branch T-450/render-html-comments commits ahead 20 files 50 touched lines +4824 / -127

Pre-push review: T-450 Render HTML Comments

17 implementation commits + 2 phase merges + 1 review-fix commit + 1 changelog/spec-bookkeeping commit. 50 files, +4824/-127. Branch unpushed.

At a glance

  • New feature: opt-in Settings toggle (Show HTML comments). Default OFF — comments are hidden entirely; ON — comments render as italic dimmed text prefixed by info.circle SF Symbol both as standalone blocks and inline.
  • Behaviour change: the toggle's OFF default differs from today's raw-HTML fall-through that leaked <!-- markers into rendered body text. Decision 3 accepts this — Prism has not shipped to the App Store.
  • Cross-repo work: Textual fork PR merged at arjenschwarz/textual@5c9f477 introducing SyntaxExtension.htmlComments(visible:appearance:); Prism's Package.swift pin bumped.
  • Pattern audit: every existing exhaustive switch over MarkdownBlock gained an explicit .htmlComment case (no default: hiding). Search/copy/export integrations thread the toggle through.
  • Note infrastructure invariant: stripCommentTags still removes <!-- comment:HASH --> markers before swift-markdown parses; HTMLCommentParser defensively rejects those bodies too. testCommentInfrastructureTagsNeverReachHTMLCommentParser pins the ordering.

Verdict

Ready to push

All 22 spec tasks implemented and committed. Four parallel review agents (code reuse, code quality, efficiency, spec adherence) ran in phase 3; the highest-stakes finding — a Req 6.2 leak in the copy-all-notes path — was validated and fixed with regression tests. Six other findings were fixed (regex centralisation, regex compile per-call in two helpers, accessibility-label catalog-lookup bug, accessibility composer colour drift). Lint clean (0 violations), both make build-ios and make build-macos clean. The macOS test runner and iOS simulator both died with the project's known environmental flakes during the post-fix test run, so the full make test-quick could not run end-to-end. Targeted suites pass in isolation. Worth a clean re-run of make test before merging the PR.

Review findings

11 raised · 6 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

What Changed / What This Does

Markdown files can have hidden notes written like <!-- this is a note -->. Until now, Prism showed these notes as plain text with the brackets visible — readers saw <!-- and --> cluttering the prose. This change adds a Settings switch called Show HTML comments under a new Markdown Rendering section. The switch is off by default; turning it on renders comments as dimmed italic text with a small info icon in front, both as standalone blocks and inline inside paragraphs. Search, copy, and export honour the switch too. Flipping the switch updates the open document instantly.

Why It Matters

HTML comments are how authors leave private side-notes in markdown (TODO: revisit, citation needed). Prism's old behaviour leaked those private notes into the reading experience. Hiding them by default respects author intent; making them opt-in lets curious readers see what the author was thinking without forcing it on everyone.

Key Concepts

  • HTML comment — a <!-- ... --> snippet that HTML browsers treat as invisible; some markdown viewers leak it.
  • Block-level vs inline — a block comment lives on its own line; inline lives inside a sentence (foo<!--note-->bar).
  • Toggle / preference — a user setting saved persistently per app install.
  • Re-render — refresh how a parsed document is shown on screen without re-reading the file.

Changes Overview

50 files, +4824/-127. The change splits into four layers:

  1. Parse-time helpers (prism/Services/): HTMLCommentParser.parseBlock accepts comment-only HTML blocks (rejects mixed, conditional, CDATA, DOCTYPE, note-infrastructure); HTMLCommentStripping.strip removes markers and collapses whitespace; MarkdownBlockParser.stripCommentsInsideLinkLabels strips only from inside […] link labels.
  2. Model variant: new MarkdownBlock.htmlComment(rawText:) case wired through every exhaustive switch. New SearchContext { showHTMLComments, footnoteData } drives the new searchableText(in:) canonical method; legacy searchableText / searchableText(with:) remain as shims. New ordinalOffsetForList(at:in:) resumes ordered-list numbering across an intervening comment block.
  3. Textual fork extension (pinned at 5c9f477): SyntaxExtension.htmlComments(visible:appearance:), HTMLCommentAppearance, HTMLCommentRangeAttribute, and a shared PatternProcessor.isInsideCodeSpan helper. Operates on the post-parse AttributedString; skips code spans; replaces matched ranges with SF Symbol + styled run when visible, empty when hidden, always tags with HTMLCommentRangeAttribute.
  4. Rendering, search, export, accessibility: HTMLCommentBlockView for block-level. HighlightedInlineText adds the Textual extension unconditionally; reorders updateHighlights so the extension transforms the string before search highlighting; extends TaskIdentifier with showHTMLComments. SearchCoordinator.recomputeAfterVisibilityChange() rebuilds match totals on toggle. HTMLCommentExport.flatten handles plain-text export. HTMLCommentAccessibilityModifier composes a paragraph-scoped Comment … End comment boundary label.

Implementation Approach

Parse-time vs render-time split — author content is parsed once into blocks; toggling the preference never re-runs the swift-markdown AST parse (Req 9.2). The toggle is read at render time by HighlightedInlineText, HTMLCommentBlockView, and MarkdownBlock.searchableText(in:). The implementation mirrors the existing footnote system one-for-one (parser, stripping helper, Textual extension, accessibility modifier, shared regex). One HTMLCommentStripping.inlineCommentRegex powers every <!--…--> scan after the post-review centralisation.

Trade-offs

Mixed-content HTML blocks pass through unchanged (Decision 10); splitting them would require synthetic block boundaries. The raw-HTML fall-back now runs through HTMLCommentStripping so the toggle-off promise holds for mixed content (Decision 16). List-item continuity uses an ordinal offset (Decision 17) rather than lifting siblings into the parent list — preserves the one-AST-node-per-block invariant. Link-label stripping happens at parse time, not in the Textual extension (Decision 18), because Textual's pattern processor doesn't expose link-range context.

Technical Deep Dive

  • Note-tag invariant (Decision 20). stripCommentTags removes <!-- comment:HASH --> markers before swift-markdown parses. HTMLCommentParser.parseBlock also rejects bodies matching ^\s*/?comment:[a-f0-9]+\s*$ as defence-in-depth. testCommentInfrastructureTagsNeverReachHTMLCommentParser pins the ordering so a future refactor can't surface internal note hashes as visible Comment annotations.
  • Heading symmetry (Decision 19). Headings treat HTML comments the same as paragraphs for search: strip markers from base, append inner text when ON. ToC entries, anchor IDs, and window titles use FootnoteStripping(HTMLCommentStripping(text)) unconditionally — stable navigation surfaces.
  • Inline accessibility composer. HTMLCommentAccessibilityModifier parses the markdown via the same pipeline (footnote + htmlComments extensions) and walks the resulting AttributedString for HTMLCommentRangeAttribute runs. Emits one Comment at the start of the first comment span and one End comment after the last (Decision 14). After the review fix, it shares the parent's htmlCommentAppearance so the accessibility-side appearance cannot drift from the rendering appearance.
  • Search-task identifier. HighlightedInlineText.TaskIdentifier includes showHTMLComments; toggling flips the identifier and SwiftUI re-runs the .task(id:) that recomputes highlightedContent + matchCount. SearchCoordinator.recomputeAfterVisibilityChange() handles document-level totals (Req 5.4).
  • List-continuity offset. ordinalOffsetForList(at:in:) is an O(1) probe of blocks[blockIndex-2] and blocks[blockIndex-1]. ListBlockView + SharedBlockViews thread the offset to ordered-list rendering; unordered lists ignore it.

Architecture Impact

No new global state — single @AppStorage("showHTMLComments") Bool on AppSettings. Existing views that already injected @Environment(AppSettings.self) (HighlightedInlineText) reuse the same injection point. Search pipeline migration is additive — legacy shims keep compiling. Notes/export integration threads showHTMLComments through every export route, including the previously-overlooked copy-all-notes path (review finding fix), so context quotes saved at note-creation time get filtered via HTMLCommentExport.flatten.

Edge Cases / Potential Issues

  • Conditional comments, CDATA, DOCTYPE (Decision 21) — explicitly rejected by HTMLCommentParser.parseBlock; fall through to the raw-HTML path with HTMLCommentStripping applied.
  • Comments inside fenced/indented code blocks never reach HighlightedInlineText; the code path uses HighlightSwift. Inline code spans skip via PatternProcessor.isInsideCodeSpan so `<!--x-->` survives literally.
  • Author content as a format key — HTMLCommentBlockView uses String(localized: "Comment, \(rawText)") (catalog-aware) for the accessibility label after the review fix; the rendered text uses Text(verbatim: rawText) to avoid accidental catalog lookup on content like %d items.
  • Performance — HTMLCommentParser's four regexes are static let after the review fix; HTMLCommentStripping.strip uses precompiled regexes via stringByReplacingMatches. HighlightedInlineTextRenderBench asserts sub-quadratic per-render scaling at N ∈ {0, 10, 100} inline comments.
  • Req 6.4 (rich export uses on-screen styling) is vacuously true — Prism has no rich-text export path. Should be tracked in the decision log as deferred.

Important changes — detailed

HTMLCommentParser.parseBlock — comment-only HTML block detection

prism/Services/HTMLCommentParser.swift

Why it matters. The new parser is the gatekeeper for the new MarkdownBlock variant. Misclassifications would either leak mixed-content HTML into the styled path (wrong visual treatment) or hide author comments under the raw-HTML fall-back (toggle ON appears broken). The four precompiled regexes — structural validator, content extractor, note-infrastructure body reject, conditional-body reject — encode the full Decision 7/10/20/21 grammar.

What to look at. prism/Services/HTMLCommentParser.swift:30-146

Takeaway. Use `(?:(?!-->).)*?` instead of `[\s\S]*?` for HTML comment bodies — the former cannot accidentally swallow `-->` runs and turn `<!--a--> x <!--b-->` into a single comment with body `a--> x <!--b`. Hoist regexes to `static let` so a parser called per HTML block compiles once.
Rationale. Per Decision 7, edge-case rules (empty, malformed, conditional, mid-word) must be enforced consistently. The single-source structural regex + the per-body rejection lets one validator cover every shape; the four regexes correspond directly to Decisions 7, 10, 20, 21.

MarkdownBlock.htmlComment case + exhaustive switch audit

prism/Models/MarkdownBlock.swift

Why it matters. Every existing exhaustive switch over MarkdownBlock now has an explicit `.htmlComment` arm — no `default:` hiding the new case. The design's Pattern Audit table lists 18+ sites; each got a deliberate decision (supportsNotes false, contentForHashing prefix, debugTypeName "Comment", textContent returns rawText, navigation surfaces ignore, etc.).

What to look at. prism/Models/MarkdownBlock.swift:246-diff

Takeaway. When adding a new variant to a discriminated enum, audit every exhaustive switch in one PR rather than relying on the compiler's exhaustivity check to surface them piecemeal — `default:` cases hide future-you from the same audit.
Rationale. The codebase already has 11 MarkdownBlock variants; using `default:` for `.htmlComment` would have made the new variant invisible to readers of consumer files. Explicit cases force every consumer to declare intent (route, ignore, route-and-strip).

HighlightedInlineText — extension wiring, search reorder, TaskIdentifier

prism/Views/HighlightedInlineText.swift

Why it matters. The order in `updateHighlights` matters: parse markdown with the full extension list FIRST (so the htmlComments extension transforms `<!--...-->` ranges into styled runs or empty), THEN run SearchHighlightApplicator on the transformed AttributedString. Reversing the order would either give spurious search ranges on hidden comments or skip search highlights inside visible comments. `TaskIdentifier` includes `showHTMLComments` so toggling refreshes the highlighted AttributedString + match count mid-search.

What to look at. prism/Views/HighlightedInlineText.swift:71-160, 264-280

Takeaway. When a search highlighter sits downstream of a markup transformation, the transformation MUST run first — otherwise the highlight ranges become stale by the time the user sees the rendered string.
Rationale. Req 5.3 demands `Search results match what the user sees`. The transform-then-highlight order is the only way to honour that when the toggle can swap the rendered string between OFF (markers gone) and ON (styled runs present).

NotesExporter — Req 6.2 leak fix in copy-all-notes path

prism/Services/NotesExporter.swift

Why it matters. Surfaced by the spec-adherence review agent. Context quotes (`BlockNote.contextQuote`) are snapshots of `block.textContent` at note-creation time. For `.paragraph(markdown:)` that includes any `<!--…-->` markers present in the source. The original implementation wired `showHTMLComments` through `exportWithInlineNotes` (share / file export) but NOT through the copy-all-notes path (`SidebarNotesView` / `NotesPanel` → `NotesManager.exportMarkdown` → `NotesExporter.export`). With the toggle OFF and a note on a paragraph containing a comment, the comment text leaked into the clipboard — Req 6.2 violation.

What to look at. prism/Services/NotesExporter.swift:26-180, prism/Services/NotesManager.swift:938, prism/Views/SidebarNotesView.swift:35, prism/Views/NotesPanel.swift:40

Takeaway. When adding a visibility toggle to a render-time feature, audit EVERY export/copy/share path that walks document content — context-quote snapshots saved before the feature existed will still carry the gated content. Apply the filter at export time on the persisted content, not just on the live render.
Rationale. Req 6.2 says comment text must NOT appear in any copy, share, or export output when the toggle is off — `any` is exhaustive. Threading the toggle through `NotesExporter.export` and applying `HTMLCommentExport.flatten` to the context-quote line covers both pre-existing and freshly-authored notes.

Textual fork — SyntaxExtension.htmlComments(visible:appearance:)

Package.resolved

Why it matters. The cross-repo work that unblocks inline HTML comment rendering. PR landed at `arjenschwarz/textual@5c9f477`. Adds the syntax extension, `HTMLCommentAppearance` value, `HTMLCommentRangeAttribute`, a shared `PatternProcessor.isInsideCodeSpan` helper, and a new `processesInsideInlineHTML` opt-in on PatternTokenizer so the extension can transform `<!--…-->` runs that Foundation's Markdown parser places inside `inlineHTML`. The Prism worktree only sees the pin bump in <code>project.pbxproj</code> / <code>Package.resolved</code>.

What to look at. prism.xcodeproj/project.pbxproj, Package.resolved

Takeaway. When a Textual extension needs to operate inside inline-HTML runs (which Foundation's parser groups together rather than splitting by markup), opt the pattern into `processesInsideInlineHTML` rather than reaching outside the inline-HTML run from a sibling pattern.
Rationale. The new extension surface mirrors `SyntaxExtension.footnoteReferences(provider:)` — same factory shape, same range-tagging idiom (`HTMLCommentRangeAttribute`), same code-span skip via `PatternProcessor.isInsideCodeSpan`. The footnote precedent kept the new API surface predictable.

Centralised <!--…--> regex (post-review fix)

prism/Services/HTMLCommentStripping.swift

Why it matters. Three review agents flagged the same issue: the inline comment regex was declared in five places (HTMLCommentExport, HTMLCommentStripping, HTMLCommentParser, MarkdownBlockParser.stripCommentsInsideLinkLabels, MarkdownBlock.inlineHTMLCommentRegex). The doc comment on MarkdownBlock literally said `centralised so the strip-and-extract paths cannot drift` — yet the pattern wasn't actually shared. Promoted `HTMLCommentStripping.inlineCommentRegex` as the single source of truth; the other call sites now reference it.

What to look at. prism/Services/HTMLCommentStripping.swift:22-46, prism/Services/HTMLCommentExport.swift:38-40, prism/Models/MarkdownBlock.swift:759-764, prism/Services/MarkdownBlockParser.swift:108-150

Takeaway. If your code comment claims a constant is centralised, verify it actually is — drifted regex patterns are an especially painful source of correctness bugs because the divergence is invisible until a corner-case input lands.
Rationale. Surfaced post-implementation by code-reuse, code-quality, and efficiency agents independently. Three independent flags is a strong signal.

Key decisions

Default OFF is a behaviour change (Decision 3)

Previous behaviour was raw-HTML fall-through that leaked <!-- markers into rendered body text. New default hides comments entirely. Accepted because the previous behaviour was a defect of the fall-through path, not a deliberate UX choice, and Prism has not shipped to the App Store. Small TestFlight cohort impacted only.

Mixed-content HTML blocks pass through unchanged (Decision 10)

An HTMLBlock with both comments and non-comment HTML routes entirely to the raw-HTML fall-back. Splitting would require synthetic block boundaries with no clean source-order anchor. The fall-back now wraps content in HTMLCommentStripping so the toggle-off promise still holds for mixed content (Decision 16).

List-item continuity via ordinal offset (Decision 17)

swift-markdown emits two list nodes when an HTMLBlock separates items. Lifting siblings into the parent list at AST-walk time would break the one-AST-node-per-block invariant. MarkdownBlock.ordinalOffsetForList(at:in:) probes the predecessor and resumes ordered-list numbering instead. Visual contiguity comes free via existing paragraph-block spacing (Req 2.9).

Link-label stripping at parse time, not in the Textual extension (Decision 18)

Textual's pattern processor does not expose link-range context to extensions; adding it would have required a larger fork PR. A targeted regex pass on the markdown source via MarkdownBlockParser.stripCommentsInsideLinkLabels handles inline-form, reference-form, and shortcut-form link labels with one pass.

Paragraph-scoped accessibility brackets (Decision 14)

A paragraph with N inline comments announces one Comment marker at the start of the first comment span and one End comment after the last — not N pairs. Avoids VoiceOver fragmentation. HTMLCommentAccessibilityModifier + HTMLCommentAccessibility.analyze implement the composer.

Note-tag invariant (Decision 20)

MarkdownBlockParser.stripCommentTags runs before swift-markdown parses. HTMLCommentParser.parseBlock defensively rejects bodies matching ^\s*/?comment:[a-f0-9]+\s*$. testCommentInfrastructureTagsNeverReachHTMLCommentParser pins the ordering so a future refactor cannot surface internal note hashes as visible Comment annotations.

Skip caching in HTMLCommentAccessibilityModifier (inferred — review fix)

The original review fix attempted @State caching keyed by inputs. Reverted to a simpler recompute-per-body pattern because: (a) FootnoteAccessibilityModifier follows the same recompute pattern and the bench has not flagged the cost; (b) the @State cache write requires a Task-deferred indirection that triggers a second body re-eval on the first invocation, which is itself wasteful. The cheap markdown.contains("<!--") short-circuit covers documents without comments — the common case.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorCopy-all-notes export pathNotesExporter did not thread showHTMLComments. Context quotes saved at note-creation time can carry <!--...--> markers from .paragraph.textContent; with the toggle OFF, copy-all-notes leaked comment text into the clipboard — Req 6.2 violation.Threaded showHTMLComments through NotesExporter.export → formatThreadGroups → formatNote, then through NotesManager.exportMarkdown, SidebarNotesView.exportMarkdownPayload, and NotesPanel.exportMarkdownPayload. The context-quote line now runs through HTMLCommentExport.flatten. Added NotesExporterHTMLCommentTests with on/off coverage.
majorRegex pattern duplicationThe <!--...--> regex was declared as a string literal in HTMLCommentExport, HTMLCommentStripping, MarkdownBlock, and MarkdownBlockParser.stripCommentsInsideLinkLabels — five separate sites. MarkdownBlock's doc comment claimed centralisation that didn't exist.Promoted HTMLCommentStripping.inlineCommentRegex as the single source of truth. HTMLCommentExport, MarkdownBlock, and MarkdownBlockParser link-label stripping now consume the shared compiled regex. HTMLCommentParser keeps its own specialised regexes (subtler body shape, conditional rejection, etc.) but hoists them to static let.
majorHTMLCommentParser per-call regex compilationparseBlock rebuilt four NSRegularExpression instances on every invocation via try? NSRegularExpression(...). For a document with N HTML blocks the parser did 4·N compiles even though the patterns are static.Hoisted all four regexes to private static let with the appropriate options pre-applied (dotMatchesLineSeparators, caseInsensitive).
majorHTMLCommentStripping per-call regex compilationstrip() used replacingOccurrences(of:options:.regularExpression) which re-parses the pattern each call. Called per heading (ToC, anchor IDs, window title), per image alt/title, and on the raw-HTML fall-back — hundreds of calls per document open.Replaced with precompiled static let regexes consumed via stringByReplacingMatches(in:range:withTemplate:). Same shape as the existing inlineHTMLCommentRegex idiom.
majorHTMLCommentBlockView accessibility label catalog lookup.accessibilityLabel(LocalizedStringKey("Comment, \(rawText)")) interpolates at construction time — the resulting LocalizedStringKey is "Comment, <actual-text>", not the catalog key "Comment, %@". Catalog lookup is defeated; the literal interpolated string is what gets read.Switched to .accessibilityLabel(String(localized: "Comment, \(rawText)")) which uses Swift's catalog-aware string interpolation, matching the pattern HTMLCommentAccessibilityModifier already uses.
minorHTMLCommentAccessibilityModifier appearance driftThe modifier constructed its own HTMLCommentAppearance with hardcoded .secondary color and duplicated the localised "Comment"/"End comment" string lookup — diverging from the render-side appearance that uses colors.textSecondary.The modifier now takes the parent's htmlCommentAppearance as a parameter and re-uses it directly. Single source of truth.
minorTaskIdentifier visibility relaxationTaskIdentifier was changed from private to internal so a test could construct it and assert Equatable behaviour. Flagged by code-quality agent as testing a tautology.Skipped — the test pins the regression-meaningful contract that showHTMLComments is wired into TaskIdentifier (removing the field would fail the test at compile time). Internal visibility is test-target only; cost is bounded.
minorSearchContext file placementSearchContext lives in Models/MarkdownBlock.swift but is a search-layer type.Skipped — the model file already owns the searchableText(in:) method that consumes the context; co-locating the context with its primary consumer is reasonable. Moving it to Services adds an import dependency without clear benefit.
minorcombinedSearchableText runs regex unconditionallyEvery call invokes inlineHTMLCommentRegex.stringByReplacingMatches to strip markers even when text contains no <!--.Skipped — the regex's first-character bail-out is cheap, and adding a String.contains gate would only help documents without any comments at all (a case the cheap path already covers cheaply). Not worth the conditional branch.
minorRich export Req 6.4 vacuously truePrism has no rich / attributed-text export path today, so Req 6.4 'rich export uses on-screen styling' has no implementation to test. The spec mandates a snapshot/structural assertion for rich export that doesn't exist.Skipped — documented as deferred in the implementation.md completeness assessment. Should add a decision-log entry in a follow-up. Plain-text export (the only path that exists) is covered by HTMLCommentExportTests + the new NotesExporterHTMLCommentTests.
minorCLAUDE.md not updated for new filesSource Structure section in CLAUDE.md does not list the five new files (HTMLCommentParser/Stripping/Export/Accessibility, HTMLCommentBlockView). Specs and Documentation list does not include render-html-comments/.Skipped for this PR — CLAUDE.md maintenance can land in a follow-up sweep covering the multiple recent specs that also touched the source structure. Out of scope for the feature itself.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d0bd3d8..063ce4b 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Added +- HTML comments (`<!-- ... -->`) now have an opt-in Settings toggle (T-450) — "Show HTML comments" under a new "Markdown Rendering" section. Default is OFF: comment markers and content are hidden entirely (a behaviour change from today's raw-HTML fall-through, which leaked `<!--` markers into rendered body text). When ON, both block-level and inline HTML comments render as italic, dimmed annotations prefixed with the `info.circle` SF Symbol; markers are stripped, multi-line bodies preserve internal line breaks, and the indicator glyph scales with Dynamic Type. Toggling re-renders the open document without re-parsing the AST. Implementation: new `HTMLCommentParser.parseBlock` (`prism/Services/`) handles block-only HTML, rejecting conditional comments, CDATA, DOCTYPE, and the note-infrastructure `comment:HASH` tags; new `HTMLCommentStripping.strip` and `MarkdownBlockParser.stripCommentsInsideLinkLabels` strip comments from headings, image alt/title, link labels, and the raw-HTML fall-back path; new `MarkdownBlock.htmlComment(rawText:)` variant routes through a new `HTMLCommentBlockView`; ordered lists separated by a stand-alone comment block resume numbering via `MarkdownBlock.ordinalOffsetForList(at:in:)` (Decision 17). Inline rendering uses a new `SyntaxExtension.htmlComments(visible:appearance:)` shipped in the Textual fork (pin bumped to `5c9f477`); the search-highlight path in `HighlightedInlineText` now runs the comment transformation before `SearchHighlightApplicator`, and `TaskIdentifier` includes `showHTMLComments` so toggling refreshes the highlighted AttributedString and match count mid-search. Accessibility: per Decision 14, paragraphs with inline comments compose a single `Comment … End comment` boundary-bracketed label (`HTMLCommentAccessibility` + `HTMLCommentAccessibilityModifier`); block-level comments expose `Comment, <text>`; the indicator glyph is decorative. Search: new `SearchContext { showHTMLComments, footnoteData }` value drives `MarkdownBlock.searchableText(in:)`; legacy `searchableText` and `searchableText(with:)` become delegating shims; `SearchCoordinator.recomputeAfterVisibilityChange()` rebuilds totals against the new visibility and resets the cursor to the first match (or `0 of 0`) when the toggle changes mid-query. Export: `HTMLCommentExport.flatten` produces a localised `Comment: %@` prefix for plain-text and clipboard outputs when ON, strips comments when OFF; `NotesManager.exportWithInlineNotes`, `InlineNotesShareHelper.share`, and the macOS file export path thread the current setting through. New catalog entries: `Show HTML comments`, `Markdown Rendering`, `Comment`, `Comment, %@`, `End comment`, `Comment: %@`. Tests: new Swift Testing suites cover the parser (example + property-based), the stripping helpers, the block view, the inline rendering (toggle on/off + code-span skip + accessibility composer), search context, recompute-on-toggle, export, and AppSettings round-trip; new XCTest performance benchmark `HighlightedInlineTextRenderBench` asserts sub-quadratic scaling for inline-comment-heavy paragraphs; new 500 KB parsing benchmark variant pins the per-file regression budget; new UI test `HTMLCommentToggleUITests` exercises the live-toggle re-render path. Mixed-content HTML blocks now also pass through `HTMLCommentStripping` so the toggle-off promise holds (Decision 16). Note-infrastructure tags (`<!-- comment:HASH -->`) continue to be stripped before swift-markdown parses (Decision 20), with a defensive second-line-of-defence rejection inside `HTMLCommentParser`. Spec under `specs/render-html-comments/`. - VoiceOver and Voice Control can now reach the Mermaid card's "Show code" and "Copy code" overlay actions even while the overlay is hidden (T-1041, audit finding H1). `MermaidPreviewCard.renderedView(_:)` now attaches card-level `.accessibilityAction(named: "Show code") { openDiagram() }` and `.accessibilityAction(named: "Copy code") { copyToClipboard() }` on its `ZStack`, mirroring the established `ImageBlockView.swift:202-203` pattern. The visible "Show" and "Copy Code" overlay buttons also gain `.accessibilityHint(LocalizedStringKey(...))` modifiers so assistive tech narrates their behaviour when the overlay is on screen. Two new catalog entries — "Show code" and "Opens the rendered diagram" — are added to `prism/Localizable.xcstrings`; the "Copy code" action name and "Copies the diagram source code to clipboard" hint reuse existing keys (the latter shared with `MermaidErrorView.swift:101`). New `MermaidPreviewCardAccessibilityTests` Swift Testing suite covers card construction and `String(localized:)` resolution for the four catalog keys involved. Visual overlay behaviour (iOS tap-to-reveal with 3 s auto-dismiss, macOS hover-only) is unchanged. Spec lives under `specs/mermaid-card-a11y/`. - Render markdown tables that appear inside list items as tables instead of raw markdown text (T-1034). `MarkdownBlockParser.convertListItem` now emits a `.block(.table(...))` child for swift-markdown `Table` nodes nested under a list item, and `ListBlockView` renders them via a new private `NestedTableBlockView` wrapper that delegates to the existing `AccessibleTableView`. The wrapper hides the row-indicator column (per-row notes on nested tables deferred per ticket) and initialises its display mode via `TableDisplayMode.initialMode(headers:rows:)` so wider tables fall back to readable mode automatically. Test coverage extends `ListItemNestedBlocksTests` with four cases (parser emits the table child, item content no longer contains pipe text, alignment markers round-trip, list-block id structural fingerprint pinned against silent ID drift). Knowingly orphans any existing note attached to a list item that contains a table — the item's `contentForHashing` necessarily changes — accepted per `specs/tables-in-list-items/decision_log.md`. - Research report on adding HTML viewing with annotations to Prism, covering WKWebView, native parse, AttributedString, HTML→Markdown, and hybrid rendering options plus a Hypothesis-style note-anchoring cascade and a layered security model (sanitisation, CSP, scheme handler). Stored under `docs/agent-notes/` and not bundled in the app.@@ -16,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `prism-notes-js/prism-notes.js` implementation (~1500 lines, single ES2022 IIFE, no dependencies). Selecting text shows a "+ Add note" pill; saving creates a note anchored via a text-quote selector (prefix/exact/suffix + position hint) and a SHA-256 document-text hash. Notes persist in `localStorage` keyed by `pathname+search`, re-highlight via the CSS Custom Highlight API on reload, and surface in a side panel with a "Copy as Markdown" footer button (clipboard write with a textarea-fallback modal). Includes `prism-notes-js/README.md` documenting the three install paths (script tag, bookmarklet for permissive CSP, DevTools paste for strict CSP) and `prism-notes-js/test-page.html` as a manual-test fixture. Personal-use scope: no sync, no sharing, no import, no Shadow DOM, no accessibility commitments. - `footnote-badge-a11y` smolspec (T-1036) for VoiceOver discoverability and operability of inline footnote badges, covering paragraph-level accessibility-label rebuild and per-paragraph accessibility actions in `HighlightedInlineText`. Decision log captures the pivot away from a Textual fork change to a single-file view-layer approach. Five linearly-dependent tasks live under `specs/footnote-badge-a11y/`. - **Debug-only:** Settings → About now shows a "Build" row beneath "Version" with the short git commit hash the build was produced from, suffixed `-dirty` when the working tree had uncommitted changes (and `unknown` when built outside a git checkout). A new `Tools/stamp-commit-hash.sh` build phase writes `GitCommit` into the bundled `Info.plist` using `PlistBuddy`; the script bails early when `CONFIGURATION` isn't `Debug`, so Release/TestFlight/App Store builds never carry the key. The Settings UI and `Bundle.appCommitHash` accessor are wrapped in `#if DEBUG` as a second line of defence. The phase is marked always-out-of-date so the Debug value refreshes on every build.+- `render-html-comments` spec (T-450) covering an opt-in Settings toggle that replaces today's raw-HTML fall-through for `<!-- ... -->`. When the toggle is on, both block-level and inline HTML comments render as italic, dimmed annotations prefixed with the `info.circle` SF Symbol and with the comment markers stripped; when off, comments are hidden entirely (new default). Toggling re-renders the open document without re-parsing the AST; search, copy/export, and accessibility honour the toggle. Requirements, design, decision log, and implementation tasks live under `specs/render-html-comments/`. - Footnote badges are now discoverable and operable for VoiceOver (T-1036). New `FootnoteAccessibility` helper (`prism/Services/FootnoteAccessibility.swift`) extracts unique footnote references in first-occurrence order and rebuilds a paragraph-level accessibility label that substitutes each resolvable `[^id]` with the localised inline phrase "footnote N". `HighlightedInlineText` applies a private `FootnoteAccessibilityModifier` that overrides the paragraph's accessibility label and exposes one "Show footnote N" action per unique reference via `.accessibilityActions { … }`. Each action opens the existing `prism://footnote/{id}` URL through `@Environment(\.openURL)`, reusing the tap-routing flow already wired in `DocumentReaderView`. The modifier is a no-op when no resolvable references are present, so paragraphs without footnotes keep their auto-derived narration. New localised keys `a11y.footnote.inline %lld` and `a11y.footnote.action %lld` added to `prism/Localizable.xcstrings`. Helpers covered by new unit tests in `prismTests/FootnoteAccessibilityTests.swift`.  ### Fixed
prism.xcodeproj/project.pbxproj Modified +1 / -1
diff --git a/prism.xcodeproj/project.pbxproj b/prism.xcodeproj/project.pbxprojindex 7cf486c..eb1cad3 100644--- a/prism.xcodeproj/project.pbxproj+++ b/prism.xcodeproj/project.pbxproj@@ -769,7 +769,7 @@ 			repositoryURL = "https://github.com/arjenschwarz/textual"; 			requirement = { 				kind = revision;-				revision = 69009d43f9809cd90adede4798b88fa16bc4cabf;+				revision = 5c9f477fa07ecc5d68d1fa9f21aff8c4ddc65b31; 			}; 		}; /* End XCRemoteSwiftPackageReference section */
prism.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved Modified +1 / -1
diff --git a/prism.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/prism.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolvedindex 20921f8..8198358 100644--- a/prism.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved+++ b/prism.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved@@ -51,7 +51,7 @@       "kind" : "remoteSourceControl",       "location" : "https://github.com/arjenschwarz/textual",       "state" : {-        "revision" : "69009d43f9809cd90adede4798b88fa16bc4cabf"+        "revision" : "5c9f477fa07ecc5d68d1fa9f21aff8c4ddc65b31"       }     }   ],
prism/Localizable.xcstrings Modified +161 / -0
diff --git a/prism/Localizable.xcstrings b/prism/Localizable.xcstringsindex fb7009c..b232450 100644--- a/prism/Localizable.xcstrings+++ b/prism/Localizable.xcstrings@@ -967,6 +967,75 @@         }       }     },+    "Comment": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Comment"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Comment"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Comment"+          }+        }+      }+    },+    "Comment, %@": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Comment, %@"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Comment, %@"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Comment, %@"+          }+        }+      }+    },+    "Comment: %@": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Comment: %@"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Comment: %@"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Comment: %@"+          }+        }+      }+    },     "Copies the code block to clipboard": {       "extractionState": "manual",       "localizations": {@@ -1864,6 +1933,29 @@         }       }     },+    "End comment": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "End comment"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "End comment"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "End comment"+          }+        }+      }+    },     "Enter Your Name": {       "extractionState": "manual",       "localizations": {@@ -2991,6 +3083,29 @@         }       }     },+    "Markdown Rendering": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Markdown Rendering"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Markdown Rendering"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Markdown Rendering"+          }+        }+      }+    },     "Mermaid source code": {       "extractionState": "manual",       "localizations": {@@ -4210,6 +4325,29 @@         }       }     },+    "Reveal author HTML comments as dimmer annotations. When off, comments are hidden entirely.": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Reveal author HTML comments as dimmer annotations. When off, comments are hidden entirely."+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Reveal author HTML comments as dimmer annotations. When off, comments are hidden entirely."+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Reveal author HTML comments as dimmer annotations. When off, comments are hidden entirely."+          }+        }+      }+    },     "Rendering diagram": {       "extractionState": "manual",       "localizations": {@@ -5015,6 +5153,29 @@         }       }     },+    "Show HTML comments": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Show HTML comments"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Show HTML comments"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Show HTML comments"+          }+        }+      }+    },     "Show inline notes": {       "extractionState": "manual",       "localizations": {
prism/Models/DocumentSession.swift Modified +10 / -0
diff --git a/prism/Models/DocumentSession.swift b/prism/Models/DocumentSession.swiftindex 39aeb7b..0148c49 100644--- a/prism/Models/DocumentSession.swift+++ b/prism/Models/DocumentSession.swift@@ -333,7 +333,10 @@ final class DocumentSession: Identifiable {     ///     /// Rules: lowercase, spaces → hyphens, keep alphanumerics, hyphens, and underscores.     private static func anchorSlug(from text: String) -> String {-        let stripped = FootnoteStripping.strip(text)+        // HTML comment stripping runs before footnote stripping because a+        // comment body may contain `[^x]` tokens that look like footnote+        // references (Decision 11, Decision 19).+        let stripped = FootnoteStripping.strip(HTMLCommentStripping.strip(text))         let processed = stripped.lowercased().replacingOccurrences(of: " ", with: "-")         let filtered = processed.unicodeScalars.filter {             CharacterSet.alphanumerics.contains($0) || $0 == "-" || $0 == "_"@@ -524,7 +527,10 @@ final class DocumentSession: Identifiable {     private func extractDocumentTitle(from blocks: [MarkdownBlock]) -> String? {         for block in blocks {             if case .heading(let level, let text) = block, level == 1 {-                let stripped = FootnoteStripping.strip(text)+                // HTML comment stripping runs before footnote stripping+                // because comments may contain `[^x]` tokens that look+                // like footnote references (Decision 11, Decision 19).+                let stripped = FootnoteStripping.strip(HTMLCommentStripping.strip(text))                 let trimmed = stripped.trimmingCharacters(in: .whitespacesAndNewlines)                 return trimmed.isEmpty ? nil : trimmed             }
prism/Models/MarkdownBlock.swift Modified +246 / -0
diff --git a/prism/Models/MarkdownBlock.swift b/prism/Models/MarkdownBlock.swiftindex 825a24b..5568f70 100644--- a/prism/Models/MarkdownBlock.swift+++ b/prism/Models/MarkdownBlock.swift@@ -412,6 +412,24 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable {     /// Raw HTML content.     case html(content: String) +    /// A stand-alone HTML comment block (`<!-- ... -->`).+    ///+    /// Emitted by ``HTMLCommentParser/parseBlock(_:)`` when the source+    /// `HTMLBlock` consists entirely of one or more HTML comments+    /// (Req 2.2). Visibility is controlled at render time by+    /// ``AppSettings/showHTMLComments``, so toggling the preference does+    /// NOT re-parse the document (Req 9.2).+    ///+    /// - Parameter rawText: The joined inner text with `<!--` / `-->`+    ///   markers stripped, per-comment outer whitespace trimmed, and the+    ///   source whitespace between consecutive comments preserved.+    ///+    /// The parser never emits `.htmlComment(rawText: "")`: an empty or+    /// whitespace-only comment-only block returns `nil` from+    /// ``HTMLCommentParser/parseBlock(_:)`` and falls through to the+    /// `.html(content:)` path (which then strips the markers).+    case htmlComment(rawText: String)+     /// YAML frontmatter metadata.     case metadata(content: String) @@ -465,6 +483,8 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable {             return result         case .html(let content):             return "html:\(content)"+        case .htmlComment(let rawText):+            return "htmlComment:\(rawText)"         case .metadata(let content):             return "metadata:\(content)"         case .details(let summary, let children, _, let depth):@@ -483,7 +503,7 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable {     /// to specific content inside them.     var supportsNotes: Bool {         switch self {-        case .thematicBreak, .metadata, .details:+        case .thematicBreak, .metadata, .details, .htmlComment:             return false         default:             // Don't allow notes on empty/blank blocks@@ -504,6 +524,7 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable {         case .thematicBreak: return "HR"         case .image: return "Img"         case .html: return "HTML"+        case .htmlComment: return "Comment"         case .metadata: return "Meta"         case .details(_, _, _, let depth): return "Details(\(depth))"         }@@ -646,6 +667,8 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable {             return alt         case .html(let content):             return content+        case .htmlComment(let rawText):+            return rawText         case .metadata(let content):             return content         case .details(let summary, let children, _, _):@@ -654,38 +677,50 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable {         }     } -    /// Plain text content suitable for search matching.+    /// Plain text content suitable for search matching, gated by the+    /// supplied ``SearchContext``.     ///-    /// Strips markdown formatting syntax to match rendered text appearance.-    /// This property differs from `textContent` which preserves markdown syntax.+    /// Canonical search-text builder for the render-html-comments feature+    /// (see design.md "Search pipeline"). Strips markdown formatting and+    /// per-block: pre-strips `<!--…-->` markers from the base so the user+    /// never matches on raw comment syntax; appends comment inner text+    /// when `context.showHTMLComments == true`; appends footnote-definition+    /// content for any `[^id]` references resolvable via+    /// `context.footnoteData`.     ///     /// Requirements covered:-    /// - 2.1-2.8: Search text content within specific block types-    /// - 2.9-2.12: Exclude html, metadata from search; strip markdown syntax-    var searchableText: String {+    /// - render-html-comments Req 5.1 / 5.2 / 5.3 / 4.2+    /// - footnote Req 5.1 / 5.3+    /// - existing search requirements 2.1-2.12 (markdown stripping)+    func searchableText(in context: SearchContext) -> String {         switch self {         case .heading(_, let text):-            return Self.stripMarkdownFormatting(text)+            return Self.combinedSearchableText(+                forInlineText: text,+                context: context+            )          case .paragraph(let markdown):-            return Self.stripMarkdownFormatting(markdown)+            return Self.combinedSearchableText(+                forInlineText: markdown,+                context: context+            )          case .codeBlock(_, let code):-            // Code blocks are searched as-is (no markdown formatting inside)             return code          case .mermaid(let source, _):-            // Mermaid source is searchable (find diagram labels, etc.)             return source          case .blockquote(let content):-            return Self.stripMarkdownFormatting(content)+            return Self.combinedSearchableText(+                forInlineText: content,+                context: context+            )          case .list(_, let items):-            // Walk children so trailing-paragraph prose and nested block-            // text (code, blockquote, table) are searchable too (T-1263).             return items-                .map { Self.listItemSearchableText($0) }+                .map { Self.listItemSearchableText($0, in: context) }                 .joined(separator: "\n")          case .table(let headers, let rows, _):@@ -702,46 +737,102 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable {             return Self.stripMarkdownFormatting(alt)          case .html, .metadata, .thematicBreak:-            // Not searchable per requirements 2.9-2.11             return "" +        case .htmlComment(let rawText):+            // Gated on the toggle: hidden comments contribute no matches+            // (Req 5.1 / 5.2).+            return context.showHTMLComments ? rawText : ""+         case .details(let summary, let children, _, _):-            // Include both summary and all children content (Requirement 4.1)-            let summaryText = Self.stripMarkdownFormatting(summary)-            let childrenText = children.map { $0.searchableText }.joined(separator: " ")+            let summaryText = Self.combinedSearchableText(+                forInlineText: summary,+                context: context+            )+            let childrenText = children+                .map { $0.searchableText(in: context) }+                .joined(separator: " ")             return summaryText + " " + childrenText         }     } +    /// Reference to the shared HTML comment regex. Centralised in+    /// ``HTMLCommentStripping/inlineCommentRegex`` so the strip-and-extract+    /// paths cannot drift.+    private static var inlineHTMLCommentRegex: NSRegularExpression {+        HTMLCommentStripping.inlineCommentRegex+    }++    /// Returns markdown-formatted inline text post-processed for search:+    /// markdown stripped, HTML comment markers stripped from the base,+    /// comment inner text appended when the context's toggle is ON, and+    /// footnote-definition content appended for any `[^id]` reference+    /// resolvable via `context.footnoteData`.+    private static func combinedSearchableText(+        forInlineText text: String,+        context: SearchContext+    ) -> String {+        let nsText = text as NSString+        let fullRange = NSRange(location: 0, length: nsText.length)++        // Strip comment markers from the base so search never matches+        // on raw `<!--…-->`. Same input is used to extract inner text+        // below when the toggle is ON.+        let baseWithoutMarkers = inlineHTMLCommentRegex.stringByReplacingMatches(+            in: text, range: fullRange, withTemplate: ""+        )+        var result = stripMarkdownFormatting(baseWithoutMarkers)++        if context.showHTMLComments {+            let matches = inlineHTMLCommentRegex.matches(in: text, range: fullRange)+            for match in matches where match.numberOfRanges >= 2 {+                let body = nsText.substring(with: match.range(at: 1))+                    .trimmingCharacters(in: .whitespacesAndNewlines)+                if !body.isEmpty {+                    result += " " + body+                }+            }+        }++        // Append footnote definition content for any resolvable `[^id]`+        // reference in the ORIGINAL text (so we don't lose references+        // hidden inside comment ranges that have been stripped above).+        if !context.footnoteData.isEmpty {+            for match in text.matches(of: footnoteReferenceRegex) {+                let identifier = String(match.1)+                if let definition = context.footnoteData.definition(for: identifier) {+                    result += " " + stripMarkdownFormatting(definition.content)+                }+            }+        }++        return result+    }++    // MARK: - Compatibility shims++    /// Legacy plain-text search builder.+    ///+    /// Delegates to ``searchableText(in:)`` with a default context that+    /// hides HTML comments (`showHTMLComments: false`) and carries no+    /// footnote data. New call sites should construct a ``SearchContext``+    /// explicitly so the toggle is honoured.+    var searchableText: String {+        searchableText(in: SearchContext(showHTMLComments: false, footnoteData: .empty))+    }+     // MARK: - Footnote-Aware Search      /// Regex matching footnote references like `[^identifier]`.     private static let footnoteReferenceRegex = FootnoteData.referencePattern -    /// Plain text content suitable for search matching, with footnote content appended.-    ///-    /// Scans the block's raw text for `[^identifier]` references and appends-    /// the stripped footnote definition content for each resolved reference.-    /// This allows searching for text that appears only in footnotes.+    /// Legacy footnote-aware search-text builder.     ///-    /// Requirements covered:-    /// - 5.1: Include footnote content in searchable text-    /// - 5.3: Footnote matches counted in host block match count+    /// Delegates to ``searchableText(in:)`` with `showHTMLComments: false`+    /// and the supplied footnote data. New call sites should construct a+    /// ``SearchContext`` directly so HTML comment visibility is honoured.     func searchableText(with footnoteData: FootnoteData) -> String {-        guard !footnoteData.isEmpty else { return searchableText }--        var base = searchableText-        let blockText = textContent--        for match in blockText.matches(of: Self.footnoteReferenceRegex) {-            let identifier = String(match.1)-            if let definition = footnoteData.definition(for: identifier) {-                let footnoteText = Self.stripMarkdownFormatting(definition.content)-                base += " " + footnoteText-            }-        }--        return base+        searchableText(in: SearchContext(showHTMLComments: false, footnoteData: footnoteData))     }      // MARK: - Precompiled Regex Patterns@@ -770,27 +861,31 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable {      private static let captureGroupTemplate = "$1" -    /// Strips common markdown inline formatting from text.-    ///-    /// Searchable text for a single list item, walking its leading content-    /// and every child in source order. `.paragraph` continuations and-    /// `.block` children (nested code, blockquote, table) all contribute-    /// their text so trailing prose stays searchable after T-1263. Nested-    /// lists recurse via their items' content; nested blocks recurse via-    /// each block's own `searchableText`.-    private static func listItemSearchableText(_ item: ListItem) -> String {-        var parts: [String] = [stripMarkdownFormatting(item.content)]+    /// Searchable text for a single list item, gated by `context`.+    ///+    /// Walks the leading content and every child in source order.+    /// `.paragraph` continuations and `.block` children (nested code,+    /// blockquote, table) all contribute their text so trailing prose+    /// stays searchable after T-1263. Nested lists recurse via their+    /// items' content; nested blocks recurse via each block's own+    /// `searchableText(in:)`. Inline HTML comments inside leading content+    /// or continuation paragraphs honour `context.showHTMLComments`.+    private static func listItemSearchableText(+        _ item: ListItem,+        in context: SearchContext+    ) -> String {+        var parts: [String] = [combinedSearchableText(forInlineText: item.content, context: context)]         for child in item.children {             switch child {             case .paragraph(let text):-                parts.append(stripMarkdownFormatting(text))+                parts.append(combinedSearchableText(forInlineText: text, context: context))             case .block(let block):-                let inner = block.searchableText+                let inner = block.searchableText(in: context)                 if !inner.isEmpty { parts.append(inner) }             case .nestedList(let nested):                 parts.append(                     nested.items-                        .map { listItemSearchableText($0) }+                        .map { listItemSearchableText($0, in: context) }                         .joined(separator: "\n")                 )             }@@ -816,6 +911,49 @@ enum MarkdownBlock: Identifiable, Equatable, Sendable {     } } +/// Bundles the preferences and document-scoped data needed to compute+/// search-eligible text for a block.+///+/// Used by ``MarkdownBlock/searchableText(in:)``. The compatibility shims+/// ``MarkdownBlock/searchableText`` and ``MarkdownBlock/searchableText(with:)``+/// construct a default context that disables HTML comment surfacing — they+/// exist so legacy call sites keep compiling until they migrate.+struct SearchContext: Sendable {+    /// Whether `AppSettings.showHTMLComments` is on. Drives the+    /// inclusion of HTML comment content in search results+    /// (render-html-comments Req 5.1, 5.2).+    let showHTMLComments: Bool++    /// Footnote definitions for the document. Drives footnote-body+    /// inclusion in search results when a paragraph or heading carries a+    /// `[^id]` reference (footnote spec Req 5.1, 5.3).+    let footnoteData: FootnoteData+}++// MARK: - Ordinal offset for lists separated by a comment block++extension MarkdownBlock {+    /// Computes the ordinal offset for the ordered list at `blockIndex` in+    /// `blocks` when it follows a `.htmlComment` block that itself follows+    /// another ordered list at the same level.+    ///+    /// Returns `0` for any other shape (unordered lists, lists with a+    /// non-`.htmlComment` predecessor, lists at index 0 or 1). When the+    /// shape matches, returns the predecessor ordered list's item count so+    /// the consumer can resume numbering from `offset + 1`.+    ///+    /// This implements the rendering half of Decision 17: list-item+    /// continuity across comment blocks via ordinal offset.+    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 .htmlComment = blocks[blockIndex - 1] else { return 0 }+        guard case .list(true, let previousItems) = blocks[blockIndex - 2] else { return 0 }+        return previousItems.count+    }+}+ // MARK: - Cache Management  extension MarkdownBlock {
prism/Models/SearchMatch.swift Modified +2 / -0
diff --git a/prism/Models/SearchMatch.swift b/prism/Models/SearchMatch.swiftindex 495cc3a..e3ec0a7 100644--- a/prism/Models/SearchMatch.swift+++ b/prism/Models/SearchMatch.swift@@ -159,7 +159,7 @@ extension MarkdownBlock {             return .mermaid         case .details:             return .details-        case .thematicBreak, .html, .metadata:+        case .thematicBreak, .html, .htmlComment, .metadata:             return .other         }     }
prism/Services/HTMLCommentAccessibility.swift Added +120 / -0
diff --git a/prism/Services/HTMLCommentAccessibility.swift b/prism/Services/HTMLCommentAccessibility.swiftnew file mode 100644index 0000000..6570d4f--- /dev/null+++ b/prism/Services/HTMLCommentAccessibility.swift@@ -0,0 +1,120 @@+//+//  HTMLCommentAccessibility.swift+//  prism+//+//  Created by Claude on 23/5/2026.+//++import Foundation+import Textual++/// Accessibility-layer helpers for inline HTML comments rendered by+/// `HighlightedInlineText`.+///+/// Walks an `AttributedString` produced by the+/// `.htmlComments(visible:appearance:)` Textual syntax extension and composes+/// a single paragraph-scoped accessibility label of the form+/// `<before> Comment <c1> ... <cN> End comment <after>` (Decision 14).+///+/// The composer is pure and operates on the rendered `AttributedString` runs,+/// so it can be unit-tested without going through a SwiftUI environment.+enum HTMLCommentAccessibility {++    /// The result of analysing an `AttributedString` for HTML comment runs.+    struct Analysis: Equatable {+        /// `true` when the input contained at least one HTML comment run, so+        /// the view layer should override the accessibility label. When+        /// this is `false`, the modifier should be a no-op (leaving+        /// VoiceOver to derive the spoken label from the rendered text).+        let hasComments: Bool++        /// The composed accessibility label. Only meaningful when+        /// ``hasComments`` is `true`; otherwise empty.+        let rebuiltLabel: String+    }++    /// Composes the paragraph-scoped accessibility label for `content`.+    ///+    /// Walks the runs of `content`:+    /// - Non-comment runs contribute their plain text directly.+    /// - The first contiguous comment-tagged region is preceded by+    ///   `commentLabel`.+    /// - The last contiguous comment-tagged region is followed by+    ///   `endCommentLabel`.+    /// - Multiple inline comments share one bracketing pair (Decision 14):+    ///   the speech reads `<before> Comment <c1> ... <cN> End comment <after>`.+    /// - Empty / whitespace-only inner texts are stripped without+    ///   contributing a boundary marker (Req 3.6 — the Textual extension+    ///   already drops them, this is defence in depth).+    static func analyze(+        content: AttributedString,+        commentLabel: String,+        endCommentLabel: String+    ) -> Analysis {+        // Collect plain text per attribute run, tagging whether the run is+        // part of an HTML comment. Walking by run gives us coalesced+        // boundaries without scanning characters twice.+        struct RunPiece {+            let text: String+            let isComment: Bool+        }+        var pieces: [RunPiece] = []+        for run in content.runs {+            let isComment = run[HTMLCommentRangeAttributeKey.self] != nil+            let segment = AttributedString(content[run.range])+            let text = String(segment.characters)+            pieces.append(RunPiece(text: text, isComment: isComment))+        }++        guard pieces.contains(where: { $0.isComment }) else {+            return Analysis(hasComments: false, rebuiltLabel: "")+        }++        let firstCommentIndex = pieces.firstIndex { $0.isComment } ?? -1+        let lastCommentIndex = pieces.lastIndex { $0.isComment } ?? -1++        // Walk pieces with a boundary state machine. We emit+        // `commentLabel` when entering the first comment span and+        // `endCommentLabel` after the last comment span. Intervening+        // non-comment text between comments is preserved verbatim so the+        // surrounding prose still reads naturally.+        var label = ""+        for (index, piece) in pieces.enumerated() {+            if piece.isComment {+                if index == firstCommentIndex {+                    label.append(commentLabel)+                    label.append(" ")+                }+                let cleaned = stripDecorativeCharacters(piece.text)+                if !cleaned.isEmpty {+                    label.append(cleaned)+                }+                if index == lastCommentIndex {+                    label.append(" ")+                    label.append(endCommentLabel)+                }+            } else {+                label.append(piece.text)+            }+        }++        return Analysis(hasComments: true, rebuiltLabel: label)+    }++    /// The Textual extension renders the SF Symbol attachment with the+    /// Unicode object-replacement character `\u{FFFC}`. That codepoint+    /// reads as a glitch under VoiceOver, so the composer drops it from+    /// the spoken label — the symbol is announced via the+    /// `Comment`/`End comment` boundary markers instead (Req 7.3).+    private static func stripDecorativeCharacters(_ text: String) -> String {+        var result = text+        result.removeAll { $0 == "\u{FFFC}" }+        // Trim a single leading space that the extension inserts between+        // the symbol attachment and the inner text — that space is part of+        // the decorative attachment, not the comment content.+        if result.first == " " {+            result.removeFirst()+        }+        return result+    }+}
prism/Services/HTMLCommentExport.swift Added +84 / -0
diff --git a/prism/Services/HTMLCommentExport.swift b/prism/Services/HTMLCommentExport.swiftnew file mode 100644index 0000000..dd47199--- /dev/null+++ b/prism/Services/HTMLCommentExport.swift@@ -0,0 +1,78 @@+//+//  HTMLCommentExport.swift+//  prism+//+//  Created by Claude on 22/5/2026.+//++import Foundation++/// Plain-text export helpers for HTML comments.+///+/// `flatten(_:visible:)` rewrites `<!--…-->` occurrences in the supplied+/// text so the exported content matches the user's on-screen toggle+/// state (Req 6.1, 6.2). When the toggle is OFF, comments are removed+/// entirely. When ON, each comment becomes a localised "Comment: <inner>"+/// prefix (Decision 13) so the plain-text consumer still distinguishes+/// the annotation from regular body text without depending on the+/// SF Symbol used in the on-screen rendering.+enum HTMLCommentExport {+    /// Returns the supplied text with `<!--…-->` occurrences rewritten+    /// for plain-text export.+    ///+    /// - Parameters:+    ///   - markdown: Source text. Treated as opaque; the helper makes no+    ///     attempt to parse markdown structure.+    ///   - visible: When `false`, each comment is removed. When `true`,+    ///     each non-empty comment is replaced with the localised+    ///     `"Comment: <inner>"` prefix (Decision 13). Empty / whitespace+    ///     bodies produce no output regardless of `visible` so the+    ///     plain-text export never contains a bare `Comment: ` line.+    static func flatten(_ markdown: String, visible: Bool) -> String {+        let nsInput = markdown as NSString+        let fullRange = NSRange(location: 0, length: nsInput.length)+        let matches = HTMLCommentStripping.inlineCommentRegex.matches(in: markdown, range: fullRange)++        guard !matches.isEmpty else { return markdown }++        var result = ""+        var lastEnd = 0++        for match in matches {+            // Append the unchanged text before this match.+            if match.range.location > lastEnd {+                let before = nsInput.substring(+                    with: NSRange(location: lastEnd, length: match.range.location - lastEnd)+                )+                result += before+            }++            if visible, match.numberOfRanges >= 2 {+                let body = nsInput.substring(with: match.range(at: 1))+                    .trimmingCharacters(in: .whitespacesAndNewlines)+                if !body.isEmpty {+                    result += commentPrefix(for: body)+                }+            }+            // Else: visibility OFF or empty body — emit nothing in place of the match.++            lastEnd = match.range.location + match.range.length+        }++        // Append any trailing text after the last match.+        if lastEnd < nsInput.length {+            result += nsInput.substring(+                with: NSRange(location: lastEnd, length: nsInput.length - lastEnd)+            )+        }++        return result+    }++    /// Localised plain-text prefix used to surface a comment body in+    /// exported content. Catalog key `"Comment: %@"` (added in task 21+    /// of the render-html-comments spec).+    private static func commentPrefix(for body: String) -> String {+        String(localized: "Comment: \(body)")+    }+}
prism/Services/HTMLCommentParser.swift Added +146 / -0
diff --git a/prism/Services/HTMLCommentParser.swift b/prism/Services/HTMLCommentParser.swiftnew file mode 100644index 0000000..44746cc--- /dev/null+++ b/prism/Services/HTMLCommentParser.swift@@ -0,0 +1,143 @@+//+//  HTMLCommentParser.swift+//  prism+//+//  Created by Claude on 22/5/2026.+//++import Foundation++/// Parses an `HTMLBlock`'s raw HTML and decides whether it is a comment-only+/// block (one or more `<!-- ... -->` comments with optional surrounding+/// whitespace, and nothing else).+///+/// When it is, the parser returns the joined inner text with `<!--` / `-->`+/// markers stripped, multiple comments joined by their source-separating+/// whitespace, and outer whitespace trimmed.+///+/// Returns `nil` for any input that should fall back to the existing raw-HTML+/// path:+/// - mixed content (a comment plus non-comment HTML);+/// - unterminated `<!--` (Req 2.7);+/// - conditional comments `<!--[if …]>…<![endif]-->` (Non-Goal);+/// - `<![CDATA[…]]>` and `<!DOCTYPE …>` (not comments);+/// - note-infrastructure tags `<!-- comment:HASH -->` / `<!-- /comment:HASH -->`+///   (defence-in-depth alongside `MarkdownBlockParser.stripCommentTags`,+///   Decision 20);+/// - empty / whitespace-only comments after trimming (Req 2.6).+enum HTMLCommentParser {+    /// Structural validator: matches an input that consists entirely of one+    /// or more `<!--…-->` comments separated by optional whitespace.+    ///+    /// The body uses `(?:(?!-->).)*?` so non-greedy expansion cannot swallow+    /// `-->` runs and turn `<!--a--> x <!--b-->` into a single comment with+    /// body `a--> x <!--b`.+    private static let structuralRegex: NSRegularExpression = {+        // swiftlint:disable:next force_try+        try! NSRegularExpression(+            pattern: #"^(?:\s*<!--(?:(?!-->).)*?-->\s*)+$"#,+            options: .dotMatchesLineSeparators+        )+    }()++    /// Inner-content extractor.+    private static let extractRegex: NSRegularExpression = {+        // swiftlint:disable:next force_try+        try! NSRegularExpression(+            pattern: #"<!--((?:(?!-->).)*?)-->"#,+            options: .dotMatchesLineSeparators+        )+    }()++    /// Body shape of a note-infrastructure tag — anything that looks like+    /// `comment:HASH` or `/comment:HASH` (lowercase hex), with optional outer+    /// whitespace. Used as defence-in-depth so infrastructure tags can never+    /// surface as visible Comment annotations even if `stripCommentTags`+    /// regresses.+    private static let noteRegex: NSRegularExpression = {+        // swiftlint:disable:next force_try+        try! NSRegularExpression(pattern: #"^\s*/?comment:[a-f0-9]+\s*$"#)+    }()++    /// Conditional-comment body shape — bodies that start with `[if` or+    /// `[endif` (case-insensitive) after stripping outer whitespace.+    /// Used so we can reject conditional comments whether or not the marker+    /// sits right after `<!--`.+    private static let conditionalRegex: NSRegularExpression = {+        // swiftlint:disable:next force_try+        try! NSRegularExpression(+            pattern: #"^\s*\[(?:if|endif)\b"#,+            options: .caseInsensitive+        )+    }()++    /// Returns the joined inner text of a comment-only block, or `nil` for+    /// any input that should not be classified as a comment-only block.+    static func parseBlock(_ rawHTML: String) -> String? {+        // Defensive prefix rejects: CDATA and DOCTYPE are not comments, but+        // they superficially resemble comment syntax. The structural regex+        // would already reject them; this is an explicit guard for clarity.+        let trimmed = rawHTML.trimmingCharacters(in: .whitespacesAndNewlines)+        if trimmed.hasPrefix("<![CDATA[") || trimmed.hasPrefix("<!DOCTYPE") {+            return nil+        }++        // Structural validation: the input must be ENTIRELY comment-only.+        let nsInput = rawHTML as NSString+        let fullRange = NSRange(location: 0, length: nsInput.length)+        guard structuralRegex.firstMatch(in: rawHTML, range: fullRange) != nil else {+            return nil+        }++        let matches = extractRegex.matches(in: rawHTML, range: fullRange)+        guard !matches.isEmpty else { return nil }++        // Build the joined string. Each `<!--…-->` contributes its body+        // (trimmed of outer whitespace per comment for clean rendering);+        // the run of source characters BETWEEN successive comments+        // contributes verbatim, so blank lines and inline whitespace are+        // preserved (Req 2.5).+        var joined = ""+        var lastEnd = matches[0].range.location++        for (i, match) in matches.enumerated() {+            guard match.numberOfRanges >= 2 else { continue }+            let bodyRange = match.range(at: 1)+            let body = nsInput.substring(with: bodyRange)+            let bodyNSLength = (body as NSString).length+            let bodyFullRange = NSRange(location: 0, length: bodyNSLength)++            // Defence-in-depth: reject if ANY body looks like a+            // note-infrastructure tag.+            if noteRegex.firstMatch(in: body, range: bodyFullRange) != nil {+                return nil+            }++            // Reject conditional comments: bodies that start with `[if` or+            // `[endif` (with optional leading whitespace, case-insensitive).+            if conditionalRegex.firstMatch(in: body, range: bodyFullRange) != nil {+                return nil+            }++            // Append the separator run preceding this comment (excluding the+            // leading whitespace before the FIRST comment — that's outer+            // whitespace and will be trimmed by the final step).+            if i > 0 {+                let sepLocation = lastEnd+                let sepLength = match.range.location - sepLocation+                if sepLength > 0 {+                    let sep = nsInput.substring(with: NSRange(location: sepLocation, length: sepLength))+                    joined += sep+                }+            }++            joined += body.trimmingCharacters(in: .whitespacesAndNewlines)+            lastEnd = match.range.location + match.range.length+        }++        // Trim outer whitespace AFTER joining so that internal separators are+        // preserved verbatim.+        let result = joined.trimmingCharacters(in: .whitespacesAndNewlines)+        return result.isEmpty ? nil : result+    }+}
prism/Services/HTMLCommentStripping.swift Added +44 / -0
diff --git a/prism/Services/HTMLCommentStripping.swift b/prism/Services/HTMLCommentStripping.swiftnew file mode 100644index 0000000..ed77ef6--- /dev/null+++ b/prism/Services/HTMLCommentStripping.swift@@ -0,0 +1,54 @@+//+//  HTMLCommentStripping.swift+//  prism+//+//  Created by Claude on 22/5/2026.+//++import Foundation++/// Removes HTML comments (`<!-- ... -->`) from text.+///+/// Mirrors `FootnoteStripping` in shape. Used at heading-derived sites+/// (ToC entries, anchor IDs, window titles), image alt/title strings, and+/// the raw-HTML fall-back so that author comment markers never leak into+/// derived navigation surfaces or the raw-HTML path, regardless of the+/// `AppSettings.showHTMLComments` toggle (Req 4.1, 4.5, Decision 16).+///+/// Composition order with `FootnoteStripping.strip`: HTML comment stripping+/// runs FIRST, because a comment may itself contain `[^x]` tokens that+/// otherwise look like footnote references.+enum HTMLCommentStripping {+    /// Precompiled regex matching an HTML comment (including multi-line+    /// bodies), with the inner text captured as group 1. Shared with+    /// ``MarkdownBlock`` and ``HTMLCommentExport`` so all `<!--…-->`-shape+    /// scans use one source of truth and pay the regex-compile cost once.+    static let inlineCommentRegex: NSRegularExpression = {+        // swiftlint:disable:next force_try+        try! NSRegularExpression(pattern: #"<!--([\s\S]*?)-->"#)+    }()++    /// Precompiled collapse regex (2+ horizontal-whitespace runs → one space).+    private static let collapseRegex: NSRegularExpression = {+        // swiftlint:disable:next force_try+        try! NSRegularExpression(pattern: #"[ \t]{2,}"#)+    }()++    /// Returns `text` with all HTML comments removed, adjacent runs of+    /// whitespace collapsed to a single space, and outer whitespace trimmed.+    static func strip(_ text: String) -> String {+        let nsText = text as NSString+        let removed = inlineCommentRegex.stringByReplacingMatches(+            in: text,+            range: NSRange(location: 0, length: nsText.length),+            withTemplate: ""+        )+        let nsRemoved = removed as NSString+        let collapsed = collapseRegex.stringByReplacingMatches(+            in: removed,+            range: NSRange(location: 0, length: nsRemoved.length),+            withTemplate: " "+        )+        return collapsed.trimmingCharacters(in: .whitespacesAndNewlines)+    }+}
prism/Services/InlineNotesShareHelper.swift Modified +3 / -0
diff --git a/prism/Services/InlineNotesShareHelper.swift b/prism/Services/InlineNotesShareHelper.swiftindex 08cfad7..757bb18 100644--- a/prism/Services/InlineNotesShareHelper.swift+++ b/prism/Services/InlineNotesShareHelper.swift@@ -55,7 +55,8 @@ enum InlineNotesShareHelper {             rawSource: session.content,             blocks: session.parsedBlocks,             username: settings.exportUsername,-            includeResolved: settings.exportIncludeResolved+            includeResolved: settings.exportIncludeResolved,+            showHTMLComments: settings.showHTMLComments         )         let displayName = notesManager.documentNotes?.displayName ?? "document.md"         let baseName = displayName.hasSuffix(".md")
prism/Services/MarkdownBlockParser.swift Modified +102 / -0
diff --git a/prism/Services/MarkdownBlockParser.swift b/prism/Services/MarkdownBlockParser.swiftindex 48ed806..f41cdd3 100644--- a/prism/Services/MarkdownBlockParser.swift+++ b/prism/Services/MarkdownBlockParser.swift@@ -96,6 +96,68 @@ enum MarkdownBlockParser: Sendable {         )     } +    /// Strips HTML comments (`<!--...-->`) that appear inside markdown link+    /// labels — the `[...]` portion of `[label](url)`, `[label][ref]`, or the+    /// shortcut `[label]` form. Comments OUTSIDE the brackets are preserved so+    /// the Textual inline extension can still render them (Req 4.5).+    ///+    /// Comments inside the `(url)` portion are left untouched: swift-markdown+    /// already treats them as part of the URL, and a valid URL cannot+    /// legitimately contain an HTML comment.+    ///+    /// Implementation: regex over `\[([^\]]*)\]` with a nested replace of+    /// `<!--[\s\S]*?-->` against the captured label. The pattern matches+    /// reference-form (`[label][ref]`), inline-form (`[label](url)`), and+    /// shortcut-form (`[label]`) link labels.+    /// 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 = {+        // swiftlint:disable:next force_try+        try! NSRegularExpression(pattern: #"\[([^\]]*)\]"#)+    }()++    nonisolated static func stripCommentsInsideLinkLabels(_ markdown: String) -> String {+        // Match any bracketed label `[...]`. We accept any content up to the+        // first closing `]` (links cannot legitimately contain a `]` in their+        // label without escaping, which markdown source rarely uses).+        // `\[((?:[^\[\]]|\[[^\]]*\])*)\]` would be stricter but unnecessary+        // — the comment regex is anchored inside the captured label only.++        let commentRegex = HTMLCommentStripping.inlineCommentRegex++        let nsInput = markdown as NSString+        let matches = linkLabelRegex.matches(in: markdown, range: NSRange(location: 0, length: nsInput.length))++        // Apply replacements right-to-left so earlier ranges stay valid.+        var result = markdown+        for match in matches.reversed() {+            guard match.numberOfRanges >= 2 else { continue }+            let labelRange = match.range(at: 1)+            guard labelRange.location != NSNotFound else { continue }++            let nsResult = result as NSString+            // Guard against any drift caused by simultaneous text mutation:+            // the right-to-left order means previous mutations were all to the+            // RIGHT of `match`, so the original NSRange is still valid against+            // the current `result`.+            guard labelRange.location + labelRange.length <= nsResult.length else { continue }++            let label = nsResult.substring(with: labelRange)+            let strippedLabel = commentRegex.stringByReplacingMatches(+                in: label,+                range: NSRange(location: 0, length: (label as NSString).length),+                withTemplate: ""+            )++            if strippedLabel != label {+                result = nsResult.replacingCharacters(in: labelRange, with: strippedLabel)+            }+        }++        return result+    }+     /// Protects blank lines inside `<details>` blocks from terminating the HTML block.     ///     /// Delegates to `CodeFenceHelper.protectDetailsBlankLines` with context validation@@ -467,20 +529,32 @@ enum MarkdownBlockParser: Sendable {                 return detailsBlock             } -            // Try to parse as an HTML image construct (Req 5.1-5.7)+            // Try to parse as an HTML image construct (Req 5.1-5.7).+            // HTML image alt/title get author-comment stripping so derived+            // accessibility text never leaks `<!--…-->` markers (Req 4.5).             if let parsedImage = HTMLImageParser.parse(restoredHTML) {                 return .image(                     source: parsedImage.source,-                    alt: parsedImage.alt,-                    title: parsedImage.title,+                    alt: HTMLCommentStripping.strip(parsedImage.alt),+                    title: parsedImage.title.map { HTMLCommentStripping.strip($0) },                     link: nil,                     width: parsedImage.width,                     height: parsedImage.height                 )             } -            // Fall back to raw HTML display-            return .html(content: restoredHTML)+            // Comment-only HTML blocks become .htmlComment (Req 2.2).+            if let commentText = HTMLCommentParser.parseBlock(restoredHTML) {+                return .htmlComment(rawText: commentText)+            }++            // Fall back to raw HTML display. Strip comment markers so+            // mixed-content blocks never leak `<!--…-->` text regardless of+            // the toggle (Decision 16). HTMLCommentStripping returns empty+            // for a comment-only input — but that case has already been+            // handled by HTMLCommentParser above, so we don't need to+            // re-check.+            return .html(content: HTMLCommentStripping.strip(restoredHTML))          default:             return nil@@ -495,14 +569,16 @@ enum MarkdownBlockParser: Sendable {     }      nonisolated static func convertParagraph(_ paragraph: Paragraph) -> MarkdownBlock? {-        // Check for image-only paragraph (Req 1.1)+        // Check for image-only paragraph (Req 1.1). Image alt and title are+        // stripped of `<!--…-->` markers at parse time so derived+        // accessibility text never includes raw comment markers (Req 4.5).         if paragraph.childCount == 1 {             // Standalone image: ![alt](src "title")             if let image = paragraph.child(at: 0) as? Markdown.Image {                 return .image(                     source: image.source ?? "",-                    alt: image.plainText,-                    title: image.title,+                    alt: HTMLCommentStripping.strip(image.plainText),+                    title: image.title.map { HTMLCommentStripping.strip($0) },                     link: nil                 )             }@@ -512,8 +588,8 @@ enum MarkdownBlockParser: Sendable {                let image = link.child(at: 0) as? Markdown.Image {                 return .image(                     source: image.source ?? "",-                    alt: image.plainText,-                    title: image.title,+                    alt: HTMLCommentStripping.strip(image.plainText),+                    title: image.title.map { HTMLCommentStripping.strip($0) },                     link: link.destination                 )             }@@ -522,8 +598,13 @@ enum MarkdownBlockParser: Sendable {         // Existing paragraph conversion (Req 1.3 — inline images stay in paragraph)         let markdown = paragraph.format()         let cleanedMarkdown = stripEmptyAnchors(markdown)+        // Strip HTML comments from inside markdown link labels so the+        // Textual inline extension never sees them inside `[…]` brackets+        // (Req 4.5, Decision 18). Outside-link comments stay in the+        // markdown for the inline extension to handle.+        let labelStripped = stripCommentsInsideLinkLabels(cleanedMarkdown)         // Trim leading/trailing whitespace to prevent extra vertical space-        let trimmedMarkdown = cleanedMarkdown.trimmingCharacters(in: .whitespacesAndNewlines)+        let trimmedMarkdown = labelStripped.trimmingCharacters(in: .whitespacesAndNewlines)         // Skip empty paragraphs         guard !trimmedMarkdown.isEmpty else {             return nil
prism/Services/NotesExporter.swift Modified +24 / -8
diff --git a/prism/Services/NotesExporter.swift b/prism/Services/NotesExporter.swiftindex 3def036..5a92321 100644--- a/prism/Services/NotesExporter.swift+++ b/prism/Services/NotesExporter.swift@@ -29,7 +29,8 @@ struct NotesExporter {         orphanedNotes: [BlockNote],         blocks: [MarkdownBlock],         username: String = "",-        includeResolved: Bool = false+        includeResolved: Bool = false,+        showHTMLComments: Bool = false     ) -> String {         guard let notes = documentNotes else { return "" } @@ -48,7 +49,13 @@ struct NotesExporter {         var exportedCount = 0         if !docLevelNotes.isEmpty {             output += "## Document Notes\n\n"-            exportedCount += formatThreadGroups(docLevelNotes, into: &output, exportedCount: 0, username: username)+            exportedCount += formatThreadGroups(+                docLevelNotes,+                into: &output,+                exportedCount: 0,+                username: username,+                showHTMLComments: showHTMLComments+            )         }          // Requirement 11.5: Export in document order (by block position)@@ -57,7 +64,13 @@ struct NotesExporter {             guard !activeNotes.isEmpty else { continue }              // Requirement 9.4s: Group by thread, export root + replies together-            exportedCount += formatThreadGroups(activeNotes, into: &output, exportedCount: exportedCount, username: username)+            exportedCount += formatThreadGroups(+                activeNotes,+                into: &output,+                exportedCount: exportedCount,+                username: username,+                showHTMLComments: showHTMLComments+            )         }          // Requirement 11.9: Orphaned notes in separate section, ordered by creation date@@ -69,7 +82,7 @@ struct NotesExporter {             output += "## Orphaned Notes\n\n"             output += "_These notes could not be matched to current document content._\n\n" -            _ = formatThreadGroups(activeOrphans, into: &output, exportedCount: 0, username: username)+            _ = formatThreadGroups(activeOrphans, into: &output, exportedCount: 0, username: username, showHTMLComments: showHTMLComments)         }          return output@@ -125,7 +138,8 @@ struct NotesExporter {         _ notes: [BlockNote],         into output: inout String,         exportedCount: Int,-        username: String+        username: String,+        showHTMLComments: Bool     ) -> Int {         let groups = NoteGrouping.groupIntoThreads(notes)         var count = 0@@ -133,9 +147,9 @@ struct NotesExporter {             if exportedCount + count > 0 {                 output += "---\n\n"             }-            output += formatNote(group.root, username: username)+            output += formatNote(group.root, username: username, showHTMLComments: showHTMLComments)             for reply in group.replies.sorted(by: { $0.createdAt < $1.createdAt }) {-                output += formatNote(reply, indent: "> ", username: username)+                output += formatNote(reply, indent: "> ", username: username, showHTMLComments: showHTMLComments)             }             count += 1         }@@ -149,7 +163,12 @@ struct NotesExporter {     ///   - indent: Prefix for reply indentation (e.g., `"> "` for blockquote). Empty for root/standalone.     ///   - username: Fallback author for user-created notes (where `note.author` is nil).     /// - Requirements: 11.4, 9.1s-9.5s-    private static func formatNote(_ note: BlockNote, indent: String = "", username: String = "") -> String {+    private static func formatNote(+        _ note: BlockNote,+        indent: String = "",+        username: String = "",+        showHTMLComments: Bool = false+    ) -> String {         var output = ""         let blockquoteContinuation = indent.isEmpty ? "" : ">" @@ -158,10 +177,13 @@ struct NotesExporter {             output += "### \(heading)\n\n"         } -        // Context quote as blockquote (root/standalone only)+        // Context quote as blockquote (root/standalone only). HTML comments+        // captured at note-creation time are filtered per the current toggle+        // so the export honours Req 6.2 regardless of when the note was made.         if indent.isEmpty {             let prefix = note.isTableRowNote ? "[Table row] " : ""-            output += "> \(prefix)\(note.contextQuote)\n\n"+            let quote = HTMLCommentExport.flatten(note.contextQuote, visible: showHTMLComments)+            output += "> \(prefix)\(quote)\n\n"         }          // Author and timestamp line (Reqs 9.1s, 9.2s)
prism/Services/NotesManager.swift Modified +18 / -2
diff --git a/prism/Services/NotesManager.swift b/prism/Services/NotesManager.swiftindex 736f737..ebeda0b 100644--- a/prism/Services/NotesManager.swift+++ b/prism/Services/NotesManager.swift@@ -934,8 +934,14 @@ extension NotesManager {     ///   - blocks: Current document blocks for ordering.     ///   - username: The configured export username, used as fallback author for user-created notes.     ///   - includeResolved: Whether to include resolved notes in the export.+    ///   - showHTMLComments: Whether to render HTML comments captured in context quotes; false strips them per Req 6.2.     /// - Returns: Markdown string ready for sharing.-    func exportMarkdown(blocks: [MarkdownBlock], username: String = "", includeResolved: Bool = false) -> String {+    func exportMarkdown(+        blocks: [MarkdownBlock],+        username: String = "",+        includeResolved: Bool = false,+        showHTMLComments: Bool = false+    ) -> String {         // Merge user and imported notes for export         var allAnchored = anchoredNotes         for (blockId, notes) in importedNotes {@@ -948,7 +954,8 @@ extension NotesManager {             orphanedNotes: orphanedNotes,             blocks: blocks,             username: username,-            includeResolved: includeResolved+            includeResolved: includeResolved,+            showHTMLComments: showHTMLComments         )     } @@ -962,20 +969,27 @@ extension NotesManager {     ///   - blocks: Current document blocks (reserved for future use).     ///   - username: The configured username for the comment author tag.     ///   - includeResolved: Whether to include resolved notes in the export.+    ///   - showHTMLComments: When `true`, author HTML comments+    ///     (`<!--…-->`) in the source are surfaced as `Comment: <inner>`+    ///     lines in the exported text. When `false` (default), the+    ///     markers and their content are stripped so the export mirrors+    ///     the on-screen rendering (render-html-comments Req 6.1 / 6.2).     /// - Returns: Markdown string with notes embedded as comment blockquotes.     func exportWithInlineNotes(         rawSource: String,         blocks: [MarkdownBlock],         username: String,-        includeResolved: Bool = false+        includeResolved: Bool = false,+        showHTMLComments: Bool = false     ) -> String {-        InlineNotesExporter.export(+        let exported = InlineNotesExporter.export(             rawSource: rawSource,             userNotes: anchoredNotes,             importedNotes: importedNotes,             username: username,             includeResolved: includeResolved         )+        return HTMLCommentExport.flatten(exported, visible: showHTMLComments)     } } 
prism/Services/SearchCoordinator.swift Modified +34 / -0
diff --git a/prism/Services/SearchCoordinator.swift b/prism/Services/SearchCoordinator.swiftindex 1158ea9..a01b27a 100644--- a/prism/Services/SearchCoordinator.swift+++ b/prism/Services/SearchCoordinator.swift@@ -27,6 +27,12 @@ final class SearchCoordinator: SearchActions {     /// Provides current footnote data for including footnote content in search.     var footnoteDataProvider: @MainActor () -> FootnoteData = { .empty } +    /// Provides the current `AppSettings.showHTMLComments` state so the+    /// match-count and highlight passes can include/exclude rendered HTML+    /// comment text in lockstep with the user's preference+    /// (render-html-comments Req 5.1 / 5.2).+    var showHTMLCommentsProvider: @MainActor () -> Bool = { false }+     /// Called after match index changes, before VoiceOver announcement.     /// DocumentSession implements this as expandAncestorsForCurrentMatch(),     /// which coordinates SectionCollapseManager and DetailsExpansionCoordinator.@@ -260,7 +266,10 @@ final class SearchCoordinator: SearchActions {             return         } -        let footnoteData = footnoteDataProvider()+        let context = SearchContext(+            showHTMLComments: showHTMLCommentsProvider(),+            footnoteData: footnoteDataProvider()+        )         var updatedCounts = Array(repeating: 0, count: blocks.count)         var totalMatches = 0 @@ -268,7 +277,7 @@ final class SearchCoordinator: SearchActions {             let count = SearchService.countMatches(                 query: activeSearchQuery,                 in: blocks[index],-                footnoteData: footnoteData+                context: context             )             updatedCounts[index] = count             totalMatches += count@@ -287,6 +296,27 @@ final class SearchCoordinator: SearchActions {         }     } +    /// Re-runs the match-count pass against the current query and the+    /// current ``SearchContext`` (built from `footnoteDataProvider` and+    /// `showHTMLCommentsProvider`). Resets the current-match cursor to the+    /// first match in document order, or to `nil` (0-of-0) when no matches+    /// remain. Wired by the view layer to `onChange(of:+    /// settings.showHTMLComments)` so the search overlay refreshes in+    /// lockstep with the toggle (render-html-comments Req 5.4).+    func recomputeAfterVisibilityChange() {+        recomputeMatchCounts()+        // Reset the cursor to the first match in document order (Req 5.4).+        // `recomputeMatchCounts` already clamped the previous index to the+        // new totals; we still pin to 0 so the user lands on the first+        // remaining match instead of inheriting an arbitrary previous+        // position.+        if totalMatchCount > 0 {+            currentGlobalMatchIndex = 0+        } else {+            currentGlobalMatchIndex = nil+        }+    }+     /// Returns the current match index within a specific block, if applicable.     ///     /// This method determines whether the globally selected match falls within
prism/Services/SearchService.swift Modified +53 / -0
diff --git a/prism/Services/SearchService.swift b/prism/Services/SearchService.swiftindex 3efd3df..d7b8f88 100644--- a/prism/Services/SearchService.swift+++ b/prism/Services/SearchService.swift@@ -32,17 +32,20 @@ enum SearchService {     /// silently desync the count from the rendered highlights.     static let searchCompareOptions: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive] -    /// Performs search across document blocks.+    /// Performs search across document blocks honouring the supplied+    /// ``SearchContext`` — including `showHTMLComments` so the toggle+    /// gates which content contributes to results+    /// (render-html-comments Req 5.1 / 5.2).     ///     /// - Parameters:     ///   - query: The search query string.     ///   - blocks: The parsed document blocks.-    ///   - footnoteData: Footnote data for including footnote content in search.+    ///   - context: Search visibility/footnote context.     /// - Returns: Array of search matches ordered by document position.     static func performSearch(         query: String,         in blocks: [MarkdownBlock],-        footnoteData: FootnoteData = .empty+        context: SearchContext     ) -> [SearchMatch] {         guard query.count >= minimumQueryLength else {             return []@@ -51,7 +54,7 @@ enum SearchService {         var matches: [SearchMatch] = []          for (index, block) in blocks.enumerated() {-            let searchableText = block.searchableText(with: footnoteData)+            let searchableText = block.searchableText(in: context)             guard !searchableText.isEmpty else { continue }              // Track match index within this block@@ -83,22 +86,42 @@ enum SearchService {         return matches     } -    /// Counts the number of matches in a specific block without creating match objects.-    ///-    /// This is more efficient than `performSearch` when you only need the count,-    /// such as during rendering when building up match count arrays.-    ///-    /// - Parameters:-    ///   - query: The search query string.-    ///   - block: The markdown block to search within.-    ///   - footnoteData: Footnote data for including footnote content in search.-    /// - Returns: The number of matches found in the block.+    /// Legacy entry point used by call sites that have not yet migrated to+    /// the ``SearchContext`` overload. Delegates with a default context+    /// that hides HTML comments and uses the supplied footnote data.+    static func performSearch(+        query: String,+        in blocks: [MarkdownBlock],+        footnoteData: FootnoteData = .empty+    ) -> [SearchMatch] {+        performSearch(+            query: query,+            in: blocks,+            context: SearchContext(showHTMLComments: false, footnoteData: footnoteData)+        )+    }++    /// Counts matches in `block` honouring the supplied ``SearchContext``.+    static func countMatches(+        query: String,+        in block: MarkdownBlock,+        context: SearchContext+    ) -> Int {+        countMatches(query: query, in: block.searchableText(in: context))+    }++    /// Legacy match-counting entry point. Delegates with a default context+    /// that hides HTML comments and uses the supplied footnote data.     static func countMatches(         query: String,         in block: MarkdownBlock,         footnoteData: FootnoteData = .empty     ) -> Int {-        countMatches(query: query, in: block.searchableText(with: footnoteData))+        countMatches(+            query: query,+            in: block,+            context: SearchContext(showHTMLComments: false, footnoteData: footnoteData)+        )     }      /// Counts case-insensitive, diacritic-insensitive matches of `query` in `text`.
prism/Services/TOCCoordinator.swift Modified +5 / -0
diff --git a/prism/Services/TOCCoordinator.swift b/prism/Services/TOCCoordinator.swiftindex 2655818..0c59fe5 100644--- a/prism/Services/TOCCoordinator.swift+++ b/prism/Services/TOCCoordinator.swift@@ -103,7 +103,10 @@ final class TOCCoordinator {                 let entry = TOCEntry(                     id: "\(block.id)-\(runningIndex)",                     level: level,-                    text: FootnoteStripping.strip(text),+                    // HTML comment stripping runs before footnote stripping+                    // because comments may contain `[^x]` tokens that look+                    // like footnote references (Decision 11, Decision 19).+                    text: FootnoteStripping.strip(HTMLCommentStripping.strip(text)),                     blockIndex: runningIndex,                     detailsAncestorIds: ancestorDetailIds                 )
prism/Settings/AppSettings.swift Modified +20 / -0
diff --git a/prism/Settings/AppSettings.swift b/prism/Settings/AppSettings.swiftindex ecd624b..313bf43 100644--- a/prism/Settings/AppSettings.swift+++ b/prism/Settings/AppSettings.swift@@ -69,6 +69,9 @@ final class AppSettings {     @ObservationIgnored     @AppStorage("rawSourceSyntaxHighlighting") private var rawSourceSyntaxHighlightingRaw: Bool = true +    @ObservationIgnored+    @AppStorage("showHTMLComments") private var showHTMLCommentsRaw: Bool = false+     @ObservationIgnored     @AppStorage("leftSidebarExpanded") private var leftSidebarExpandedRaw: Bool = true @@ -186,6 +189,21 @@ final class AppSettings {         }     } +    /// Whether to render HTML comments (`<!-- ... -->`) inline and as+    /// stand-alone annotations (render-html-comments Req 1.1–1.6).+    /// Defaults to `false` (comments hidden) — Decision 3.+    var showHTMLComments: Bool {+        get {+            access(keyPath: \.showHTMLComments)+            return showHTMLCommentsRaw+        }+        set {+            withMutation(keyPath: \.showHTMLComments) {+                showHTMLCommentsRaw = newValue+            }+        }+    }+     /// Whether the left sidebar (TOC) is expanded on iPad/macOS.     var leftSidebarExpanded: Bool {         get {@@ -476,6 +494,7 @@ final class AppSettings {         darkTheme: PrismTheme = .prismDark,         rawSourceWrapLines: Bool = true,         rawSourceSyntaxHighlighting: Bool = true,+        showHTMLComments: Bool = false,         leftSidebarExpanded: Bool = true,         rightSidebarExpanded: Bool = true,         leftSidebarWidth: CGFloat = -1,@@ -495,6 +514,7 @@ final class AppSettings {         settings.darkTheme = darkTheme         settings.rawSourceWrapLines = rawSourceWrapLines         settings.rawSourceSyntaxHighlighting = rawSourceSyntaxHighlighting+        settings.showHTMLComments = showHTMLComments         settings.leftSidebarExpanded = leftSidebarExpanded         settings.rightSidebarExpanded = rightSidebarExpanded         if leftSidebarWidth != -1 {
prism/Settings/SettingsView.swift Modified +24 / -0
diff --git a/prism/Settings/SettingsView.swift b/prism/Settings/SettingsView.swiftindex 4de8b24..586354c 100644--- a/prism/Settings/SettingsView.swift+++ b/prism/Settings/SettingsView.swift@@ -42,7 +42,7 @@ struct SettingsView: View {     #if os(macOS)     /// Settings categories for the macOS sidebar.     enum SettingsCategory: String, CaseIterable, Identifiable {-        case appearance, notes, rawSource, support, about+        case appearance, notes, markdownRendering, rawSource, support, about         #if DEBUG         case debug         #endif@@ -53,6 +53,7 @@ struct SettingsView: View {             switch self {             case .appearance: "Appearance"             case .notes: "Notes"+            case .markdownRendering: "Markdown Rendering"             case .rawSource: "Raw Source"             case .support: "Support"             case .about: "About"@@ -66,6 +67,7 @@ struct SettingsView: View {             switch self {             case .appearance: "paintbrush"             case .notes: "note.text"+            case .markdownRendering: "doc.richtext"             case .rawSource: "doc.plaintext"             case .support: "heart"             case .about: "info.circle"@@ -92,6 +94,8 @@ struct SettingsView: View {                     macOSDetailView { appearanceSection }                 case .notes:                     macOSDetailView { notesSection }+                case .markdownRendering:+                    macOSDetailView { markdownRenderingSection }                 case .rawSource:                     macOSDetailView { rawSourceSection }                 case .support:@@ -148,6 +152,7 @@ struct SettingsView: View {             Form {                 appearanceSection                 notesSection+                markdownRenderingSection                 rawSourceSection                 supportSection                 aboutSection@@ -337,6 +342,23 @@ struct SettingsView: View {         }     } +    // MARK: - Markdown Rendering Section++    /// Markdown rendering toggles (render-html-comments Req 1.1–1.6).+    /// Today the section contains the HTML comments toggle; future+    /// rendering-related preferences belong here too.+    private var markdownRenderingSection: some View {+        Section {+            Toggle("Show HTML comments", isOn: $settings.showHTMLComments)+                .accessibilityLabel(LocalizedStringKey("Show HTML comments"))+                .accessibilityValue(settings.showHTMLComments ? LocalizedStringKey("On") : LocalizedStringKey("Off"))+        } header: {+            Text("Markdown Rendering")+        } footer: {+            Text("Reveal author HTML comments as dimmer annotations. When off, comments are hidden entirely.")+        }+    }+     // MARK: - Raw Source Section      /// Raw source view settings section (Req 1.5, 3.1)
prism/Views/DocumentReaderView.swift Modified +14 / -0
diff --git a/prism/Views/DocumentReaderView.swift b/prism/Views/DocumentReaderView.swiftindex 72078ab..d2344ce 100644--- a/prism/Views/DocumentReaderView.swift+++ b/prism/Views/DocumentReaderView.swift@@ -329,6 +329,17 @@ struct DocumentReaderView: View {             .onAppear {                 leftSidebarVisible = settings.leftSidebarExpanded                 rightSidebarVisible = settings.rightSidebarExpanded+                // Wire the show-HTML-comments provider so the search+                // coordinator's match-count pass honours the toggle.+                // Captured weakly via the binding inside SearchCoordinator.+                session.search.showHTMLCommentsProvider = { [settings] in+                    settings.showHTMLComments+                }+            }+            // Refresh search totals and cursor when the user toggles HTML+            // comment visibility while a query is active (Req 5.4).+            .onChange(of: settings.showHTMLComments) { _, _ in+                session.search.recomputeAfterVisibilityChange()             }             #if os(macOS)             // Auto-collapse sidebars when macOS window gets too narrow (T-495)@@ -460,7 +471,8 @@ struct DocumentReaderView: View {             rawSource: session.content,             blocks: session.parsedBlocks,             username: settings.exportUsername,-            includeResolved: settings.exportIncludeResolved+            includeResolved: settings.exportIncludeResolved,+            showHTMLComments: settings.showHTMLComments         )          let panel = NSSavePanel()
prism/Views/HTMLCommentBlockView.swift Added +48 / -0
diff --git a/prism/Views/HTMLCommentBlockView.swift b/prism/Views/HTMLCommentBlockView.swiftnew file mode 100644index 0000000..49e0886--- /dev/null+++ b/prism/Views/HTMLCommentBlockView.swift@@ -0,0 +1,48 @@+//+//  HTMLCommentBlockView.swift+//  prism+//+//  Created by Claude on 22/5/2026.+//++import SwiftUI++/// Renders a stand-alone HTML comment block (``MarkdownBlock/htmlComment``).+///+/// When ``AppSettings/showHTMLComments`` is OFF, this view produces no+/// output (Req 2.1). When ON, the inner text renders as a dimmer, italic+/// annotation prefixed by an `info.circle` SF Symbol (Req 2.2–2.5,+/// Decision 5).+///+/// The text is rendered via `Text(verbatim:)` so author content like+/// `"%d items"` or `"settings.section.appearance"` never accidentally+/// triggers a localisation lookup (CLAUDE.md ban on `Text(LocalizedStringKey(...))`+/// for non-literal author content).+///+/// Reuses ``ThemeColors/textSecondary`` for the foreground colour — the+/// established dim text token across every supported theme — so the+/// annotation meets WCAG 2.1 AA contrast against the document background+/// in every theme (Req 7.4).+struct HTMLCommentBlockView: View {+    let rawText: String++    @Environment(AppSettings.self) private var settings+    @Environment(\.themeColors) private var colors++    var body: some View {+        if settings.showHTMLComments {+            HStack(alignment: .firstTextBaseline, spacing: 4) {+                Image(systemName: "info.circle")+                    .imageScale(.medium)+                    .accessibilityHidden(true)+                Text(verbatim: rawText)+                    .italic()+            }+            .foregroundStyle(colors.textSecondary)+            .accessibilityElement(children: .combine)+            .accessibilityLabel(String(localized: "Comment, \(rawText)"))+        } else {+            EmptyView()+        }+    }+}
prism/Views/HighlightedInlineText.swift Modified +140 / -0
diff --git a/prism/Views/HighlightedInlineText.swift b/prism/Views/HighlightedInlineText.swiftindex 64d8a04..8f4d09b 100644--- a/prism/Views/HighlightedInlineText.swift+++ b/prism/Views/HighlightedInlineText.swift@@ -68,19 +68,30 @@ struct HighlightedInlineText: View {                 // (container-level .font() doesn't propagate into Textual's pipeline)                 InlineText(                     markdown: markdown,-                    syntaxExtensions: footnoteSyntaxExtensions+                    syntaxExtensions: inlineSyntaxExtensions                 )                     .font(font ?? scaledFont)                     .textual.inlineStyle(resolvedInlineStyle)             }         }-        .task(id: TaskIdentifier(markdown: markdown, searchQuery: searchQuery, currentIndex: currentMatchIndexInText)) {+        .task(id: TaskIdentifier(+            markdown: markdown,+            searchQuery: searchQuery,+            currentIndex: currentMatchIndexInText,+            showHTMLComments: settings.showHTMLComments+        )) {             await updateHighlights()         }         .onChange(of: matchCount) { _, newCount in             onMatchCountChanged?(newCount)         }         .modifier(FootnoteAccessibilityModifier(markdown: markdown, footnoteData: footnoteData))+        .modifier(HTMLCommentAccessibilityModifier(+            markdown: markdown,+            visible: settings.showHTMLComments,+            appearance: htmlCommentAppearance,+            footnoteData: footnoteData+        ))     }      /// Scaled body font for the search-highlighted Text path.@@ -94,9 +105,40 @@ struct HighlightedInlineText: View {         inlineStyle ?? TextualThemeAdapter(colors: colors).inlineStyle     } -    private var footnoteSyntaxExtensions: [AttributedStringMarkdownParser.SyntaxExtension] {-        guard !footnoteData.isEmpty else { return [] }-        return [.footnoteReferences(provider: footnoteData)]+    /// Syntax extensions applied to the inline markdown pipeline.+    ///+    /// The HTML comments extension runs unconditionally — both visible and+    /// invisible states have work to do (`visible == false` strips+    /// `<!--…-->` ranges; `visible == true` replaces them with the styled+    /// run + `HTMLCommentRangeAttribute` tagging). The footnote extension+    /// only runs when the paragraph has resolvable footnote data.+    private var inlineSyntaxExtensions: [AttributedStringMarkdownParser.SyntaxExtension] {+        var exts: [AttributedStringMarkdownParser.SyntaxExtension] = []+        if !footnoteData.isEmpty {+            exts.append(.footnoteReferences(provider: footnoteData))+        }+        exts.append(.htmlComments(+            visible: settings.showHTMLComments,+            appearance: htmlCommentAppearance+        ))+        return exts+    }++    /// Appearance configuration for the inline HTML comment extension.+    ///+    /// Reuses the same dim foreground colour (`textSecondary`) as+    /// `HTMLCommentBlockView` so the inline and block paths render with+    /// matching tone in every theme (Req 7.4 / design "Visual parity").+    /// The localised accessibility labels match the catalog entries used by+    /// `HTMLCommentAccessibilityModifier`.+    private var htmlCommentAppearance: HTMLCommentAppearance {+        HTMLCommentAppearance(+            foregroundColor: colors.textSecondary,+            prefixSymbolName: "info.circle",+            italic: true,+            accessibilityCommentLabel: String(localized: "Comment"),+            accessibilityEndCommentLabel: String(localized: "End comment")+        )     }      private func updateHighlights() async {@@ -106,9 +148,15 @@ struct HighlightedInlineText: View {             return         } -        // Parse the markdown to an AttributedString with footnote extensions+        // Parse the markdown to an AttributedString with the full+        // extension list. HTML comments are transformed by the+        // `.htmlComments` extension FIRST (whether visible or not) so the+        // search highlighter only sees the post-transformation string.+        // This ensures hidden comments don't produce spurious search+        // ranges and visible comments are highlightable inside their+        // styled run.         let parser = AttributedStringMarkdownParser.inlineMarkdown(-            syntaxExtensions: footnoteSyntaxExtensions+            syntaxExtensions: inlineSyntaxExtensions         )         guard let parsed = try? parser.attributedString(for: markdown) else {             highlightedContent = nil@@ -116,7 +164,8 @@ struct HighlightedInlineText: View {             return         } -        // Apply search highlights (adds SearchHighlightAttribute)+        // Apply search highlights (adds SearchHighlightAttribute) on the+        // already-transformed AttributedString.         let result = SearchHighlightApplicator.applyHighlights(             to: parsed,             query: searchQuery,@@ -222,10 +271,14 @@ extension HighlightedInlineText {     /// Identifier for the highlight computation task.     ///     /// Changes to any of these values trigger recomputation of highlights.-    private struct TaskIdentifier: Equatable {+    /// `showHTMLComments` is part of the identifier so flipping the+    /// preference while a search query is active re-runs the inline+    /// pipeline and refreshes the match count (Req 5.4).+    struct TaskIdentifier: Equatable {         let markdown: String         let searchQuery: String         let currentIndex: Int?+        let showHTMLComments: Bool     } } @@ -270,6 +323,70 @@ private struct FootnoteAccessibilityModifier: ViewModifier {     } } +// MARK: - HTML Comment Accessibility Modifier++/// Overrides the paragraph's accessibility label with a composed string of+/// the form `<before> Comment <c1> ... <cN> End comment <after>` when the+/// paragraph contains one or more inline HTML comments (Decision 14).+///+/// The modifier parses the markdown source via the same inline pipeline as+/// the rendered output and walks the resulting `HTMLCommentRangeAttribute`+/// runs to compose the label. When no comment runs exist, the modifier is+/// a no-op so VoiceOver continues to derive narration from the rendered+/// text. The pattern mirrors `FootnoteAccessibilityModifier`.+private struct HTMLCommentAccessibilityModifier: ViewModifier {+    let markdown: String+    let visible: Bool+    let appearance: HTMLCommentAppearance+    let footnoteData: FootnoteData++    @ViewBuilder+    func body(content: Content) -> some View {+        // Cheap exit when the source has no comments at all — most+        // paragraphs hit this path, so we don't want to parse markdown+        // every layout pass when there is nothing to compose.+        if !markdown.contains("<!--") {+            content+        } else {+            let analysis = computeAnalysis()+            if analysis.hasComments {+                content+                    // `Text(verbatim:)` is intentional: `rebuiltLabel` is+                    // dynamic document prose stitched with already-localised+                    // "Comment" / "End comment" phrases. Routing through+                    // `LocalizedStringKey` would double-translate.+                    .accessibilityLabel(Text(verbatim: analysis.rebuiltLabel))+            } else {+                content+            }+        }+    }++    /// Parses the markdown with the same syntax extensions used for+    /// rendering, then composes the accessibility label from the resulting+    /// `HTMLCommentRangeAttribute` runs. Reuses the parent's+    /// `htmlCommentAppearance` so the accessibility-side appearance cannot+    /// drift from the render-side appearance.+    private func computeAnalysis() -> HTMLCommentAccessibility.Analysis {+        var extensions: [AttributedStringMarkdownParser.SyntaxExtension] = []+        if !footnoteData.isEmpty {+            extensions.append(.footnoteReferences(provider: footnoteData))+        }+        extensions.append(.htmlComments(visible: visible, appearance: appearance))+        let parser = AttributedStringMarkdownParser.inlineMarkdown(+            syntaxExtensions: extensions+        )+        guard let parsed = try? parser.attributedString(for: markdown) else {+            return HTMLCommentAccessibility.Analysis(hasComments: false, rebuiltLabel: "")+        }+        return HTMLCommentAccessibility.analyze(+            content: parsed,+            commentLabel: appearance.accessibilityCommentLabel,+            endCommentLabel: appearance.accessibilityEndCommentLabel+        )+    }+}+ // MARK: - Preview  #Preview("Basic Text") {
prism/Views/ListBlockView.swift Modified +13 / -0
diff --git a/prism/Views/ListBlockView.swift b/prism/Views/ListBlockView.swiftindex 1a26705..d0d9230 100644--- a/prism/Views/ListBlockView.swift+++ b/prism/Views/ListBlockView.swift@@ -66,6 +66,17 @@ struct ListBlockView: View {     /// Nil for clipboard content.     var documentURL: URL? +    /// Marker offset for ordered lists.+    ///+    /// When this list immediately follows a `.htmlComment` block that itself+    /// follows another ordered list, the offset is the previous list's+    /// item count so numbering continues across the comment (Req 4.4 with+    /// the implementation interpretation in Decision 17). The first item+    /// renders as `offset + 1`, the second as `offset + 2`, etc. Defaults+    /// to `0` for ordered lists with no predecessor and is ignored entirely+    /// for unordered lists.+    var ordinalOffset: Int = 0+     @Environment(\.themeColors) private var colors      /// Tracks match counts per item index (for items at this level).@@ -311,7 +322,7 @@ struct ListBlockView: View {      private func listMarker(for index: Int) -> String {         if ordered {-            return "\(index + 1)."+            return "\(index + 1 + ordinalOffset)."         } else {             return "•"         }
prism/Views/NotesPanel.swift Modified +10 / -2
diff --git a/prism/Views/NotesPanel.swift b/prism/Views/NotesPanel.swiftindex 08a05a7..61f4509 100644--- a/prism/Views/NotesPanel.swift+++ b/prism/Views/NotesPanel.swift@@ -41,9 +41,15 @@ struct NotesPanel: View {         notesManager: NotesManager,         blocks: [MarkdownBlock],         username: String,-        includeResolved: Bool = false+        includeResolved: Bool = false,+        showHTMLComments: Bool = false     ) -> String {-        notesManager.exportMarkdown(blocks: blocks, username: username, includeResolved: includeResolved)+        notesManager.exportMarkdown(+            blocks: blocks,+            username: username,+            includeResolved: includeResolved,+            showHTMLComments: showHTMLComments+        )     }      /// The notes manager providing note data.@@ -439,7 +445,8 @@ struct NotesPanel: View {                     notesManager: notesManager,                     blocks: blocks,                     username: settings.exportUsername,-                    includeResolved: settings.exportIncludeResolved+                    includeResolved: settings.exportIncludeResolved,+                    showHTMLComments: settings.showHTMLComments                 )                 ClipboardHelper.copy(markdown)                 storeManager.incrementExportCount()
prism/Views/Search/SearchOverlaySheet.swift Modified +10 / -0
diff --git a/prism/Views/Search/SearchOverlaySheet.swift b/prism/Views/Search/SearchOverlaySheet.swiftindex 06491bf..20a1c5f 100644--- a/prism/Views/Search/SearchOverlaySheet.swift+++ b/prism/Views/Search/SearchOverlaySheet.swift@@ -36,6 +36,7 @@ struct SearchOverlaySheet: View {      @FocusState private var isSearchFieldFocused: Bool     @Environment(\.themeColors) private var colors+    @Environment(AppSettings.self) private var settings      var body: some View {         NavigationStack {@@ -169,14 +170,19 @@ struct SearchOverlaySheet: View {             return []         } +        let context = SearchContext(+            showHTMLComments: settings.showHTMLComments,+            footnoteData: session.footnoteData+        )         let matches = SearchService.performSearch(             query: session.search.activeSearchQuery,-            in: session.parsedBlocks+            in: session.parsedBlocks,+            context: context         )          return matches.enumerated().map { index, match in             let block = session.parsedBlocks[match.blockIndex]-            let searchableText = block.searchableText+            let searchableText = block.searchableText(in: context)             let excerpt = extractExcerptForMatch(                 searchableText: searchableText,                 query: session.search.activeSearchQuery,
prism/Views/SharedBlockViews.swift Modified +10 / -0
diff --git a/prism/Views/SharedBlockViews.swift b/prism/Views/SharedBlockViews.swiftindex 09e9c74..d244fcb 100644--- a/prism/Views/SharedBlockViews.swift+++ b/prism/Views/SharedBlockViews.swift@@ -254,6 +254,13 @@ enum SharedBlockViews {         let headingPath = context.session.documentStructure.headingPath(forBlockId: block.id, sourceIndex: blockIndex)         // Block-level notes: imported comments anchored to the list block itself (not items)         let blockLevelNotes = context.notesManager.allNotes(for: block.id, headingPath: headingPath)+        // Continue numbering across an HTML comment block between two+        // ordered lists at the same level (Decision 17). For other shapes+        // this returns 0 and rendering is unaffected.+        let ordinalOffset = MarkdownBlock.ordinalOffsetForList(+            at: blockIndex,+            in: context.session.parsedBlocks+        )          VStack(alignment: .leading, spacing: 0) {             ListBlockView(@@ -285,7 +292,8 @@ enum SharedBlockViews {                     context.session.search.updateMatchCount(for: blockIndex, count: count)                 },                 reduceMotion: context.reduceMotion,-                documentURL: context.session.source.url+                documentURL: context.session.source.url,+                ordinalOffset: ordinalOffset             )             .searchHighlightColors(                 match: context.colors.searchMatchBackground,
prism/Views/SidebarNotesView.swift Modified +10 / -2
diff --git a/prism/Views/SidebarNotesView.swift b/prism/Views/SidebarNotesView.swiftindex 79823b0..ee20c1a 100644--- a/prism/Views/SidebarNotesView.swift+++ b/prism/Views/SidebarNotesView.swift@@ -36,9 +36,15 @@ struct SidebarNotesView: View {         notesManager: NotesManager,         blocks: [MarkdownBlock],         username: String,-        includeResolved: Bool = false+        includeResolved: Bool = false,+        showHTMLComments: Bool = false     ) -> String {-        notesManager.exportMarkdown(blocks: blocks, username: username, includeResolved: includeResolved)+        notesManager.exportMarkdown(+            blocks: blocks,+            username: username,+            includeResolved: includeResolved,+            showHTMLComments: showHTMLComments+        )     }      /// The notes manager providing note data.@@ -214,7 +220,8 @@ struct SidebarNotesView: View {                     notesManager: notesManager,                     blocks: blocks,                     username: settings.exportUsername,-                    includeResolved: settings.exportIncludeResolved+                    includeResolved: settings.exportIncludeResolved,+                    showHTMLComments: settings.showHTMLComments                 )                 ClipboardHelper.copy(markdown)                 storeManager.incrementExportCount()
prism/Views/TextualBlockView.swift Modified +3 / -0
diff --git a/prism/Views/TextualBlockView.swift b/prism/Views/TextualBlockView.swiftindex a90b4e9..5d12462 100644--- a/prism/Views/TextualBlockView.swift+++ b/prism/Views/TextualBlockView.swift@@ -154,6 +154,9 @@ struct TextualBlockView: View {         case .html(let content):             htmlView(content: content) +        case .htmlComment(let rawText):+            HTMLCommentBlockView(rawText: rawText)+         case .metadata(let content):             MetadataView(content: content, reduceMotion: reduceMotion) 
prismTests/AppSettingsTests.swift Modified +33 / -0
diff --git a/prismTests/AppSettingsTests.swift b/prismTests/AppSettingsTests.swiftindex 12124b2..e4cce4c 100644--- a/prismTests/AppSettingsTests.swift+++ b/prismTests/AppSettingsTests.swift@@ -548,4 +548,37 @@ struct ExportUsernameSanitizationTests {         #expect(settings.exportUsername == "Valid Name")         UserDefaults.standard.removeObject(forKey: "exportUsername")     }++    // MARK: - showHTMLComments Tests (render-html-comments Req 1.1–1.6)++    @Test("Default showHTMLComments is false")+    @MainActor+    func testDefaultShowHTMLCommentsIsFalse() {+        // Clear any leaked value from prior runs before reading the default.+        UserDefaults.standard.removeObject(forKey: "showHTMLComments")+        let settings = AppSettings()+        #expect(settings.showHTMLComments == false)+    }++    @Test("showHTMLComments round-trips through @AppStorage")+    @MainActor+    func testShowHTMLCommentsRoundTripsThroughAppStorage() {+        UserDefaults.standard.set(true, forKey: "showHTMLComments")+        let settings = AppSettings()+        #expect(settings.showHTMLComments == true)+        UserDefaults.standard.removeObject(forKey: "showHTMLComments")+    }++    @Test("Setting showHTMLComments updates value")+    @MainActor+    func testSettingShowHTMLCommentsUpdatesValue() {+        UserDefaults.standard.removeObject(forKey: "showHTMLComments")+        let settings = AppSettings()+        #expect(settings.showHTMLComments == false)+        settings.showHTMLComments = true+        #expect(settings.showHTMLComments == true)+        settings.showHTMLComments = false+        #expect(settings.showHTMLComments == false)+        UserDefaults.standard.removeObject(forKey: "showHTMLComments")+    } }
prismTests/HTMLCommentBlockViewTests.swift Added +70 / -0
diff --git a/prismTests/HTMLCommentBlockViewTests.swift b/prismTests/HTMLCommentBlockViewTests.swiftnew file mode 100644index 0000000..1509e42--- /dev/null+++ b/prismTests/HTMLCommentBlockViewTests.swift@@ -0,0 +1,70 @@+//+//  HTMLCommentBlockViewTests.swift+//  prismTests+//+//  Created by Claude on 22/5/2026.+//++import Foundation+import SwiftUI+import Testing+@testable import prism++/// Tests for `HTMLCommentBlockView`.+///+/// SwiftUI views that read `@Environment(AppSettings.self)` cannot have+/// their `body` evaluated outside of a SwiftUI rendering context (the+/// environment lookup traps when the value is absent). These tests cover+/// the construction invariants and content-handling rules that are+/// observable from the unit-test layer:+///+/// - The view constructs regardless of preference state (no init-time+///   environment read).+/// - The view accepts the inner-text shapes produced by+///   ``HTMLCommentParser/parseBlock(_:)`` — non-empty single-line,+///   multi-line, and content that resembles a localisation key or format+///   specifier (pins the `Text(verbatim:)` choice).+@Suite("HTML Comment Block View")+@MainActor+struct HTMLCommentBlockViewTests {++    @Test("View constructs with single-line content")+    func constructsWithSingleLineContent() {+        let view = HTMLCommentBlockView(rawText: "an author note")+        // Property reads on `View` shells that haven't been laid out are+        // fine; we just confirm the type exists and the value is stored.+        #expect(view.rawText == "an author note")+    }++    @Test("View constructs with multi-line content")+    func constructsWithMultiLineContent() {+        let raw = "line one\nline two\nline three"+        let view = HTMLCommentBlockView(rawText: raw)+        #expect(view.rawText == raw)+    }++    @Test(+        "View constructs with content that resembles a localisation key",+        arguments: ["%d items", "settings.section.appearance", "%@ count", "Show HTML comments"]+    )+    func constructsWithLocalisationKeyLikeContent(content: String) {+        // Pins the Text(verbatim:) choice: author content that looks like a+        // catalog key or a format specifier MUST NOT be interpreted as a+        // localisation key — that would either swallow text, crash, or+        // substitute unrelated translations. The view's `body` uses+        // `Text(verbatim:)` to defend against this; the test pins the+        // constructor accepts such content.+        let view = HTMLCommentBlockView(rawText: content)+        #expect(view.rawText == content)+    }++    @Test("View accepts minimal non-empty raw text matching the parser invariant")+    func acceptsMinimalNonEmptyRawText() {+        // HTMLCommentParser.parseBlock never emits an empty rawText —+        // empty / whitespace-only comments return nil and route to the+        // .html fall-back. The view should still construct with any+        // non-empty author content.+        let view = HTMLCommentBlockView(rawText: "x")+        #expect(view.rawText == "x")+    }+}
prismTests/HTMLCommentExportTests.swift Added +99 / -0
diff --git a/prismTests/HTMLCommentExportTests.swift b/prismTests/HTMLCommentExportTests.swiftnew file mode 100644index 0000000..eb760ab--- /dev/null+++ b/prismTests/HTMLCommentExportTests.swift@@ -0,0 +1,99 @@+//+//  HTMLCommentExportTests.swift+//  prismTests+//+//  Created by Claude on 22/5/2026.+//++import Foundation+import Testing+@testable import prism++/// Tests for `HTMLCommentExport.flatten(_:visible:)` and the+/// export-path integration that uses it.+///+/// `flatten` replaces each `<!--…-->` occurrence in the supplied text:+/// - with an empty string when `visible == false` (Req 6.2);+/// - with the localised plain-text prefix `Comment: <inner>` when+///   `visible == true` (Req 6.1 / 6.3, Decision 13).+@Suite("HTML Comment Export")+struct HTMLCommentExportTests {++    // MARK: - flatten(_:visible:)++    @Test("flatten with visible=false strips all comments")+    func flattenStripsCommentsWhenInvisible() {+        let input = "Before <!-- a note --> middle <!-- another --> end"+        let result = HTMLCommentExport.flatten(input, visible: false)+        #expect(!result.contains("<!--"), "expected markers stripped; got: \(result)")+        #expect(!result.contains("-->"), "expected markers stripped; got: \(result)")+        #expect(!result.contains("a note"), "expected inner text stripped; got: \(result)")+        #expect(!result.contains("another"), "expected inner text stripped; got: \(result)")+        #expect(result.contains("Before"))+        #expect(result.contains("middle"))+        #expect(result.contains("end"))+    }++    @Test("flatten with visible=true replaces comments with Comment: prefix")+    func flattenReplacesWithLocalisedPrefixWhenVisible() {+        let input = "Body before <!-- a note --> body after"+        let result = HTMLCommentExport.flatten(input, visible: true)+        #expect(!result.contains("<!--"), "expected markers stripped; got: \(result)")+        #expect(!result.contains("-->"), "expected markers stripped; got: \(result)")+        #expect(result.contains("Comment: a note"), "expected localised prefix; got: \(result)")+        #expect(result.contains("Body before"))+        #expect(result.contains("body after"))+    }++    @Test("flatten preserves text with no comments unchanged")+    func flattenIsIdentityWhenNoComments() {+        let input = "Just a body paragraph with no comments at all."+        #expect(HTMLCommentExport.flatten(input, visible: false) == input)+        #expect(HTMLCommentExport.flatten(input, visible: true) == input)+    }++    @Test("flatten with visible=true handles multiple comments")+    func flattenHandlesMultipleVisibleComments() {+        let input = "One <!-- first --> two <!-- second --> three"+        let result = HTMLCommentExport.flatten(input, visible: true)+        #expect(result.contains("Comment: first"))+        #expect(result.contains("Comment: second"))+        #expect(result.contains("One"))+        #expect(result.contains("two"))+        #expect(result.contains("three"))+    }++    @Test("flatten with visible=false handles multiple comments")+    func flattenHandlesMultipleInvisibleComments() {+        let input = "One <!-- first --> two <!-- second --> three"+        let result = HTMLCommentExport.flatten(input, visible: false)+        #expect(!result.contains("first"))+        #expect(!result.contains("second"))+        #expect(!result.contains("Comment:"))+    }++    @Test("flatten trims inner text whitespace when visible")+    func flattenTrimsInnerTextWhitespace() {+        let input = "<!--   padded note   -->"+        let result = HTMLCommentExport.flatten(input, visible: true)+        #expect(result.contains("Comment: padded note"), "expected inner whitespace trimmed; got: \(result)")+        #expect(!result.contains("Comment:    padded"))+    }++    @Test("flatten with empty comment body produces no Comment: entry when visible")+    func flattenSkipsEmptyCommentsWhenVisible() {+        // Author-empty comments (`<!---->` or whitespace-only) should not+        // emit a bare "Comment: " entry in the plain-text export — they+        // would be visual noise.+        let input = "Before <!----> after <!--   --> end"+        let result = HTMLCommentExport.flatten(input, visible: true)+        #expect(!result.contains("Comment:"), "expected no Comment: entry for empty comment bodies; got: \(result)")+    }++    @Test("flatten preserves multi-line comment inner text when visible")+    func flattenPreservesMultiLineWhenVisible() {+        let input = "Body <!-- line one\nline two --> end"+        let result = HTMLCommentExport.flatten(input, visible: true)+        #expect(result.contains("Comment: line one\nline two"), "expected multi-line preserved; got: \(result)")+    }+}
prismTests/HTMLCommentParserTests.swift Added +247 / -0
diff --git a/prismTests/HTMLCommentParserTests.swift b/prismTests/HTMLCommentParserTests.swiftnew file mode 100644index 0000000..c5b7118--- /dev/null+++ b/prismTests/HTMLCommentParserTests.swift@@ -0,0 +1,247 @@+//+//  HTMLCommentParserTests.swift+//  prismTests+//+//  Created by Claude on 22/5/2026.+//++import Foundation+import Testing+@testable import prism++/// Tests for `HTMLCommentParser.parseBlock(_:)`.+///+/// The parser returns the inner text of a comment-only HTML block, or `nil`+/// for any input that should not be classified as a comment-only block+/// (mixed content, unterminated `<!--`, conditional comments, CDATA,+/// DOCTYPE, note-infrastructure tags, and empty/whitespace-only comments).+@Suite("HTML Comment Parser")+struct HTMLCommentParserTests {++    // MARK: - Example cases++    @Test("Single-line comment-only block returns inner text")+    func singleLineCommentOnly() {+        let result = HTMLCommentParser.parseBlock("<!-- note -->")+        #expect(result == "note")+    }++    @Test("Single-line comment-only block trims inner text")+    func singleLineCommentTrimsInnerWhitespace() {+        // Outer whitespace is trimmed; inner whitespace at the edges is too+        // because we trim the FINAL joined string.+        let result = HTMLCommentParser.parseBlock("<!--   spaced   -->")+        #expect(result == "spaced")+    }++    @Test("Multi-line comment-only block preserves internal line breaks")+    func multiLineCommentPreservesLineBreaks() {+        let input = "<!-- line one\nline two\nline three -->"+        let result = HTMLCommentParser.parseBlock(input)+        #expect(result == "line one\nline two\nline three")+    }++    @Test("Multiple consecutive comments joined with source whitespace")+    func multipleConsecutiveComments() {+        let input = "<!-- a --> <!-- b --> <!-- c -->"+        let result = HTMLCommentParser.parseBlock(input)+        #expect(result == "a b c")+    }++    @Test("Multiple comments separated by blank lines preserve blank lines")+    func multipleCommentsPreserveBlankLines() {+        let input = "<!-- first -->\n\n<!-- second -->"+        let result = HTMLCommentParser.parseBlock(input)+        #expect(result == "first\n\nsecond")+    }++    @Test("Leading and trailing whitespace around the block is trimmed")+    func outerWhitespaceTrimmed() {+        let input = "  \n<!-- note -->\n  "+        let result = HTMLCommentParser.parseBlock(input)+        #expect(result == "note")+    }++    // MARK: - Empty / whitespace-only++    @Test("Empty comment <!----> returns nil")+    func emptyCommentReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!---->")+        #expect(result == nil)+    }++    @Test("Whitespace-only comment returns nil")+    func whitespaceOnlyCommentReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!--   -->")+        #expect(result == nil)+    }++    @Test("Multiple empty comments return nil")+    func multipleEmptyCommentsReturnNil() {+        let result = HTMLCommentParser.parseBlock("<!----><!---->")+        #expect(result == nil)+    }++    // MARK: - Malformed / mixed input++    @Test("Unterminated <!-- returns nil")+    func unterminatedCommentReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!-- no end here")+        #expect(result == nil)+    }++    @Test("Mixed comment + paragraph returns nil")+    func mixedContentReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!-- x --> <p>real html</p>")+        #expect(result == nil)+    }++    @Test("Mixed comment + bare text returns nil")+    func mixedBareTextReturnsNil() {+        let result = HTMLCommentParser.parseBlock("text before <!-- x -->")+        #expect(result == nil)+    }++    // MARK: - Conditional comments (Non-Goal)++    @Test("Conditional <!--[if IE]>...<![endif]--> returns nil")+    func conditionalIfReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!--[if IE]>legacy<![endif]-->")+        #expect(result == nil)+    }++    @Test("Conditional <!--[endif]--> returns nil")+    func conditionalEndifReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!--[endif]-->")+        #expect(result == nil)+    }++    @Test("Conditional comment with leading whitespace returns nil")+    func conditionalWithLeadingWhitespaceReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!--  [if gte IE 9]>x<![endif]-->")+        #expect(result == nil)+    }++    // MARK: - CDATA / DOCTYPE++    @Test("CDATA section returns nil")+    func cdataReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<![CDATA[ raw data ]]>")+        #expect(result == nil)+    }++    @Test("DOCTYPE declaration returns nil")+    func doctypeReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!DOCTYPE html>")+        #expect(result == nil)+    }++    // MARK: - Note infrastructure tags++    @Test("Comment matching <!-- comment:HASH --> returns nil")+    func noteInfrastructureCommentReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!-- comment:abc123 -->")+        #expect(result == nil)+    }++    @Test("Comment matching <!-- /comment:HASH --> returns nil")+    func noteInfrastructureClosingCommentReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!-- /comment:abc123 -->")+        #expect(result == nil)+    }++    @Test("Comment with longer hash matching note-infrastructure pattern returns nil")+    func noteInfrastructureLongHashReturnsNil() {+        let result = HTMLCommentParser.parseBlock("<!--   comment:deadbeef0123456789abcdef  -->")+        #expect(result == nil)+    }++    // MARK: - Property-based: comment-only is recovered++    /// Generates a comment-only HTML block of `n` comments with random inner+    /// bodies drawn from a safe alphabet, joined by random whitespace.+    ///+    /// `expected` mirrors the parser's behaviour: per-body outer whitespace+    /// is trimmed before joining, then inter-comment separators are inserted+    /// verbatim, then the whole string is outer-trimmed.+    private static func makeCommentOnlyBlock(+        bodies: [String],+        separators: [String]+    ) -> (source: String, expected: String) {+        precondition(bodies.count == separators.count + 1 || (bodies.isEmpty && separators.isEmpty))+        var source = ""+        var expected = ""+        for (i, body) in bodies.enumerated() {+            source += "<!--\(body)-->"+            expected += body.trimmingCharacters(in: .whitespacesAndNewlines)+            if i < separators.count {+                source += separators[i]+                expected += separators[i]+            }+        }+        return (source, expected.trimmingCharacters(in: .whitespacesAndNewlines))+    }++    /// Safe inner-text alphabet excluding `[if`, `[endif`, `comment:`, and+    /// `-->` so generated bodies never accidentally trip rejection rules.+    private static let safeAlphabet: [String] = [+        "hello", "world", "TODO", "note", "x", "y",+        " plain ", " with words ", "abc 123"+    ]++    private static let separators: [String] = [" ", "  ", "\n", "\n\n", " \n "]++    @Test(+        "Property: comment-only is recovered (1-5 comments)",+        arguments: [1, 2, 3, 4, 5]+    )+    func propertyCommentOnlyRecovered(commentCount: Int) {+        var rng = SystemRandomNumberGenerator()+        // Run a handful of iterations per count to amplify coverage.+        for _ in 0..<5 {+            var bodies: [String] = []+            for _ in 0..<commentCount {+                let idx = Int(rng.next(upperBound: UInt64(Self.safeAlphabet.count)))+                bodies.append(Self.safeAlphabet[idx])+            }+            var seps: [String] = []+            for _ in 0..<max(0, commentCount - 1) {+                let idx = Int(rng.next(upperBound: UInt64(Self.separators.count)))+                seps.append(Self.separators[idx])+            }+            let (source, expected) = Self.makeCommentOnlyBlock(bodies: bodies, separators: seps)+            let result = HTMLCommentParser.parseBlock(source)+            if expected.isEmpty {+                #expect(result == nil, "expected nil for all-whitespace bodies; source=\(source)")+            } else {+                #expect(result == expected, "source=\(source)")+            }+        }+    }++    // MARK: - Property-based: mixed always nil++    @Test(+        "Property: mixed comment + non-comment always returns nil",+        arguments: ["x", "<p>", "real text", "\u{2603}", "1"]+    )+    func propertyMixedAlwaysNil(intruder: String) {+        var rng = SystemRandomNumberGenerator()+        for _ in 0..<5 {+            let bodyIdx = Int(rng.next(upperBound: UInt64(Self.safeAlphabet.count)))+            let body = Self.safeAlphabet[bodyIdx]+            // Place intruder before, between, or after a comment.+            let placement = Int(rng.next(upperBound: UInt64(3)))+            let source: String+            switch placement {+            case 0:+                source = "\(intruder) <!--\(body)-->"+            case 1:+                source = "<!--\(body)--> \(intruder) <!--\(body)-->"+            default:+                source = "<!--\(body)--> \(intruder)"+            }+            #expect(HTMLCommentParser.parseBlock(source) == nil, "source=\(source)")+        }+    }+}
prismTests/HTMLCommentStrippingTests.swift Added +147 / -0
diff --git a/prismTests/HTMLCommentStrippingTests.swift b/prismTests/HTMLCommentStrippingTests.swiftnew file mode 100644index 0000000..117742f--- /dev/null+++ b/prismTests/HTMLCommentStrippingTests.swift@@ -0,0 +1,147 @@+//+//  HTMLCommentStrippingTests.swift+//  prismTests+//+//  Created by Claude on 22/5/2026.+//++import Foundation+import Testing+@testable import prism++/// Tests for the `HTMLCommentStripping.strip(_:)` helper and the link-label+/// stripping helper `MarkdownBlockParser.stripCommentsInsideLinkLabels(_:)`.+///+/// The strip helper is used at heading-derived sites (ToC entries, anchor IDs,+/// window titles), image alt/title strings, and the raw-HTML fall-back path so+/// that author comment markers never leak into derived navigation surfaces or+/// the raw-HTML path regardless of the toggle state. The link-label helper is+/// applied at parse time so the Textual inline extension never sees a comment+/// inside a markdown link label (Req 4.5).+@Suite("HTML Comment Stripping")+struct HTMLCommentStrippingTests {++    // MARK: - HTMLCommentStripping.strip++    @Test("Single comment is removed")+    func singleCommentRemoved() {+        let input = "Hello <!-- note --> World"+        let result = HTMLCommentStripping.strip(input)+        #expect(result == "Hello World")+    }++    @Test("Multiple comments are removed")+    func multipleCommentsRemoved() {+        let input = "<!-- a -->Hello<!-- b --> World<!-- c -->"+        let result = HTMLCommentStripping.strip(input)+        #expect(result == "Hello World")+    }++    @Test("Multi-line comment is removed")+    func multiLineCommentRemoved() {+        let input = "Before<!-- this\nis\nmulti-line -->After"+        let result = HTMLCommentStripping.strip(input)+        #expect(result == "BeforeAfter")+    }++    @Test("Whitespace collapses to a single space after stripping")+    func whitespaceCollapses() {+        let input = "foo   <!-- x -->   bar"+        let result = HTMLCommentStripping.strip(input)+        #expect(result == "foo bar")+    }++    @Test("No-op when no comments present")+    func noCommentsUnchanged() {+        let input = "Plain text without comments"+        let result = HTMLCommentStripping.strip(input)+        #expect(result == "Plain text without comments")+    }++    @Test("Edges trimmed after stripping")+    func edgesTrimmed() {+        let input = "  <!-- a -->trimmed me<!-- b -->  "+        let result = HTMLCommentStripping.strip(input)+        #expect(result == "trimmed me")+    }++    @Test("Empty string returns empty")+    func emptyStringReturnsEmpty() {+        let result = HTMLCommentStripping.strip("")+        #expect(result == "")+    }++    @Test("String of only comments returns empty")+    func onlyCommentsReturnsEmpty() {+        let input = "<!-- a --><!-- b -->"+        let result = HTMLCommentStripping.strip(input)+        #expect(result == "")+    }++    // MARK: - stripCommentsInsideLinkLabels++    @Test("Strips comment inside inline link label")+    func stripsCommentInsideInlineLinkLabel() {+        let input = "Click [foo<!--x-->bar](https://example.com) here"+        let result = MarkdownBlockParser.stripCommentsInsideLinkLabels(input)+        #expect(result == "Click [foobar](https://example.com) here")+    }++    @Test("Strips comment inside link label with title")+    func stripsCommentInsideLinkLabelWithTitle() {+        let input = "[label<!--note-->more](https://example.com \"a title\")"+        let result = MarkdownBlockParser.stripCommentsInsideLinkLabels(input)+        #expect(result == "[labelmore](https://example.com \"a title\")")+    }++    @Test("Strips comment inside reference-form link label")+    func stripsCommentInsideReferenceLinkLabel() {+        let input = "See [foo<!--x-->bar][ref] for details"+        let result = MarkdownBlockParser.stripCommentsInsideLinkLabels(input)+        #expect(result == "See [foobar][ref] for details")+    }++    @Test("Strips comment inside shortcut-form link label")+    func stripsCommentInsideShortcutLinkLabel() {+        // Shortcut form: [label] (with a separate definition elsewhere).+        // The bracketed label is still detected by the helper.+        let input = "Look at [foo<!--x-->bar] for the answer"+        let result = MarkdownBlockParser.stripCommentsInsideLinkLabels(input)+        #expect(result == "Look at [foobar] for the answer")+    }++    @Test("Comments OUTSIDE link brackets are NOT stripped by link-label helper")+    func commentsOutsideLinksUnchanged() {+        let input = "before <!-- outer --> [label](url) after <!-- end -->"+        let result = MarkdownBlockParser.stripCommentsInsideLinkLabels(input)+        #expect(result == "before <!-- outer --> [label](url) after <!-- end -->")+    }++    @Test("Multiple comments inside one link label all stripped")+    func multipleCommentsInsideLabelStripped() {+        let input = "[a<!--x-->b<!--y-->c](url)"+        let result = MarkdownBlockParser.stripCommentsInsideLinkLabels(input)+        #expect(result == "[abc](url)")+    }++    @Test("Link label without any comment is unchanged")+    func linkLabelWithoutCommentUnchanged() {+        let input = "[plain label](https://example.com)"+        let result = MarkdownBlockParser.stripCommentsInsideLinkLabels(input)+        #expect(result == "[plain label](https://example.com)")+    }++    @Test("Text without any link brackets is unchanged")+    func textWithoutLinksUnchanged() {+        let input = "Just text with <!-- a comment --> in it"+        let result = MarkdownBlockParser.stripCommentsInsideLinkLabels(input)+        #expect(result == "Just text with <!-- a comment --> in it")+    }++    @Test("Multi-line comment inside link label is stripped")+    func multiLineCommentInsideLabelStripped() {+        let input = "[foo<!--multi\nline-->bar](url)"+        let result = MarkdownBlockParser.stripCommentsInsideLinkLabels(input)+        #expect(result == "[foobar](url)")+    }+}
prismTests/HighlightedInlineTextRenderBench.swift Added +168 / -0
diff --git a/prismTests/HighlightedInlineTextRenderBench.swift b/prismTests/HighlightedInlineTextRenderBench.swiftnew file mode 100644index 0000000..ceedd92--- /dev/null+++ b/prismTests/HighlightedInlineTextRenderBench.swift@@ -0,0 +1,168 @@+//+//  HighlightedInlineTextRenderBench.swift+//  prismTests+//+//  Created by Claude on 23/5/2026.+//+//  Performance benchmark for the inline HTML comment pipeline used by+//  `HighlightedInlineText`. Toggling `showHTMLComments` re-runs the+//  inline parse + accessibility composer for every visible paragraph, so+//  this benchmark exists to catch a regression where the regex inside the+//  Textual extension or the accessibility composer would scale+//  super-linearly with the number of comments per paragraph.+//+//  Uses XCTest's `measure` block — Swift Testing does not provide a+//  built-in performance measurement primitive at parity with XCTest's+//  metrics, so the benchmark uses XCTestCase.measure with the default+//  clock metric.+//+//  Requirement 9.1 / 9.2: feature must not introduce a noticeable+//  regression when toggling the preference on a parsed document.++import XCTest+import SwiftUI+import Textual+@testable import prism++/// Performance benchmark for `HighlightedInlineText`'s inline pipeline.+///+/// Re-renders a paragraph that contains N inline HTML comments (N ∈+/// {0, 10, 100}) and asserts the per-render time is bounded by an absolute+/// budget that also pins the no-super-linear-scaling property — the+/// per-render cost at N=100 must stay within an order of magnitude of the+/// per-render cost at N=10.+@MainActor+final class HighlightedInlineTextRenderBench: XCTestCase {++    private static func appearance() -> HTMLCommentAppearance {+        HTMLCommentAppearance(+            foregroundColor: .secondary,+            prefixSymbolName: "info.circle",+            italic: true,+            accessibilityCommentLabel: "Comment",+            accessibilityEndCommentLabel: "End comment"+        )+    }++    /// Builds a paragraph of the form+    /// "prefix0 <!--c0--> prefix1 <!--c1--> ... prefixN-1 <!--cN-1--> tail"+    /// when N > 0, or a plain paragraph when N == 0.+    private static func paragraph(commentCount: Int) -> String {+        guard commentCount > 0 else {+            return "Plain paragraph with no comments at all, just some prose."+        }+        var pieces: [String] = []+        for index in 0..<commentCount {+            pieces.append("prefix\(index) <!--comment number \(index)-->")+        }+        pieces.append("tail")+        return pieces.joined(separator: " ")+    }++    /// Runs the inline pipeline once: parse with the Textual extension+    /// (visible == true) and compose the accessibility label. Mirrors the+    /// work `HighlightedInlineText` does on each re-render when the+    /// preference toggles.+    @MainActor+    private static func renderOnce(_ markdown: String) {+        let parser = AttributedStringMarkdownParser.inlineMarkdown(+            syntaxExtensions: [.htmlComments(visible: true, appearance: appearance())]+        )+        guard let parsed = try? parser.attributedString(for: markdown) else { return }+        _ = HTMLCommentAccessibility.analyze(+            content: parsed,+            commentLabel: "Comment",+            endCommentLabel: "End comment"+        )+    }++    // MARK: - Baselines++    /// Baseline at N == 0. Pins the cost of the no-comment path.+    func testRenderBaseline_zeroComments() {+        let markdown = Self.paragraph(commentCount: 0)+        measure {+            for _ in 0..<100 {+                Self.renderOnce(markdown)+            }+        }+    }++    /// 10 comments — typical "annotated paragraph" workload.+    func testRender_tenComments() {+        let markdown = Self.paragraph(commentCount: 10)+        measure {+            for _ in 0..<100 {+                Self.renderOnce(markdown)+            }+        }+    }++    /// 100 comments — adversarial workload to catch quadratic regressions.+    func testRender_hundredComments() {+        let markdown = Self.paragraph(commentCount: 100)+        measure {+            for _ in 0..<10 {+                Self.renderOnce(markdown)+            }+        }+    }++    // MARK: - Sub-quadratic scaling assertion++    /// Empirically asserts that the per-render cost at N == 100 is+    /// bounded by a constant multiple of the per-render cost at N == 10.+    /// A linear or sub-linear extension cost passes; an O(N²) regression+    /// fails because the ratio would balloon past the budget.+    ///+    /// The budget is generous (50x) — we are catching algorithmic+    /// regressions, not micro-optimisation drift. A truly linear pipeline+    /// at N=100 should be ~10x N=10; quadratic would be ~100x; the budget+    /// sits between the two so the test is stable under noise.+    func testRender_doesNotScaleSuperLinearly() {+        let small = Self.paragraph(commentCount: 10)+        let large = Self.paragraph(commentCount: 100)++        // Warm up — first parse pays one-off setup cost (regex compile,+        // attribute scope registration).+        Self.renderOnce(small)+        Self.renderOnce(large)++        let smallElapsed = measureElapsed(iterations: 20) {+            Self.renderOnce(small)+        }+        let largeElapsed = measureElapsed(iterations: 20) {+            Self.renderOnce(large)+        }++        // Guard against divide-by-zero / unstable noise floor: if the+        // small run is below 1ms, the timing is dominated by noise and+        // the ratio is meaningless. In that case we just assert the+        // large run finished within an absolute envelope.+        if smallElapsed < 0.001 {+            XCTAssertLessThan(+                largeElapsed,+                0.5,+                "N=100 render exceeded 500ms absolute budget (noise floor on N=10 prevents ratio check)"+            )+            return+        }++        let ratio = largeElapsed / smallElapsed+        XCTAssertLessThan(+            ratio,+            50.0,+            "N=100 / N=10 ratio = \(ratio); expected sub-quadratic scaling (< 50x)"+        )+    }++    /// Measures total elapsed seconds for `iterations` calls of `block`.+    private func measureElapsed(iterations: Int, _ block: () -> Void) -> Double {+        let start = ContinuousClock.now+        for _ in 0..<iterations {+            block()+        }+        let elapsed = start.duration(to: .now)+        return Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) * 1e-18+    }+}
prismTests/HighlightedInlineTextTests.swift Added +257 / -0
diff --git a/prismTests/HighlightedInlineTextTests.swift b/prismTests/HighlightedInlineTextTests.swiftnew file mode 100644index 0000000..3a04f5a--- /dev/null+++ b/prismTests/HighlightedInlineTextTests.swift@@ -0,0 +1,257 @@+//+//  HighlightedInlineTextTests.swift+//  prismTests+//+//  Created by Claude on 23/5/2026.+//+//  Inline rendering tests for HighlightedInlineText with HTML comments.+//+//  The view's `body` cannot be exercised directly from the unit test layer+//  (the SwiftUI environment lookups would trap), so these tests drive the+//  same inline pipeline the view's `inlineSyntaxExtensions` configures —+//  the `.htmlComments(visible:appearance:)` Textual extension plus the+//  `HTMLCommentAccessibility` composer the view's+//  `HTMLCommentAccessibilityModifier` calls.+//+//  Together they pin the behaviour the view delegates to those two+//  components: visibility-driven text changes, code-span skipping, the+//  Decision 14 accessibility composer, and the `TaskIdentifier` change+//  triggered by toggling `showHTMLComments` while a search is active.++import Foundation+import SwiftUI+import Testing+import Textual+@testable import prism++@Suite("HighlightedInlineText HTML Comments")+@MainActor+struct HighlightedInlineTextTests {++    // MARK: - Fixtures++    /// Builds the same appearance configuration `HighlightedInlineText`+    /// passes to the Textual extension. Foreground colour is irrelevant to+    /// these tests; the localised labels match the catalog entries the+    /// composer reads.+    private static func appearance() -> HTMLCommentAppearance {+        HTMLCommentAppearance(+            foregroundColor: .secondary,+            prefixSymbolName: "info.circle",+            italic: true,+            accessibilityCommentLabel: String(localized: "Comment"),+            accessibilityEndCommentLabel: String(localized: "End comment")+        )+    }++    /// Parses `markdown` through the same inline pipeline+    /// `HighlightedInlineText.inlineSyntaxExtensions` configures.+    private static func parse(+        _ markdown: String,+        visible: Bool+    ) throws -> AttributedString {+        let parser = AttributedStringMarkdownParser.inlineMarkdown(+            syntaxExtensions: [.htmlComments(visible: visible, appearance: appearance())]+        )+        return try parser.attributedString(for: markdown)+    }++    /// Plain-text projection of an `AttributedString`, with the SF Symbol+    /// attachment placeholder (`\u{FFFC}`) stripped so assertions can focus+    /// on the rendered text.+    private static func plainText(_ content: AttributedString) -> String {+        var characters = String(content.characters)+        characters.removeAll { $0 == "\u{FFFC}" }+        return characters+    }++    // MARK: - Visibility (Req 3.4 / 3.6)++    @Test("Toggle OFF: foo<!--x-->bar renders as foobar")+    func toggleOff_hidesInlineComment() throws {+        let parsed = try Self.parse("foo<!--x-->bar", visible: false)+        #expect(Self.plainText(parsed) == "foobar")++        // No HTMLCommentRangeAttribute runs are produced when hidden.+        let commentRuns = parsed.runs.filter { $0[HTMLCommentRangeAttributeKey.self] != nil }+        #expect(commentRuns.isEmpty)+    }++    @Test("Toggle ON: foo<!--x-->bar renders foo + symbol + x + bar with a visible break")+    func toggleOn_showsInlineComment() throws {+        let parsed = try Self.parse("foo<!--x-->bar", visible: true)+        let text = Self.plainText(parsed)++        // The inner text "x" appears between "foo" and "bar".+        #expect(text.contains("x"))+        #expect(text.starts(with: "foo"))+        #expect(text.hasSuffix("bar"))++        // foo and bar are NOT joined into a single token — the comment+        // breaks the surrounding text run (Req 3.4).+        #expect(!text.contains("foobar"))++        // The symbol attachment placeholder is present in the raw string.+        #expect(String(parsed.characters).contains("\u{FFFC}"))++        // A run is tagged with HTMLCommentRangeAttribute carrying the+        // inner text "x".+        let commentRuns = parsed.runs.compactMap { $0[HTMLCommentRangeAttributeKey.self] }+        #expect(commentRuns.contains { $0.innerText == "x" })+    }++    @Test("Code-span <code><!--x--></code> survives literally when toggle is OFF")+    func codeSpan_preservesCommentLiteral_visibleOff() throws {+        let parsed = try Self.parse("before `<!--x-->` after", visible: false)+        let text = Self.plainText(parsed)+        #expect(+            text.contains("<!--x-->"),+            "Code-span comment must survive literally when visibility is OFF"+        )+    }++    @Test("Code-span <code><!--x--></code> survives literally when toggle is ON")+    func codeSpan_preservesCommentLiteral_visibleOn() throws {+        let parsed = try Self.parse("before `<!--x-->` after", visible: true)+        let text = Self.plainText(parsed)+        #expect(+            text.contains("<!--x-->"),+            "Code-span comment must survive literally when visibility is ON"+        )++        // The literal comment inside a code span must NOT be tagged with+        // HTMLCommentRangeAttribute — the extension skips code spans.+        let commentRuns = parsed.runs.compactMap { $0[HTMLCommentRangeAttributeKey.self] }+        #expect(+            commentRuns.isEmpty,+            "Comments inside code spans must not produce comment runs"+        )+    }++    // MARK: - Accessibility composer (Decision 14, Req 7.2)++    @Test("Single inline comment composes 'Comment x End comment' boundary")+    func singleInlineComment_singleBoundary() throws {+        let parsed = try Self.parse("foo<!--x-->bar", visible: true)+        let analysis = HTMLCommentAccessibility.analyze(+            content: parsed,+            commentLabel: "Comment",+            endCommentLabel: "End comment"+        )++        #expect(analysis.hasComments)+        // Boundary markers wrap the single comment text.+        #expect(analysis.rebuiltLabel.contains("Comment x End comment"))+        // Surrounding prose is preserved.+        #expect(analysis.rebuiltLabel.hasPrefix("foo"))+        #expect(analysis.rebuiltLabel.hasSuffix("bar"))+    }++    @Test("Multiple inline comments compose a single boundary pair")+    func multipleInlineComments_singleBoundaryPair() throws {+        let markdown = "before<!--alpha-->middle<!--beta-->after<!--gamma-->end"+        let parsed = try Self.parse(markdown, visible: true)+        let analysis = HTMLCommentAccessibility.analyze(+            content: parsed,+            commentLabel: "Comment",+            endCommentLabel: "End comment"+        )++        #expect(analysis.hasComments)+        let label = analysis.rebuiltLabel++        // Exactly one "Comment" marker (Decision 14).+        let commentCount = label.components(separatedBy: "Comment").count - 1+        // "End comment" also contains "Comment" — subtract its+        // contribution to count the standalone markers.+        let endCommentCount = label.components(separatedBy: "End comment").count - 1+        let standaloneCommentCount = commentCount - endCommentCount+        #expect(standaloneCommentCount == 1, "Expected exactly one 'Comment' marker, got \(standaloneCommentCount)")+        #expect(endCommentCount == 1, "Expected exactly one 'End comment' marker, got \(endCommentCount)")++        // All three inner texts appear in source order.+        let alphaRange = label.range(of: "alpha")+        let betaRange = label.range(of: "beta")+        let gammaRange = label.range(of: "gamma")+        #expect(alphaRange != nil && betaRange != nil && gammaRange != nil)+        if let alpha = alphaRange, let beta = betaRange, let gamma = gammaRange {+            #expect(alpha.lowerBound < beta.lowerBound)+            #expect(beta.lowerBound < gamma.lowerBound)+        }++        // Surrounding prose is preserved verbatim.+        #expect(label.hasPrefix("before"))+        #expect(label.hasSuffix("end"))+        #expect(label.contains("middle"))+        #expect(label.contains("after"))+    }++    @Test("No comments → composer reports hasComments = false (no-op)")+    func noComments_isNoOp() throws {+        let parsed = try Self.parse("plain prose with no comments", visible: true)+        let analysis = HTMLCommentAccessibility.analyze(+            content: parsed,+            commentLabel: "Comment",+            endCommentLabel: "End comment"+        )+        #expect(!analysis.hasComments)+        #expect(analysis.rebuiltLabel.isEmpty)+    }++    @Test("Toggle OFF hides comments from accessibility composer too")+    func toggleOff_noBoundaryEmitted() throws {+        let parsed = try Self.parse("foo<!--x-->bar", visible: false)+        let analysis = HTMLCommentAccessibility.analyze(+            content: parsed,+            commentLabel: "Comment",+            endCommentLabel: "End comment"+        )+        #expect(!analysis.hasComments)+        #expect(analysis.rebuiltLabel.isEmpty)+    }++    // MARK: - TaskIdentifier change (Req 5.4)++    @Test("Toggling showHTMLComments while a query is active changes the TaskIdentifier")+    func taskIdentifier_changesOnVisibilityToggle() {+        let off = HighlightedInlineText.TaskIdentifier(+            markdown: "foo<!--x-->bar",+            searchQuery: "foo",+            currentIndex: 0,+            showHTMLComments: false+        )+        let on = HighlightedInlineText.TaskIdentifier(+            markdown: "foo<!--x-->bar",+            searchQuery: "foo",+            currentIndex: 0,+            showHTMLComments: true+        )+        #expect(off != on, "TaskIdentifier must change when showHTMLComments flips, so the task re-runs")+    }++    @Test("Match count refreshes when toggle flips while a search query is active")+    func matchCount_refreshesOnVisibilityToggle() throws {+        // OFF: only the surrounding prose is searchable. The inner text+        // "secret" is hidden, so a query for "secret" should yield zero+        // matches on the post-transformation string.+        let off = try Self.parse("visible <!--secret--> body", visible: false)+        let resultOff = SearchHighlightApplicator.applyHighlights(+            to: off,+            query: "secret",+            currentMatchIndex: nil+        )+        #expect(resultOff.matchCount == 0)++        // ON: the styled run includes the inner text "secret", so the+        // query yields exactly one match. This pins the design's+        // "transform-then-highlight" order — if highlighting ran before+        // the comment transformation, OFF would also report a match.+        let on = try Self.parse("visible <!--secret--> body", visible: true)+        let resultOn = SearchHighlightApplicator.applyHighlights(+            to: on,+            query: "secret",+            currentMatchIndex: nil+        )+        #expect(resultOn.matchCount == 1)+    }+}
prismTests/MarkdownBlockParserHTMLCommentPerformanceTests.swift Added +69 / -0
diff --git a/prismTests/MarkdownBlockParserHTMLCommentPerformanceTests.swift b/prismTests/MarkdownBlockParserHTMLCommentPerformanceTests.swiftnew file mode 100644index 0000000..ece01c7--- /dev/null+++ b/prismTests/MarkdownBlockParserHTMLCommentPerformanceTests.swift@@ -0,0 +1,69 @@+//+//  MarkdownBlockParserHTMLCommentPerformanceTests.swift+//  prismTests+//+//  Created by Claude on 23/5/2026.+//+//  Performance tests for the parsing pipeline with HTML comment branches+//  in place. Lives in its own file to keep `MarkdownBlockParserTests`+//  below the project's file-length lint budget.++import Testing+@testable import prism++/// Extends the existing 500 KB parsing budget to a document that exercises+/// the new HTML comment branches in `MarkdownBlockParser` and the inline+/// comment markers preserved for the Textual extension.+@Suite("MarkdownBlockParser HTML Comment Performance")+struct MarkdownBlockParserHTMLCommentPerformanceTests {++    @Test("Parses large comment-bearing document within acceptable time (render-html-comments Req 9.1)")+    func largeDocumentWithHTMLComments() {+        // Render-html-comments Req 9.1: parsing a 500 KB document with the+        // new `.htmlComment` branch in place must not regress beyond the+        // existing 2 s budget. The document mixes stand-alone HTML comment+        // blocks (exercising HTMLCommentParser.parseBlock) and inline+        // comments (which stay inside paragraph markdown for the Textual+        // extension to handle at render time).+        var content = "# Large Document With Comments\n\n"+        for i in 0..<500 {+            content += """+            ## Section \(i)++            <!-- author note about section \(i) -->++            This is paragraph \(i) <!-- inline TODO \(i) --> with **bold** text+            and an inline <!-- second note --> comment.++            - List item 1 <!-- per-item note -->+            - List item 2+            - List item 3++            <!-- multi-line note+            spanning two source lines for section \(i) -->++            ```swift+            // <!-- comments inside code blocks must survive literally -->+            let x = \(i)+            print(x)+            ```++            """+        }++        let start = ContinuousClock.now+        let blocks = MarkdownBlockParser.parse(content)+        let elapsed = start.duration(to: .now)++        #expect(elapsed < .seconds(2), "Parsing took \(elapsed), expected < 2s (Req 9.1)")+        #expect(blocks.count > 0, "Should have parsed blocks")++        // Sanity check: the document yielded `.htmlComment` blocks via+        // the new parsing branch, so the budget covers the new path.+        let htmlCommentBlockCount = blocks.reduce(0) { acc, block in+            if case .htmlComment = block { return acc + 1 }+            return acc+        }+        #expect(htmlCommentBlockCount > 0, "Expected `.htmlComment` blocks to exercise the new branch")+    }+}
prismTests/MarkdownBlockParserHTMLCommentTests.swift Added +271 / -0
diff --git a/prismTests/MarkdownBlockParserHTMLCommentTests.swift b/prismTests/MarkdownBlockParserHTMLCommentTests.swiftnew file mode 100644index 0000000..2263adf--- /dev/null+++ b/prismTests/MarkdownBlockParserHTMLCommentTests.swift@@ -0,0 +1,261 @@+//+//  MarkdownBlockParserHTMLCommentTests.swift+//  prismTests+//+//  Created by Claude on 22/5/2026.+//++import Foundation+import Markdown+import Testing+@testable import prism++/// Integration tests for `MarkdownBlockParser` HTML comment handling.+///+/// Covers the parser wiring put in place by tasks 4 / 6 / 7 / 9:+/// stand-alone comment-only HTML blocks become `.htmlComment` blocks;+/// mixed-content HTML blocks fall back through the existing `.html`+/// path with markers stripped; inline comments in paragraphs survive+/// verbatim; link-label comments are stripped at parse time; and note+/// infrastructure tags never reach the comment parser.+@Suite("Markdown Block Parser HTML Comment Handling")+struct MarkdownBlockParserHTMLCommentTests {++    // MARK: - Block-level comments++    @Test("Stand-alone <!-- note --> paragraph produces a .htmlComment block")+    func standaloneCommentBlock() {+        let content = """+        # Title++        <!-- note -->++        Body paragraph.+        """+        let blocks = MarkdownBlockParser.parse(content)++        // Locate the comment block.+        let commentBlock = blocks.first { block in+            if case .htmlComment = block { return true }+            return false+        }+        guard case .htmlComment(let rawText) = commentBlock else {+            Issue.record("Expected a .htmlComment block; got blocks: \(blocks.map { $0.debugTypeName })")+            return+        }+        #expect(rawText == "note")+    }++    @Test("Mixed-content HTML block falls back to .html with comment markers stripped")+    func mixedContentStripsCommentMarkers() {+        // Mixed-content HTML that isn't an image or details block falls+        // through to `.html(content:)`, with HTMLCommentStripping applied to+        // the stored content so the raw path never shows `<!--…-->`+        // regardless of the toggle (Decision 16).+        let content = """+        <!-- author note --><div>some raw html content</div>+        """+        let blocks = MarkdownBlockParser.parse(content)++        let htmlBlock = blocks.first { block in+            if case .html = block { return true }+            return false+        }+        guard case .html(let storedContent) = htmlBlock else {+            Issue.record("Expected .html fall-back; got blocks: \(blocks.map { $0.debugTypeName })")+            return+        }+        #expect(!storedContent.contains("<!--"), "expected comment markers stripped from .html content; got: \(storedContent)")+        #expect(!storedContent.contains("-->"), "expected comment markers stripped from .html content; got: \(storedContent)")+        // The non-comment HTML survives.+        #expect(storedContent.contains("<div>"), "expected <div> to remain in content: \(storedContent)")+        #expect(storedContent.contains("some raw html content"), "expected text to remain in content: \(storedContent)")+    }++    @Test("Unterminated <!-- routes through .html unchanged")+    func unterminatedCommentFallsBackToHtml() {+        let content = """+        <!-- never closed+        """+        let blocks = MarkdownBlockParser.parse(content)+        let htmlBlock = blocks.first { block in+            if case .html = block { return true }+            return false+        }+        // We don't assert any specific shape beyond "did not produce a+        // .htmlComment" — Req 2.7 says the source is left to the existing+        // raw path which may render or hide it case-by-case.+        let commentBlock = blocks.first { block in+            if case .htmlComment = block { return true }+            return false+        }+        #expect(commentBlock == nil, "unterminated <!-- should not produce a .htmlComment block")+        #expect(htmlBlock != nil || blocks.isEmpty, "expected either .html fall-back or no block")+    }++    // MARK: - Inline comments in paragraphs++    @Test("Inline <!-- inside --> stays in .paragraph(markdown:) verbatim")+    func inlineCommentStaysInParagraph() {+        let content = "Hello <!-- inside --> World"+        let blocks = MarkdownBlockParser.parse(content)+        guard let block = blocks.first, case .paragraph(let markdown) = block else {+            Issue.record("Expected a single .paragraph block")+            return+        }+        #expect(markdown.contains("<!-- inside -->"), "expected inline comment to survive in paragraph markdown: \(markdown)")+    }++    // MARK: - Link-label stripping++    @Test("Link label `[foo<!--x-->bar](url)` stores `[foobar](url)`")+    func linkLabelCommentStrippedAtParseTime() {+        let content = "See [foo<!--x-->bar](https://example.com) for details."+        let blocks = MarkdownBlockParser.parse(content)+        guard let block = blocks.first, case .paragraph(let markdown) = block else {+            Issue.record("Expected a single .paragraph block")+            return+        }+        #expect(!markdown.contains("<!--"), "expected link-label comment stripped: \(markdown)")+        #expect(markdown.contains("[foobar]"), "expected stripped label `foobar`: \(markdown)")+        #expect(markdown.contains("(https://example.com)"), "expected URL preserved: \(markdown)")+    }++    @Test("Inline comment outside any link label stays in paragraph markdown")+    func inlineCommentOutsideLinksPreserved() {+        let content = "before <!-- outer --> [label](url) after"+        let blocks = MarkdownBlockParser.parse(content)+        guard let block = blocks.first, case .paragraph(let markdown) = block else {+            Issue.record("Expected a single .paragraph block")+            return+        }+        #expect(markdown.contains("<!-- outer -->"), "expected outside-link comment preserved: \(markdown)")+        #expect(markdown.contains("[label](url)"), "expected link unchanged: \(markdown)")+    }++    // MARK: - Note-infrastructure tags never reach the comment parser++    @Test("testCommentInfrastructureTagsNeverReachHTMLCommentParser")+    func noteInfrastructureTagsStripped() {+        // The note infrastructure tag is removed by stripCommentTags BEFORE+        // swift-markdown ever sees it; the parser should never emit a+        // .htmlComment for it.+        let content = """+        # Title++        <!-- comment:abcdef123 -->++        Body paragraph.+        """+        let blocks = MarkdownBlockParser.parse(content)+        let commentBlock = blocks.first { block in+            if case .htmlComment = block { return true }+            return false+        }+        #expect(commentBlock == nil, "note infrastructure tag must not produce a .htmlComment block; got blocks: \(blocks.map { $0.debugTypeName })")+    }++    // MARK: - swift-markdown round-trip pins (Req 4.5 / Decision 18)++    /// Confirms that swift-markdown's `Paragraph.format()` preserves inline+    /// `<!--…-->` characters verbatim across the inline-formatting cases the+    /// renderer cares about. These are load-bearing pins: if a future+    /// swift-markdown upgrade drops the inline HTML during round-tripping,+    /// the new design breaks silently — these tests catch that.+    @Test(+        "Paragraph.format() preserves inline <!--…--> markers",+        arguments: [+            "*foo<!--x-->bar*",+            "[label<!--x-->more](url)",+            "**bold<!--y-->again**"+        ]+    )+    func paragraphFormatPreservesInlineComments(input: String) {+        let document = Document(parsing: input)+        var foundParagraph: Paragraph?+        for child in document.children {+            if let paragraph = child as? Paragraph {+                foundParagraph = paragraph+                break+            }+        }+        guard let paragraph = foundParagraph else {+            Issue.record("Expected at least one Paragraph for input: \(input)")+            return+        }+        let formatted = paragraph.format()+        #expect(formatted.contains("<!--"), "expected `<!--` preserved by Paragraph.format() for `\(input)`; got: \(formatted)")+        #expect(formatted.contains("-->"), "expected `-->` preserved by Paragraph.format() for `\(input)`; got: \(formatted)")+    }++    @Test("BlockQuote.format() preserves inline <!--…--> markers")+    func blockquoteFormatPreservesInlineComments() {+        let input = "> quote <!--x--> more"+        let document = Document(parsing: input)+        var foundQuote: BlockQuote?+        for child in document.children {+            if let blockquote = child as? BlockQuote {+                foundQuote = blockquote+                break+            }+        }+        guard let blockquote = foundQuote else {+            Issue.record("Expected a BlockQuote for input: \(input)")+            return+        }+        let formatted = blockquote.format()+        #expect(formatted.contains("<!--"), "expected `<!--` preserved by BlockQuote.format() for `\(input)`; got: \(formatted)")+        #expect(formatted.contains("-->"), "expected `-->` preserved by BlockQuote.format() for `\(input)`; got: \(formatted)")+    }++    // MARK: - Lists separated by a comment block++    @Test("List items separated by a comment-only block produce .list .htmlComment .list")+    func listSeparatedByCommentBlock() {+        // swift-markdown's behaviour: an HTML block between two list items at+        // the same level interrupts the list, producing two list siblings.+        // The new design lets that happen and renders the comment between.+        let content = """+        1. First item+        2. Second item++        <!-- between -->++        3. Third item+        4. Fourth item+        """+        let blocks = MarkdownBlockParser.parse(content)++        // Locate the comment block.+        var sawFirstList = false+        var sawComment = false+        var sawSecondList = false+        var firstItemCount = 0+        var secondListIndex: Int?+        for (index, block) in blocks.enumerated() {+            if case .list(true, let items) = block, !sawComment {+                sawFirstList = true+                firstItemCount = items.count+            } else if case .htmlComment(let rawText) = block {+                sawComment = true+                #expect(rawText == "between")+            } else if case .list(true, _) = block, sawComment {+                sawSecondList = true+                secondListIndex = index+            }+        }+        #expect(sawFirstList, "expected first ordered list block")+        #expect(sawComment, "expected a .htmlComment block between the two lists")+        #expect(sawSecondList, "expected second ordered list block")+        #expect(firstItemCount == 2, "expected two items in the first list, got \(firstItemCount)")++        // Decision 17: ordinalOffsetForList must return the first list's item+        // count so the second ordered list resumes numbering from item N+1.+        if let index = secondListIndex {+            let offset = MarkdownBlock.ordinalOffsetForList(at: index, in: blocks)+            #expect(offset == firstItemCount, "expected second list's ordinalOffset to equal first list's item count (\(firstItemCount)), got \(offset)")+        } else {+            Issue.record("expected to find the second ordered list to check ordinalOffsetForList")+        }+    }+}
prismTests/MarkdownBlockParserTests.swift Modified +1 / -0
diff --git a/prismTests/MarkdownBlockParserTests.swift b/prismTests/MarkdownBlockParserTests.swiftindex ccdc214..877cc2b 100644--- a/prismTests/MarkdownBlockParserTests.swift+++ b/prismTests/MarkdownBlockParserTests.swift@@ -552,6 +552,7 @@ struct MarkdownBlockParserTests {         #expect(blocks.count > 0, "Should have parsed blocks")     } +     @Test("Block IDs are stable for lazy rendering")     func testBlockIDsStableForLazyRendering() {         // Requirement 9.4: Lazy rendering requires stable block IDs
prismTests/MarkdownBlockSearchContextTests.swift Added +128 / -0
diff --git a/prismTests/MarkdownBlockSearchContextTests.swift b/prismTests/MarkdownBlockSearchContextTests.swiftnew file mode 100644index 0000000..f61e306--- /dev/null+++ b/prismTests/MarkdownBlockSearchContextTests.swift@@ -0,0 +1,128 @@+//+//  MarkdownBlockSearchContextTests.swift+//  prismTests+//+//  Created by Claude on 22/5/2026.+//++import Foundation+import Testing+@testable import prism++/// Tests for the context-aware `MarkdownBlock.searchableText(in:)` method+/// and the `SearchContext` value type that drives it. The compatibility+/// shims `searchableText` and `searchableText(with:)` are also covered to+/// confirm legacy call sites continue to behave identically until they+/// migrate.+///+/// Coverage maps to render-html-comments Req 5.1 / 5.2 / 5.3 / 4.2 and+/// Decision 19 (headings treat comments symmetrically across render and+/// search).+@Suite("MarkdownBlock Search Context")+struct MarkdownBlockSearchContextTests {++    // MARK: - .htmlComment++    @Test("htmlComment text is included when showHTMLComments == true")+    func htmlCommentIncludedWhenOn() {+        let block = MarkdownBlock.htmlComment(rawText: "an author note")+        let context = SearchContext(showHTMLComments: true, footnoteData: .empty)+        #expect(block.searchableText(in: context) == "an author note")+    }++    @Test("htmlComment text is empty when showHTMLComments == false")+    func htmlCommentEmptyWhenOff() {+        let block = MarkdownBlock.htmlComment(rawText: "an author note")+        let context = SearchContext(showHTMLComments: false, footnoteData: .empty)+        #expect(block.searchableText(in: context) == "")+    }++    // MARK: - .paragraph with inline comments++    @Test("paragraph with inline comments: markers stripped from base in both states")+    func paragraphStripsMarkersBaseInBothStates() {+        let block = MarkdownBlock.paragraph(markdown: "Hello <!-- inside --> world")+        let onContext = SearchContext(showHTMLComments: true, footnoteData: .empty)+        let offContext = SearchContext(showHTMLComments: false, footnoteData: .empty)+        let onText = block.searchableText(in: onContext)+        let offText = block.searchableText(in: offContext)+        #expect(!onText.contains("<!--"), "ON: markers should be stripped from base; got: \(onText)")+        #expect(!onText.contains("-->"), "ON: markers should be stripped from base; got: \(onText)")+        #expect(!offText.contains("<!--"), "OFF: markers should be stripped from base; got: \(offText)")+        #expect(!offText.contains("-->"), "OFF: markers should be stripped from base; got: \(offText)")+    }++    @Test("paragraph with inline comments: inner text appended when ON")+    func paragraphAppendsInnerTextWhenOn() {+        let block = MarkdownBlock.paragraph(markdown: "Hello <!-- inside --> world")+        let onContext = SearchContext(showHTMLComments: true, footnoteData: .empty)+        let offContext = SearchContext(showHTMLComments: false, footnoteData: .empty)+        let onText = block.searchableText(in: onContext)+        let offText = block.searchableText(in: offContext)+        #expect(onText.contains("inside"), "ON: inner text must contribute matches; got: \(onText)")+        #expect(!offText.contains("inside"), "OFF: inner text must not contribute matches; got: \(offText)")+        #expect(onText.contains("Hello"))+        #expect(onText.contains("world"))+        #expect(offText.contains("Hello"))+        #expect(offText.contains("world"))+    }++    // MARK: - .heading with embedded comment (Decision 19)++    @Test("heading with embedded comment: symmetric search treatment with paragraphs")+    func headingSearchSymmetricWithParagraphs() {+        let block = MarkdownBlock.heading(level: 2, text: "Section <!-- TODO --> Title")+        let onContext = SearchContext(showHTMLComments: true, footnoteData: .empty)+        let offContext = SearchContext(showHTMLComments: false, footnoteData: .empty)+        let onText = block.searchableText(in: onContext)+        let offText = block.searchableText(in: offContext)+        #expect(!onText.contains("<!--"), "heading ON: markers should be stripped from base")+        #expect(!offText.contains("<!--"), "heading OFF: markers should be stripped from base")+        #expect(onText.contains("TODO"), "heading ON: inner text must be searchable")+        #expect(!offText.contains("TODO"), "heading OFF: inner text must not be searchable")+        #expect(onText.contains("Section"))+        #expect(onText.contains("Title"))+    }++    // MARK: - Compatibility shims++    @Test("bare searchableText matches searchableText(in: default-empty-context)")+    func bareSearchableTextMatchesDefaultContext() {+        let blocks: [MarkdownBlock] = [+            .heading(level: 1, text: "Title"),+            .paragraph(markdown: "Hello world"),+            .paragraph(markdown: "Mid <!-- TODO --> sentence"),+            .htmlComment(rawText: "annotation"),+            .codeBlock(language: nil, code: "let x = 1")+        ]+        for block in blocks {+            let bare = block.searchableText+            let viaContext = block.searchableText(+                in: SearchContext(showHTMLComments: false, footnoteData: .empty)+            )+            #expect(bare == viaContext, "bare and context-aware results must agree for \(block.debugTypeName)")+        }+    }++    @Test("searchableText(with:) delegates with showHTMLComments == false")+    func searchableTextWithFootnotesDelegatesWithOff() {+        let footnote = FootnoteDefinition(identifier: "1", displayNumber: 1, content: "footnote body")+        let data = FootnoteData(+            definitions: ["1": footnote],+            referenceOrder: ["1"]+        )+        let block = MarkdownBlock.paragraph(markdown: "Hello[^1] world <!-- TODO -->")+        let legacy = block.searchableText(with: data)+        let viaContext = block.searchableText(+            in: SearchContext(showHTMLComments: false, footnoteData: data)+        )+        #expect(legacy == viaContext)+        #expect(legacy.contains("footnote body"), "legacy must still surface footnote body: \(legacy)")+        #expect(!legacy.contains("TODO"), "legacy must hide comment body: \(legacy)")+    }++    @Test("SearchContext is Sendable")+    func searchContextIsSendable() {+        let _: any Sendable = SearchContext(showHTMLComments: true, footnoteData: .empty)+    }+}
prismTests/NotesExporterHTMLCommentTests.swift Added +102 / -0
diff --git a/prismTests/NotesExporterHTMLCommentTests.swift b/prismTests/NotesExporterHTMLCommentTests.swiftnew file mode 100644index 0000000..9f307d7--- /dev/null+++ b/prismTests/NotesExporterHTMLCommentTests.swift@@ -0,0 +1,102 @@+//+//  NotesExporterHTMLCommentTests.swift+//  prismTests+//+//  Created by Claude on 23/5/2026.+//++import Foundation+import Testing+@testable import prism++/// Tests for NotesExporter HTML comment filtering on the context quote line.+///+/// Pins Req 6.2: when `showHTMLComments` is false, comment text captured at+/// note-creation time must not leak into the exported markdown — the toggle+/// owns visibility regardless of when the note was made.+@Suite("Notes export honours showHTMLComments toggle")+struct NotesExporterHTMLCommentTests {++    private func makeNote(+        id: UUID = UUID(),+        blockId: String = "block123",+        contextQuote: String,+        content: String = "Note content"+    ) -> BlockNote {+        BlockNote(+            id: id,+            blockId: blockId,+            contextQuote: contextQuote,+            sectionHeading: nil,+            content: content,+            status: .active,+            createdAt: Date(),+            modifiedAt: Date(),+            author: nil,+            noteHash: nil,+            threadId: nil+        )+    }++    private func makeDocumentNotes(notes: [BlockNote] = []) -> DocumentNotes {+        DocumentNotes(+            schemaVersion: 1,+            identifier: DocumentIdentifier(path: "project/specs/test.md"),+            displayName: "test.md",+            notes: notes+        )+    }++    private func makeBlocks() -> [MarkdownBlock] {+        [+            .heading(level: 1, text: "Introduction"),+            .paragraph(markdown: "Body paragraph.")+        ]+    }++    @Test("Context quote strips HTML comments when showHTMLComments is false")+    func exportStripsHTMLCommentsWhenToggleOff() {+        let blocks = makeBlocks()+        let note = makeNote(+            blockId: blocks[1].id,+            contextQuote: "Visible text <!-- hidden author note --> more visible"+        )+        let docNotes = makeDocumentNotes(notes: [note])+        let result = NotesExporter.export(+            documentNotes: docNotes,+            anchoredNotes: [blocks[1].id: [note]],+            orphanedNotes: [],+            blocks: blocks,+            showHTMLComments: false+        )++        #expect(!result.contains("<!--"))+        #expect(!result.contains("-->"))+        #expect(!result.contains("hidden author note"))+        #expect(result.contains("Visible text"))+        #expect(result.contains("more visible"))+    }++    @Test("Context quote renders HTML comments inline with prefix when showHTMLComments is true")+    func exportIncludesHTMLCommentsWhenToggleOn() {+        let blocks = makeBlocks()+        let note = makeNote(+            blockId: blocks[1].id,+            contextQuote: "Visible text <!-- hidden author note --> more visible"+        )+        let docNotes = makeDocumentNotes(notes: [note])+        let result = NotesExporter.export(+            documentNotes: docNotes,+            anchoredNotes: [blocks[1].id: [note]],+            orphanedNotes: [],+            blocks: blocks,+            showHTMLComments: true+        )++        #expect(!result.contains("<!--"))+        #expect(!result.contains("-->"))+        #expect(result.contains("hidden author note"))+        #expect(result.contains("Visible text"))+        #expect(result.contains("more visible"))+    }+}
prismTests/NotesExporterTests.swift Modified +0 / -50
diff --git a/prismTests/NotesExporterTests.swift b/prismTests/NotesExporterTests.swiftindex 45a9118..8a1c7a5 100644--- a/prismTests/NotesExporterTests.swift+++ b/prismTests/NotesExporterTests.swift@@ -876,9 +876,6 @@ struct NotesExporterTests {         #expect(result.contains("**Charlie** \u{2014} 2026-02-20 12:00"))     } --// MARK: - Username Fallback Tests- } extension NotesExporterTests {     @Test("Uses configured username as fallback when note has no author")
prismTests/SearchCoordinatorTests.swift Modified +92 / -0
diff --git a/prismTests/SearchCoordinatorTests.swift b/prismTests/SearchCoordinatorTests.swiftindex 30e1b02..e509ae5 100644--- a/prismTests/SearchCoordinatorTests.swift+++ b/prismTests/SearchCoordinatorTests.swift@@ -747,4 +747,96 @@ struct SearchCoordinatorTests {         actions.isSearchActive = true         #expect(coordinator.isSearchActive == true)     }++    // MARK: - recomputeAfterVisibilityChange (render-html-comments Req 5.4)++    @Test("Toggling showHTMLComments while a query is active rebuilds match counts")+    @MainActor+    func recomputeRebuildsMatchCountsOnVisibilityFlip() {+        let blocks: [MarkdownBlock] = [+            .paragraph(markdown: "Hello world."),+            .htmlComment(rawText: "needle annotation")+        ]+        let coordinator = SearchCoordinator()+        coordinator.blockProvider = { blocks }+        coordinator.footnoteDataProvider = { .empty }+        var showComments = false+        coordinator.showHTMLCommentsProvider = { showComments }+        coordinator.onMatchSelected = {}++        // Initial query with toggle OFF: the comment text does not match.+        coordinator.setActiveSearchQueryForTesting("needle")+        #expect(coordinator.totalMatchCount == 0)+        #expect(coordinator.currentGlobalMatchIndex == nil)++        // Toggle ON: recomputeAfterVisibilityChange rebuilds the totals to+        // include the comment text and resets the cursor to the first+        // match in document order.+        showComments = true+        coordinator.recomputeAfterVisibilityChange()+        #expect(coordinator.totalMatchCount == 1)+        #expect(coordinator.currentGlobalMatchIndex == 0)+    }++    @Test("Toggling showHTMLComments OFF resets cursor when no matches remain")+    @MainActor+    func recomputeResetsCursorToNilWhenNoMatches() {+        let blocks: [MarkdownBlock] = [+            .paragraph(markdown: "Hello world."),+            .htmlComment(rawText: "needle annotation")+        ]+        let coordinator = SearchCoordinator()+        coordinator.blockProvider = { blocks }+        coordinator.footnoteDataProvider = { .empty }+        var showComments = true+        coordinator.showHTMLCommentsProvider = { showComments }+        coordinator.onMatchSelected = {}++        // ON: comment text matches.+        coordinator.setActiveSearchQueryForTesting("needle")+        #expect(coordinator.totalMatchCount == 1)+        #expect(coordinator.currentGlobalMatchIndex == 0)++        // OFF: the only match disappears; the cursor goes to nil (0-of-0+        // per Req 5.4).+        showComments = false+        coordinator.recomputeAfterVisibilityChange()+        #expect(coordinator.totalMatchCount == 0)+        #expect(coordinator.currentGlobalMatchIndex == nil)+    }++    @Test("Recompute resets cursor to first match in document order")+    @MainActor+    func recomputeResetsCursorToFirstMatch() {+        // Document has matches both in body text AND in a comment. With+        // the toggle ON, all matches contribute and the cursor can be+        // moved away from the first. With the toggle OFF the comment+        // match disappears; recompute resets the cursor to the first+        // remaining match (index 0) per Req 5.4.+        let blocks: [MarkdownBlock] = [+            .paragraph(markdown: "needle in body"),+            .htmlComment(rawText: "another needle in a comment"),+            .paragraph(markdown: "yet another needle later")+        ]+        let coordinator = SearchCoordinator()+        coordinator.blockProvider = { blocks }+        coordinator.footnoteDataProvider = { .empty }+        var showComments = true+        coordinator.showHTMLCommentsProvider = { showComments }+        coordinator.onMatchSelected = {}++        coordinator.setActiveSearchQueryForTesting("needle")+        #expect(coordinator.totalMatchCount == 3)+        // Move cursor away from the first match.+        coordinator.navigateToMatch(at: 2)+        #expect(coordinator.currentGlobalMatchIndex == 2)++        // Toggle OFF: comment match disappears, leaving two matches.+        // recomputeAfterVisibilityChange resets the cursor to the first+        // remaining match.+        showComments = false+        coordinator.recomputeAfterVisibilityChange()+        #expect(coordinator.totalMatchCount == 2)+        #expect(coordinator.currentGlobalMatchIndex == 0)+    } }
prismUITests/HTMLCommentToggleUITests.swift Added +272 / -0
diff --git a/prismUITests/HTMLCommentToggleUITests.swift b/prismUITests/HTMLCommentToggleUITests.swiftnew file mode 100644index 0000000..3e8656a--- /dev/null+++ b/prismUITests/HTMLCommentToggleUITests.swift@@ -0,0 +1,272 @@+//+//  HTMLCommentToggleUITests.swift+//  prismUITests+//+//  Created by Claude on 23/5/2026.+//+//  UI tests for the "Show HTML comments" Settings toggle while a document+//  is open.+//+//  Covers:+//  - Req 1.5: live re-render when the preference changes WHILE a+//    document is open.+//  - Req 5.4: when the preference changes while a search query is+//    active, recompute matches and refresh highlights.++import XCTest++/// UI tests for the HTML comments toggle interacting with an open document.+final class HTMLCommentToggleUITests: XCTestCase {++    var app: XCUIApplication!++    /// Markdown fixture containing both stand-alone and inline HTML+    /// comments plus uniquely-keyed body text so we can drive search and+    /// detect re-render behaviour.+    private let documentMarkdown = """+    # HTML Comment Toggle Test++    <!-- this is a standalone author note containing UNIQUEALPHA -->++    Body paragraph one with embedded <!-- inline UNIQUEBETA note --> annotation.++    Another paragraph with a <!-- second UNIQUEGAMMA note --> mid-sentence.++    Filler paragraph to ensure the document is scrollable. Lorem ipsum+    dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor+    incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,+    quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea+    commodo consequat.++    More filler so the body has obvious vertical extent.+    """++    override func setUpWithError() throws {+        continueAfterFailure = false+        app = XCUIApplication()+        app.launchArguments = ["--uitesting"]++        // Seed the clipboard so the Welcome view's "Paste from Clipboard"+        // button gives us a deterministic document.+        #if os(macOS)+        let pasteboard = NSPasteboard.general+        pasteboard.clearContents()+        pasteboard.setString(documentMarkdown, forType: .string)+        #else+        UIPasteboard.general.string = documentMarkdown+        #endif++        app.launch()+    }++    override func tearDownWithError() throws {+        app = nil+    }++    // MARK: - Live re-render (Req 1.5)++    /// Toggle the setting from Settings while the document is visible;+    /// the body should re-render without losing scroll position.+    @MainActor+    func testTogglingShowHTMLCommentsReRendersOpenDocument() throws {+        try navigateToDocument()++        // Verify the toggle starts OFF: the inline comment text should+        // not be visible. The standalone comment's inner text contains+        // UNIQUEALPHA which is unique to a hidden comment.+        let hiddenAlphaPredicate = NSPredicate(format: "label CONTAINS[c] 'UNIQUEALPHA'")+        XCTAssertEqual(+            app.staticTexts.matching(hiddenAlphaPredicate).count,+            0,+            "When toggle is OFF, UNIQUEALPHA (inside a comment) must not appear"+        )++        // Open Settings and flip the toggle ON.+        try toggleShowHTMLComments(expectedFinalValue: true)++        // After dismissing Settings, the document re-renders with comments+        // visible. The standalone comment's UNIQUEALPHA text should now+        // appear in the body.+        let visibleAlphaText = app.staticTexts.matching(hiddenAlphaPredicate)+        XCTAssertTrue(+            waitForCount(visibleAlphaText, atLeast: 1, timeout: 5),+            "When toggle is ON, the standalone comment's inner text must appear in the body"+        )++        // The original document body must still be present — re-render+        // must not lose document content. We check the first body+        // paragraph as a smoke signal.+        let bodyTextPredicate = NSPredicate(format: "label CONTAINS[c] 'Body paragraph one'")+        XCTAssertGreaterThan(+            app.staticTexts.matching(bodyTextPredicate).count,+            0,+            "Body text must still be present after the re-render"+        )++        // Flip the toggle back OFF to verify the round-trip and restore+        // default state for subsequent tests.+        try toggleShowHTMLComments(expectedFinalValue: false)+        XCTAssertTrue(+            waitForCount(app.staticTexts.matching(hiddenAlphaPredicate), atMost: 0, timeout: 5),+            "When toggled back OFF, UNIQUEALPHA must disappear from the body"+        )+    }++    // MARK: - Search refresh (Req 5.4)++    /// With a search active for text that lives inside a comment, the+    /// match count and highlight refresh when the toggle changes.+    ///+    /// Specifically, searching for UNIQUEBETA (inline comment content)+    /// should produce zero matches when the toggle is OFF and at least+    /// one match when the toggle is ON, with the change reflected after+    /// flipping the toggle WITHOUT re-entering the search query.+    @MainActor+    func testTogglingShowHTMLCommentsWhileSearchActiveRefreshesMatches() throws {+        try navigateToDocument()++        // Open a search bar — either the inline search bar (regular+        // layout) or the search overlay (compact layout). We accept+        // whichever the harness produces.+        let searchField = openSearchField()+        guard let field = searchField else {+            throw XCTSkip("Could not locate a search field for this layout")+        }++        field.tap()+        field.typeText("UNIQUEBETA")++        // Wait a moment for search to settle.+        sleep(1)++        // We can't reliably assert "zero matches" via the match-count+        // label because its accessibility text varies by layout. Instead+        // we assert the inline comment text (UNIQUEBETA) becomes visible+        // only after enabling the toggle — that is the user-visible+        // outcome of the search-aware re-render.+        let betaPredicate = NSPredicate(format: "label CONTAINS[c] 'UNIQUEBETA'")+        XCTAssertEqual(+            app.staticTexts.matching(betaPredicate).count,+            0,+            "OFF + search active: UNIQUEBETA must not be visible in the body"+        )++        // Dismiss any keyboard/search overlay to reach Settings.+        if app.keyboards.firstMatch.exists {+            app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])+        }++        // Toggle ON while the search query is still active.+        try toggleShowHTMLComments(expectedFinalValue: true)++        XCTAssertTrue(+            waitForCount(app.staticTexts.matching(betaPredicate), atLeast: 1, timeout: 5),+            "ON + search active: UNIQUEBETA must become visible in the body after the toggle"+        )++        // Restore default state.+        try toggleShowHTMLComments(expectedFinalValue: false)+    }++    // MARK: - Helpers++    /// Opens the document via the Welcome view's "Paste from Clipboard"+    /// button. Throws `XCTSkip` if the paste button is unavailable.+    private func navigateToDocument() throws {+        let pasteButton = app.buttons["Paste from Clipboard"]+        guard pasteButton.waitForExistence(timeout: 3) else {+            XCTFail("Paste from Clipboard button should exist on WelcomeView")+            return+        }+        guard pasteButton.isEnabled else {+            throw XCTSkip("Clipboard paste unavailable — cannot drive document UI test")+        }+        pasteButton.tap()++        // Wait for the document to render — the document toolbar Source+        // button appears once the layout settles.+        let documentReady = app.buttons["Source"].waitForExistence(timeout: 5)+        if !documentReady {+            throw XCTSkip("Document did not finish loading after paste")+        }+    }++    /// Locates a search input field across both compact and regular+    /// layouts. Returns `nil` if none can be found.+    private func openSearchField() -> XCUIElement? {+        // Regular-layout inline search bar uses the textField identifier+        // "search-field"; compact-layout overlay also uses it.+        let inline = app.textFields["search-field"]+        if inline.waitForExistence(timeout: 2) {+            return inline+        }++        // Try to surface the search via toolbar / menu commands.+        let searchButton = app.buttons["Search document"]+        if searchButton.exists {+            searchButton.tap()+            if inline.waitForExistence(timeout: 2) {+                return inline+            }+        }++        #if os(macOS)+        // Cmd-F as a fallback on macOS.+        app.typeKey("f", modifierFlags: .command)+        if inline.waitForExistence(timeout: 2) {+            return inline+        }+        #endif++        return nil+    }++    /// Opens Settings, toggles the "Show HTML comments" switch to the+    /// requested value, and dismisses Settings.+    private func toggleShowHTMLComments(expectedFinalValue: Bool) throws {+        let settingsButton = app.buttons["Settings"]+        guard settingsButton.waitForExistence(timeout: 3) else {+            XCTFail("Settings button should exist in the document toolbar")+            return+        }+        settingsButton.tap()++        let toggle = app.switches["Show HTML comments"]+        guard toggle.waitForExistence(timeout: 3) else {+            XCTFail("Show HTML comments toggle should exist in Settings")+            return+        }++        let currentValue = (toggle.value as? String) == "1"+        if currentValue != expectedFinalValue {+            toggle.tap()+        }++        let doneButton = app.buttons["Done"]+        if doneButton.waitForExistence(timeout: 3) {+            doneButton.tap()+        }+    }++    /// Waits up to `timeout` seconds for `query.count` to be at least+    /// `atLeast`. Returns `true` if reached, `false` if timed out.+    private func waitForCount(_ query: XCUIElementQuery, atLeast: Int, timeout: TimeInterval) -> Bool {+        let deadline = Date().addingTimeInterval(timeout)+        while Date() < deadline {+            if query.count >= atLeast { return true }+            usleep(100_000)+        }+        return false+    }++    /// Waits up to `timeout` seconds for `query.count` to be at most+    /// `atMost`. Returns `true` if reached, `false` if timed out.+    private func waitForCount(_ query: XCUIElementQuery, atMost: Int, timeout: TimeInterval) -> Bool {+        let deadline = Date().addingTimeInterval(timeout)+        while Date() < deadline {+            if query.count <= atMost { return true }+            usleep(100_000)+        }+        return false+    }+}
specs/OVERVIEW.md Modified +10 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex b358228..b357825 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -69,6 +69,7 @@ | [prism-notes.js](#prism-notes-js) | 2026-05-16 | Done | Self-contained single-file JavaScript library for adding basic note-taking and copy-as-markdown to any HTML page; personal use, no sync/share/import | | [Tables in List Items](#tables-in-list-items) | 2026-05-17 | Done | Render markdown tables that appear inside list items (currently shown as raw markdown); per-row notes deferred (T-1034) | | [macOS URL Drop](#macos-url-drop) | 2026-05-18 | Planned | Drag-and-drop entry point for opening markdown files and `http(s)` URLs onto the Prism window on macOS (T-432) |+| [Render HTML Comments](#render-html-comments) | 2026-05-22 | Done | Opt-in toggle to render HTML comments as styled inline annotations (T-450) |  --- @@ -700,3 +701,12 @@ Wire a SwiftUI `dropDestination(for: URL.self)` modifier on the macOS window so - [smolspec.md](macos-url-drop/smolspec.md) - [tasks.md](macos-url-drop/tasks.md) - [decision_log.md](macos-url-drop/decision_log.md)++## Render HTML Comments++Replace today's raw-HTML fall-through for `<!-- ... -->` with explicit handling: a new `Show HTML comments` Settings toggle (default off) controls whether comments render as styled inline annotations. When on, both block-level and inline comments render as italic, dimmed text prefixed with the `info.circle` SF Symbol; markers are stripped. When off, comments are hidden entirely. Toggling does not reparse the AST; search, copy/export, and accessibility honour the toggle. Covers Transit ticket T-450.++- [requirements.md](render-html-comments/requirements.md)+- [design.md](render-html-comments/design.md)+- [decision_log.md](render-html-comments/decision_log.md)+- [tasks.md](render-html-comments/tasks.md)
specs/render-html-comments/decision_log.md Added +718 / -0
diff --git a/specs/render-html-comments/decision_log.md b/specs/render-html-comments/decision_log.mdnew file mode 100644index 0000000..c512b9f--- /dev/null+++ b/specs/render-html-comments/decision_log.md@@ -0,0 +1,718 @@+# Decision Log: Render HTML Comments++## Decision 1: Spec Folder Name++**Date**: 2026-05-22+**Status**: accepted++### Context++Transit ticket T-450 ("Conditionally render HTML comments") needs a stable folder under `specs/` matching the feature, with conventions favouring short, kebab-case names that match the user-facing concept.++### Decision++Use `specs/render-html-comments/` for all spec artifacts (requirements, design, tasks, decision log).++### Rationale++`render-html-comments` matches the Settings label ("Render HTML comments") and the ticket title, making the spec easy to find from both directions. It is descriptive without being unnecessarily long.++### Alternatives Considered++- **`html-comments`**: Shorter — rejected as too generic; might be confused with future features about HTML handling in general.+- **`html-comment-rendering`**: Descriptive — rejected as slightly more verbose than necessary for the same meaning.++### Consequences++**Positive:**+- Name aligns with the Settings string the user will see.+- Easy to discover via search by the ticket title.++**Negative:**+- Slightly long compared to other spec folder names in the repo.++---++## Decision 2: Scope Includes Inline HTML Comments++**Date**: 2026-05-22+**Status**: accepted++### Context++HTML comments appear in markdown source both as stand-alone blocks (their own `HTMLBlock` in swift-markdown's AST) and embedded inside paragraphs (carried inside paragraph markdown). Rendering only the block form would leave mid-paragraph comments silently dropped, which is the same behaviour as today.++### Decision++Render both block-level HTML comments and inline HTML comments inside paragraphs when the toggle is on.++### Rationale++Authors commonly drop short `<!-- TODO: ... -->` notes mid-sentence; treating them differently from block-level comments would be confusing and incomplete. The cost is moderate: inline handling requires either preprocessing the paragraph markdown or adding a Textual syntax extension, but the project already has precedent (footnote badges) for inline custom rendering via syntax extensions.++### Alternatives Considered++- **Block-level only**: Smaller and cleaner — rejected because it leaves a visible asymmetry between block and inline cases and would require a follow-up ticket for parity.+- **Block-level now, inline as follow-up ticket**: Sequencing — rejected because the user explicitly asked for both in the same change and the cost of doing them together is lower than splitting the work.++### Consequences++**Positive:**+- Consistent reader behaviour: all hidden comments become visible or stay hidden together.+- Single setting governs all cases.++**Negative:**+- Adds inline rendering complexity (new Textual syntax extension or paragraph preprocessing).+- Slightly larger surface area for tests.++---++## Decision 3: Default Preference Is OFF (Behaviour Change)++**Date**: 2026-05-22+**Status**: accepted++### Context++The current behaviour of the app is that HTML comments fall through to the raw-HTML rendering path and are displayed as visible body text with the `<!--` / `-->` markers intact. This looks like noise in the reading experience: most users do not want raw comment markup in the rendered document. Two reasonable defaults exist for the new toggle: preserve the current "show as raw" behaviour, or hide comments entirely and require the user to opt in to seeing them.++### Decision++Ship the toggle with a default of OFF, where OFF means HTML comments are hidden entirely (no markers, no text). ON applies the new styled rendering (markers stripped, dimmer italic-style text with the indicator glyph).++### Rationale++The current "show as raw text with markers" behaviour is an artefact of the raw-HTML fall-through path, not a deliberate UX decision. Hiding comments by default is closer to the convention in other Markdown viewers and respects author intent (HTML comments are conventionally private). Users who want to see them get a one-tap path via the new toggle, with proper styling. The Transit ticket explicitly requests "Default to false" for the toggle, with "false" meaning "do not render as comments", which aligns with this interpretation.++### Alternatives Considered++- **Default OFF means "preserve current behaviour" (raw text with markers visible)**: Zero regression for existing users — rejected because it leaves the existing noisy rendering in place for the majority of users who never discover the toggle; the current behaviour is itself a defect more than a feature.+- **Default ON**: Could surface useful author notes by default — rejected because some authors use HTML comments deliberately to hide content from readers; flipping the default to visible would expose content authors expected to be private.++### Consequences++**Positive:**+- Cleaner default reading experience: no raw comment markers in body text.+- Single toggle covers both "hide cleanly" (OFF) and "render properly" (ON).+- Aligns with the Transit ticket's stated default.++**Negative:**+- Technically a behaviour change vs. today's fall-through rendering. Impact is negligible: Prism has not shipped to the App Store yet, and the only affected audience is a small TestFlight cohort, so no formal migration messaging is required.++---++## Decision 4: Settings Section — New "Markdown Rendering" Group++**Date**: 2026-05-22+**Status**: accepted++### Context++The toggle could live in the existing Raw Source section, the Appearance section, or a new section. None of the existing sections cleanly express "rendering-behaviour preferences for the parsed document"; Raw Source is about the raw view, Appearance is about visual styling.++### Decision++Add a new "Markdown Rendering" section in Settings and place the toggle there.++### Rationale++Creating a dedicated home now matches the toggle's semantics (it changes how the parsed document renders, not the raw source view or theming) and gives a natural place for future rendering toggles (e.g., wide tables already lives under its own grouping; admonitions or callouts could land here later).++### Alternatives Considered++- **Raw Source section**: Adjacent rendering-related toggles — rejected because Raw Source is specifically about the raw-markdown view; an HTML comment toggle affects the rendered view.+- **Appearance section**: Visual-feeling preference — rejected because comment rendering is a content-visibility decision, not styling.++### Consequences++**Positive:**+- Clear conceptual home for future rendering toggles.+- Keeps existing sections focused on their current purposes.++**Negative:**+- Adds a new section header to Settings (small UI cost).++---++## Decision 5: SF Symbol = `info.circle`++**Date**: 2026-05-22+**Status**: accepted++### Context++The user requested an "info-type Apple symbol (not an emoji)" to prefix rendered comments. SF Symbols offers several candidates: `info.circle`, `info.bubble`, `text.bubble`, `bubble.left`.++### Decision++Use `info.circle` as the prefix symbol for rendered HTML comments, both block and inline.++### Rationale++`info.circle` is the standard Apple HIG symbol for informational annotations and is widely recognised across iOS / macOS. The other speech-bubble variants imply user-authored commentary rather than embedded author notes.++### Alternatives Considered++- **`info.bubble`**: Speech-bubble style — rejected as less standard and visually heavier.+- **`text.bubble`**: Strong "comment" association — rejected as implying user-generated commentary rather than read-only author notes.+- **`bubble.left`**: Conveys "side note" — rejected for the same reason.++### Consequences++**Positive:**+- Recognisable, standard symbol.+- Consistent with existing Apple UI conventions.++**Negative:**+- None significant.++---++## Decision 6: Live Re-render on Toggle Change++**Date**: 2026-05-22+**Status**: accepted++### Context++Other rendering-related settings in Prism (raw source toggle, table display mode) update the open document immediately when changed. Requiring users to close and reopen a file to see HTML comments appear would feel stale.++### Decision++When the preference changes while a document is open, re-render the document immediately to reflect the new state.++### Rationale++Matches existing UX for rendering toggles. The technical cost is invalidating the parsed-block cache (or re-running the parse) when the setting changes; this is consistent with how the table display mode and similar settings already behave.++### Alternatives Considered++- **Apply on next document open**: Simpler implementation — rejected as inconsistent with other rendering toggles in the app.++### Consequences++**Positive:**+- Consistent UX across rendering toggles.+- Immediate user feedback when discovering the setting.++**Negative:**+- Requires the parsing pipeline to depend on the setting (or to be re-run when the setting changes).++---++## Decision 7: Edge Cases — Empty / Malformed / Code-Block / Mid-Word++**Date**: 2026-05-22+**Status**: accepted++### Context++HTML comments come in many shapes: empty (`<!---->`), whitespace-only (`<!--   -->`), unterminated (`<!--` with no `-->`), inside code blocks, and embedded between word characters.++### Decision++Apply the following rules uniformly:+- Empty / whitespace-only comments render nothing even when the toggle is on (no bare symbol, no chip).+- Comments inside fenced or indented code blocks render literally as part of the code content regardless of the toggle.+- Inline comments embedded between word characters (`foo<!--x-->bar`) render as three sequential pieces, breaking the word.+- Malformed `<!--` without a matching `-->` is left as-is and passes through the existing raw-HTML path.++### Rationale++These rules avoid surprising failure modes: empty comments would otherwise render an orphan symbol, code-block comments must stay verbatim for code accuracy, mid-word splits respect the source structure, and refusing to guess a closing point for unterminated comments avoids silently swallowing real content.++### Alternatives Considered++- **Render empty comments as a bare symbol**: Could indicate "hidden empty note" — rejected as visual noise with no value.+- **Try to fuse `foo` and `bar` across an inline comment**: Could keep words intact — rejected because Markdown's own behaviour for HTML inline elements already breaks the surrounding text run.+- **Auto-close unterminated `<!--` at end-of-block**: Could rescue malformed input — rejected because it risks swallowing legitimate content the author intended to display.++### Consequences++**Positive:**+- Predictable, source-respecting behaviour.+- Avoids confusing renders for edge inputs.++**Negative:**+- Each edge case adds at least one test.++---++## Decision 8: Copy / Export Includes Rendered Comments; No Annotation Notes++**Date**: 2026-05-22+**Status**: accepted++### Context++Prism's copy / export flows reflect the rendered document. The question is whether rendered HTML comments participate in those flows and whether they can be annotated like other content blocks.++### Decision++- When the toggle is ON, copy / share / export operations include rendered comment text (markers stripped, info symbol included as a textual or rendered prefix).+- When the toggle is OFF, comments never appear in any copy / export output.+- Rendered HTML comments are NOT eligible targets for user annotation notes regardless of toggle state.++### Rationale++Users expect output to match what they see; including rendered comments in export when the toggle is on preserves that mental model. Disallowing annotation notes mirrors the existing behaviour of the raw `.html` block (which also does not support notes) and avoids surfacing implementation choices about anchoring notes to a comment that may move or be removed at the source level.++### Alternatives Considered++- **Always include comments in export regardless of toggle**: Simpler — rejected because it leaks hidden content out of the app context.+- **Allow annotation notes on rendered comments**: More uniform with paragraphs / lists — rejected because notes are anchored by block identity and comments are conceptually source-level metadata, not document content.++### Consequences++**Positive:**+- Export matches on-screen rendering.+- Note system stays scoped to true document content.++**Negative:**+- Slight inconsistency: a block visible on screen is not annotatable.++---++## Decision 9: Search Honours The Toggle++**Date**: 2026-05-22+**Status**: accepted++### Context++Document search currently indexes the visible body of each block via `searchableText`. If comment text is invisible (toggle off), searching for words inside a comment finding a hit would be confusing.++### Decision++Include comment text in document search results when and only when the toggle is ON. When a search is active and the toggle is changed, recompute matches against the new visibility, update the total match count, and reset the current-match cursor to the first match in document order.++### Rationale++Search should match what the user can see. Resetting the cursor to the first match (rather than trying to keep the previous match position) is the simplest predictable behaviour and avoids the awkward state where the cursor sits past the end of the new match list.++### Alternatives Considered++- **Always search comments regardless of toggle**: Could surface useful metadata — rejected because it gives invisible hits, leading to confusion when the toggle is off.+- **Never search comment text**: Simpler — rejected because it omits visible text from search when the toggle is on.+- **Preserve the current match index on refresh**: Less disruptive — rejected because the index may now point past the end of the result list, requiring extra clamping logic with no real UX benefit.++### Consequences++**Positive:**+- Search results match what the user sees.+- Cursor behaviour after toggle is predictable.++**Negative:**+- `searchableText` (or its consumer) needs access to the toggle state.++---++## Decision 10: Mixed Comment + Other HTML Blocks Pass Through Unchanged++**Date**: 2026-05-22+**Status**: accepted++### Context++An `HTMLBlock` in swift-markdown's AST can contain a mix of HTML comments and other HTML. Earlier drafts proposed splitting the block — rendering the comment portion with the comment styling and rendering the surrounding HTML through the existing raw-HTML path. External review flagged this as ambiguous (how is the block split? interleaved? wrapped?) and as a parser trap.++### Decision++If an `HTMLBlock` contains any non-comment HTML content alongside comments, the entire block renders through the existing raw-HTML path unchanged. Only `HTMLBlock` instances that are comment-only (one or more `<!-- ... -->` with optional surrounding whitespace) participate in the new rendering.++### Rationale++Splitting a mixed block is genuinely ambiguous and would require defining synthetic block boundaries with no clean source-order anchor. Comment-only blocks are by far the common case for the use-cases driving this feature (TODOs, author notes between paragraphs). The pragmatic choice is to do the obvious thing for the common case and leave the edge case untouched.++### Alternatives Considered++- **Split the block and render comments separately interleaved with raw HTML segments**: More uniform — rejected because it requires inventing synthetic blocks and introduces ambiguity about ordering and spacing for cases that almost never occur in real documents.+- **Always strip comments out of any HTML block**: Could surface comments anywhere — rejected because the raw-HTML path is specifically for content Prism does not pretend to render; mutating it is out of scope.++### Consequences++**Positive:**+- Clear, testable rule.+- No new ambiguity in the raw-HTML rendering path.++**Negative:**+- Comments embedded inside an otherwise-non-rendered HTML block stay hidden even when the toggle is on.++---++## Decision 11: Comments in Headings Stripped from ToC, Anchors, and Titles++**Date**: 2026-05-22+**Status**: accepted++### Context++The codebase already strips footnote references (`[^id]`) from heading text used for anchor IDs, table-of-contents entries, navigation titles, and accessibility labels — see `FootnoteStripping.swift`. HTML comments embedded in heading text raise the same question: should the comment text contribute to derived navigation surfaces?++### Decision++Mirror the footnote-stripping pattern: HTML comments embedded in heading text are stripped from anchor IDs, ToC entries, navigation titles, and heading accessibility labels regardless of the preference. When the preference is ON, the comment is still rendered visibly inside the heading body (per the inline rules); only the derived surfaces are stripped.++### Rationale++Anchor IDs and navigation surfaces are stable identifiers; they should not change when the user toggles a visibility preference. Re-using the existing `FootnoteStripping` precedent keeps the behaviour consistent across the two features and reduces the surface area of new utility code.++### Alternatives Considered++- **Include comment text in ToC entries**: Could expose more context — rejected because it changes anchor IDs when the user toggles the preference and clutters the ToC with notes that are not chapter labels.+- **Reject HTML comments in headings entirely (render as literal)**: Simpler — rejected because it punishes a legitimate authoring pattern.++### Consequences++**Positive:**+- Consistent treatment with existing footnote handling.+- Stable anchor IDs and ToC entries regardless of toggle state.++**Negative:**+- Introduces a small additional stripping function similar to `FootnoteStripping`.++---++## Decision 12: Comments in Link Labels and Image Alt/Title Stripped++**Date**: 2026-05-22+**Status**: accepted++### Context++HTML comments can appear inside link labels (`[foo<!--x-->bar](url)`), link titles, image alt text, and image titles. The rendering rules for these contexts are not the same as a paragraph: link labels render as link text, image alt is consumed by assistive technology rather than displayed.++### Decision++HTML comments inside link labels, link titles, image alt text, and image titles are stripped from the rendered/derived value regardless of the preference. They do not produce visible glyphs.++### Rationale++Rendering a glyph inside a link label is visually awkward (the glyph would become part of the clickable text). Image alt text is by convention a short non-decorative description; injecting comment content into it could conflict with assistive-technology expectations.++### Alternatives Considered++- **Render comments inline within link labels as in paragraphs**: Uniform — rejected because it makes the link harder to scan and the glyph becomes part of the tappable target.+- **Surface link-label comments as tooltips**: Could preserve information — rejected as out of scope and inconsistent with paragraph behaviour.++### Consequences++**Positive:**+- Predictable link and image rendering.+- No interference with assistive descriptions.++**Negative:**+- Comments in link labels are invisible even when the preference is on; this is a deliberate trade-off.++---++## Decision 13: Plain-Text Export Uses Localised "Comment:" Prefix++**Date**: 2026-05-22+**Status**: accepted++### Context++When rendered comments appear in copy / share / export output, the on-screen indicator glyph (an SF Symbol) does not survive into plain text. Embedding the symbol's Unicode character would not be portable across consumers of the exported text.++### Decision++For plain-text export and clipboard outputs, replace the indicator glyph with a localised "Comment: " prefix (or equivalent in the target locale). For rich (attributed-text / formatted) export, keep the glyph as rendered on screen.++### Rationale++The plain-text consumer (other apps' text fields, email clients, terminals) cannot rely on the glyph rendering correctly. A localised textual prefix preserves the semantic information without depending on SF Symbol availability in the consuming context.++### Alternatives Considered++- **Use the Unicode character `\u{24D8}` (ⓘ)**: Universal-ish — rejected because the visual representation varies wildly across fonts and is not always recognisable.+- **Omit the prefix entirely in plain text**: Simpler — rejected because it loses the distinction between comments and regular text in the exported content.++### Consequences++**Positive:**+- Export remains readable in plain-text contexts.+- Rich exports retain the on-screen affordance.++**Negative:**+- One more localised string to maintain.++---++## Decision 14: Inline Accessibility Uses Paragraph-Scoped Boundary Markers++**Date**: 2026-05-22+**Status**: accepted++### Context++A paragraph can contain multiple inline HTML comments. Announcing "Comment, X, …, Comment, Y, …, Comment, Z, …" fragments the surrounding speech and is exhausting for VoiceOver users. We need a way to mark inline comments distinctively without inserting a marker before every occurrence.++### Decision++For each paragraph that contains one or more inline HTML comments, the paragraph's accessibility text announces a localised "Comment" marker once at the start of the first inline comment in the paragraph and a localised "End comment" marker after the last inline comment in the paragraph. Block-level comments still announce "Comment" once per block.++### Rationale++This keeps the announcement coherent for paragraphs with several comments (a single bracketing pair instead of N pairs) while still flagging the speech as containing comment material. It mirrors the way some screen readers handle adjacent inline emphasis runs.++### Alternatives Considered++- **Announce "Comment, … " before each inline comment**: Most explicit — rejected because it severely fragments paragraph speech when several comments appear.+- **Announce nothing extra for inline comments**: Cleanest speech — rejected because it loses the comment-vs-body distinction in the audio output.++### Consequences++**Positive:**+- VoiceOver users can still distinguish comment content.+- Paragraph speech stays coherent.++**Negative:**+- Implementation requires composing paragraph accessibility text from its parts rather than relying on the default reading.++---++## Decision 15: Toggle Label Is "Show HTML Comments"++**Date**: 2026-05-22+**Status**: accepted++### Context++The Transit ticket proposed "Render HTML comments" as the toggle label. While accurate to the implementation, "render" reads as technical jargon to most readers.++### Decision++Use "Show HTML comments" as the user-facing label for the Settings toggle. Internal identifiers (preference keys, type names, etc.) may continue to use a "render"-flavoured naming if it reads better in code; the user-facing string is the one that matters.++### Rationale++"Show" is unambiguous everyday English: ON shows them, OFF doesn't. "Render" is correct but reads as an engineering term and is less direct.++### Alternatives Considered++- **"Render HTML comments"**: The original ticket wording — rejected as less readable for non-technical users.+- **"Display HTML comments"**: Equivalent — slightly more formal; "Show" reads more natural in a Settings list.++### Consequences++**Positive:**+- Clearer label for non-technical users.+- Aligns with conventional Settings phrasing ("Show inline notes", "Show line numbers", etc.).++**Negative:**+- Slight divergence between user-facing label and the underlying preference key name in code; resolved by treating the key as an implementation detail.++---++## Decision 16: Mixed-Content HTML Blocks Stripped of Comment Markers When Falling Back++**Date**: 2026-05-22+**Status**: accepted++### Context++When an `HTMLBlock` contains both HTML comments and other HTML (Decision 10), the block now passes through to the `.html` fall-back path. Today's `.html` rendering shows raw content, so the `<!--…-->` markers would appear on screen regardless of the toggle. That contradicts the user's mental model ("comments are hidden when the toggle is off").++### Decision++The `MarkdownBlockParser.convert()` HTMLBlock fall-back wraps its content in `HTMLCommentStripping.strip(_:)` before producing `.html(content:)`. Mixed-content raw HTML therefore never shows `<!--…-->` markers in any toggle state. The visible-rendering toggle exclusively governs the styled `.htmlComment` path; it does not need to control the raw-HTML path.++### Rationale++Hiding the markers in the raw path is a one-line addition with no semantic loss — the non-comment HTML still renders as it does today. Combining the two paths (toggle off across both styled and raw) keeps the user-visible behaviour consistent.++### Alternatives Considered++- **Leave raw-HTML path untouched**: simplest — rejected because it leaks `<!--` markers into mixed-content blocks regardless of toggle, contradicting the user model.+- **Extend the `.htmlComment` block path to also split mixed content**: rejected per Decision 10 (mixed splitting is ambiguous).++### Consequences++**Positive:**+- Consistent toggle-off behaviour for both comment-only and mixed-content blocks.+- No new ambiguity in the raw-HTML rendering path.++**Negative:**+- The raw-HTML path now performs an extra regex strip on every fall-through; cost is negligible.++---++## Decision 17: List-Item Continuity Across Comment Blocks via Ordinal Offset++**Date**: 2026-05-22+**Status**: accepted++### Context++Req 4.4 requires that a stand-alone HTML comment between two list items not break the list. swift-markdown emits two separate list nodes when an `HTMLBlock` sits between them. Lifting siblings into a parent list at AST-walk time would break the parser invariant that each `MarkdownBlock` corresponds to a single AST node.++### Decision++Render the two lists separately, but pass an `ordinalOffset` to the second list when its preceding sibling is `.htmlComment` AND the first list was ordered. The second list's items resume numbering from `firstList.itemCount + 1`. Visual contiguity is achieved by the existing block-spacing convention (Req 2.9 already requires comment-block spacing to match paragraph-block spacing). For unordered lists, no numbering is needed and visual contiguity alone satisfies the requirement.++### Rationale++This delivers the user-visible outcome ("items remain part of the same list") without violating the parser invariant. The alternative — synthesising parent-list membership — would require a deep rewrite of the AST-walk and would not generalise cleanly to nested HTMLBlock-between-items shapes.++### Alternatives Considered++- **Lift HTMLBlock siblings into the surrounding list at AST-walk time**: would deliver literal compliance — rejected as invasive and risk-laden; breaks the one-AST-node-per-block invariant.+- **Accept the two-list rendering with no ordinal offset**: simpler — rejected because ordered lists would visibly restart at 1, violating the spirit of the requirement.++### Consequences++**Positive:**+- Requirement met for the common ordered-list case.+- Parser invariants preserved.++**Negative:**+- `ordinalOffset` adds a small new piece of state to list rendering.++---++## Decision 18: Link-Label Stripping at Parse Time, Not in the Textual Extension++**Date**: 2026-05-22+**Status**: accepted++### Context++Req 4.5 says HTML comments inside link labels, link titles, and image alt/title must not produce a visible glyph. The Textual inline extension scans the AttributedString for `<!--…-->` ranges; without context awareness it would render the glyph inside a link's tappable label.++### Decision++Strip HTML comments from inside markdown link labels at parse time via a new `MarkdownBlockParser.stripCommentsInsideLinkLabels(_:)` helper applied in `convertParagraph` before storing `.paragraph(markdown:)`. Image alt and title are handled at parse time too (they are extracted into `.image` arguments). Outside-link inline comments remain in the paragraph markdown for the Textual extension to handle.++### Rationale++Textual's pattern processor does not expose link-range context to extensions, and adding that capability is more invasive than a targeted regex pass on the markdown source. Link label positions are a stable parse-time structure, so manipulating them in source is straightforward.++### Alternatives Considered++- **Make the Textual extension link-aware**: cleaner conceptually — rejected because it requires changes to Textual's pattern-processor API and a fork PR larger than this feature warrants.+- **Render the glyph inside link labels**: rejected as a contradiction with Req 4.5.++### Consequences++**Positive:**+- Link labels stay clean regardless of toggle state.+- Image alt and title get the same treatment.++**Negative:**+- One more parse-time stripping pass; cost is negligible.++---++## Decision 19: Headings Treat Comments Symmetrically Across Render and Search++**Date**: 2026-05-22+**Status**: accepted++### Context++An earlier draft of the design proposed pre-stripping HTML comments from heading `searchableText` even when the toggle was on, while still rendering them inline in the heading body. That asymmetry was flagged as a logical inconsistency: rendered text should be searchable.++### Decision++Headings treat HTML comments the same way paragraphs do for search purposes: strip the markers from the base searchable string, and append the inner text only when the toggle is on. ToC entries, anchor IDs, and window titles continue to strip comments unconditionally via `HTMLCommentStripping` (Decision 11) — those are navigation surfaces, not rendered body.++### Rationale++Search results should match what the user sees. If a heading body renders a comment when the toggle is on, the comment text should be findable. The earlier asymmetry was an optimisation that violated the requirement.++### Alternatives Considered++- **Always exclude heading comments from search**: simpler — rejected because it contradicts the rendered-visible-equals-searchable invariant.++### Consequences++**Positive:**+- Search matches rendering for headings.+- Same rule applies across all block types.++**Negative:**+- `searchableText(in:)` for headings now includes a comment-detection step (negligible cost).++---++## Decision 20: Note-Infrastructure Tag Invariant (stripCommentTags Runs First)++**Date**: 2026-05-22+**Status**: accepted++### Context++Prism already uses HTML comments (`<!-- comment:HASH -->` and `<!-- /comment:HASH -->`) as anchor markers for the inline notes feature. `MarkdownBlockParser.stripCommentTags(_:)` removes these from the raw source before swift-markdown parses, so they never reach `HTMLBlock` and cannot be rendered as visible author comments. The new design depends on this layering.++### Decision++The design depends on `stripCommentTags` running before the HTMLBlock branch and before any new `HTMLCommentParser` step. A regression test (`MarkdownBlockParserTests.testCommentInfrastructureTagsNeverReachHTMLCommentParser`) pins this ordering. In addition, `HTMLCommentParser.parseBlock` defensively rejects inputs whose body matches `^\s*/?comment:[a-f0-9]+\s*$` as a second line of defence.++### Rationale++The infrastructure tags are not author content and must not be visible regardless of the toggle. Two layers of defence make this hard to break by accident in a future refactor.++### Alternatives Considered++- **Rely only on `stripCommentTags`**: simpler — rejected because a future re-order of the pipeline could surface infrastructure tags as visible "Comment, …" annotations, exposing internal note hashes.+- **Move infrastructure tag handling into `HTMLCommentParser`**: would conflate two concerns — rejected for separation of concerns.++### Consequences++**Positive:**+- Note-infrastructure tags stay invisible regardless of toggle state.+- Future refactors are guarded by an explicit test.++**Negative:**+- Two layers of defence is mildly redundant.++---++## Decision 21: Conditional Comments, CDATA, and DOCTYPE Rejected by Parser++**Date**: 2026-05-22+**Status**: accepted++### Context++`<!--[if IE]>…<![endif]-->` (conditional comments) is listed as a Non-Goal in the requirements. CDATA sections (`<![CDATA[…]]>`) and DOCTYPE declarations (`<!DOCTYPE …>`) are not HTML comments at all but resemble comment syntax superficially. The `HTMLCommentParser` regex `<!--…-->` would otherwise misclassify conditional comments as renderable author notes.++### Decision++`HTMLCommentParser.parseBlock` returns `nil` (caller falls back to `.html`) when:+- any comment body starts with `[if` or `[endif` (case-insensitive, optional whitespace), OR+- the input contains `<![CDATA[` or `<!DOCTYPE` tokens (defensive — these do not start with `<!--` so the structural check already rejects them, but the parser asserts this explicitly).++### Rationale++Conditional comments are a different feature with different semantics; rendering their bodies as author notes would be confusing. CDATA and DOCTYPE are not comments. Explicit rejection prevents subtle misclassification.++### Alternatives Considered++- **Treat conditional comments as regular author comments**: rejected per the Non-Goal in requirements.+- **Pass through silently to `.html`**: this is what happens — the rejection only means "fall back to the existing path", which is the desired behaviour.++### Consequences++**Positive:**+- Clean separation between commenting forms.+- No misclassification risk for HTML constructs that resemble comments.++**Negative:**+- Two small regex checks added to the parser.++---++## Notes for Design Phase (non-blocking)++The following items came out of external review but are implementation choices rather than decisions to lock in at requirements time. The design phase should address each:++1. **Parsing strategy**: swift-markdown places block-level HTML comments inside `HTMLBlock.rawHTML`; inline HTML comments stay inside paragraph markdown. Options include a `MarkupRewriter`, a regex pre-pass on raw markdown, a Textual syntax extension (the precedent set by footnote badges), or paragraph-level preprocessing in `MarkdownBlockParser.convertParagraph()`. The design should pick one approach and justify it.+2. **Cache decoupling**: Requirement 9.2 forbids re-running the swift-markdown parse on toggle change. The design must specify how the parsed-block representation carries enough information to render OR hide each comment without reparsing — typically by parsing comments into structured renderable + searchable content once and filtering at render time.+3. **Vertical spacing for block comments**: Make sure rendered comment blocks integrate with the existing block-spacing system rather than collapsing or doubling up against neighbours.+4. **Specific SF Symbol**: `info.circle` is the working candidate (Decision 5). External reviewers raised alternatives; design phase can confirm or substitute.++These items are tracked here so the design phase has a concrete checklist to address.++---
specs/render-html-comments/design.md Added +458 / -0
diff --git a/specs/render-html-comments/design.md b/specs/render-html-comments/design.mdnew file mode 100644index 0000000..dccebdc--- /dev/null+++ b/specs/render-html-comments/design.md@@ -0,0 +1,458 @@+# Design: Render HTML Comments++## Overview++Replace today's raw-HTML fall-through for `<!-- ... -->` with explicit handling: block-level comment-only HTML becomes a new `MarkdownBlock` variant, inline comments are transformed inside Textual's inline pipeline, and a single Settings toggle (`AppSettings.showHTMLComments`) governs visibility at render time. Parsing produces the same blocks regardless of toggle state; only the rendering / filtering layer reads the toggle, so flipping it does not reparse the AST (Req 9.2).++## Architecture++```+raw markdown source+   │+   ▼+MarkdownBlockParser.parse (existing pipeline)+   │  1. extractFrontmatter+   │  2. stripCommentTags        ← already removes <!-- comment:HASH --> note+   │     (existing, line 50)        infrastructure tags BEFORE swift-markdown+   │                                  sees them. New work depends on this+   │                                  invariant — see "Note-tag invariant".+   │  3. upgradeNestedCodeFences+   │  4. protectDetailsBlankLines+   │  5. FootnotePreprocessor+   │  6. swift-markdown Document parse+   │+   ▼+swift-markdown AST+   │+   ▼+convert() — per-node converter+   │ HTMLBlock branch (ordered):+   │  a. restoreDetailsBlankLines+   │  b. DetailsBlockParser+   │  c. HTMLImageParser+   │  d. HTMLCommentParser.parseBlock          ◄── NEW+   │  e. fall back to .html(content:)         ◄── now also runs+   │                                              HTMLCommentStripping.strip+   │                                              on its content (see below)+   │+   ▼+[MarkdownBlock]  ─ may contain .htmlComment(rawText) ─┐+                                                       │+[FootnoteData] — separate sidecar — also adopts        │+HTMLCommentStripping for definition content            │+                                                       │+   ▼                                                   │+Rendering ◄────────── reads AppSettings.showHTMLComments+   │  - .htmlComment block → HTMLCommentBlockView+   │      OFF: EmptyView()      ON: styled annotation+   │  - .paragraph (and any block routed through+   │      HighlightedInlineText) → Textual extension+   │      htmlComments(visible:appearance:) decides+   │      OFF: replace <!--…--> with empty+   │      ON : replace with styled run + tag with+   │             HTMLCommentRangeAttribute+   │+   ▼+SwiftUI view+```++### Integration points++| Concern | Where the change plugs in | Notes |+|---|---|---|+| New block variant | `MarkdownBlock.htmlComment(rawText: String)` in `prism/Models/MarkdownBlock.swift` | All existing switches over the enum must add a case (see audit) |+| Block-level parsing | New `HTMLCommentParser.parseBlock(_:)`, called from the HTMLBlock branch in `MarkdownBlockParser.convert()` after `HTMLImageParser` and before the `.html` fall-back | Returns `nil` for non-comment-only blocks, mixed blocks, unterminated `<!--`, conditional comments (`<!--[if …]>`), CDATA, DOCTYPE |+| Raw HTML fallback content | `MarkdownBlockParser.convert()` HTMLBlock fall-back now wraps the returned content in `HTMLCommentStripping.strip(_:)` so that the existing raw-HTML path no longer shows `<!--` markers in any state — the toggle exclusively governs the styled `.htmlComment` path | Resolves the "mixed-content raw HTML still shows markers when OFF" surprise raised in review |+| Stripping helper | New `HTMLCommentStripping.strip(_:)` (parallels `FootnoteStripping`) | Called at heading-derived sites, image alt/title, raw-HTML fall-back, and a new paragraph-source post-processor that strips comments from inside markdown link labels (see Req 4.5 below) |+| Inline rendering | New `SyntaxExtension.htmlComments(visible:appearance:)` added to the project's `arjenschwarz/textual` fork; consumed in `HighlightedInlineText.body` | Mirrors `footnoteReferences` extension |+| Settings | New `AppSettings.showHTMLComments: Bool` (`@AppStorage("showHTMLComments")`, default `false`); new `markdownRenderingSection` between Notes and Raw Source in `SettingsView` | App-scoped; no iCloud KVS sync (consistent with `rawSourceWrapLines`, `showInlineNotes`) |+| Block-level rendering | New case in `TextualBlockView.body` switch (the single block-routing site — confirmed by reading `Views/TextualBlockView.swift:154`) | `SharedBlockViews` reuses `TextualBlockView`; no second routing site to update |+| Search | New `SearchContext` value; `searchableText(in:)` becomes the canonical method, with `searchableText` and `searchableText(with:)` retained as compatibility shims delegating to the new method | See "Search pipeline" below |+| Export | `InlineNotesExporter` and clipboard / share paths that walk blocks; `ExportSourceMapper` also touched (see audit) | See "Export" below |++### Note-tag invariant (collision safety)++`MarkdownBlockParser.stripCommentTags(_:)` (currently at line 91, called at line 50) removes `<!-- comment:HASH -->` and `<!-- /comment:HASH -->` markers from the raw source BEFORE swift-markdown parses, so the note infrastructure tags never reach `HTMLBlock`. Likewise `CodeFenceHelper.detailsBlankLinePlaceholder` (`<!-- prism-details-blank -->`) is restored to a blank line by `restoreDetailsBlankLines` before the HTMLBlock branch runs.++**The new design depends on this layering staying intact.** A regression test must assert that the note-tag stripper runs before any new HTML-comment handling, so a future refactor that moves `HTMLCommentParser` earlier in the pipeline cannot accidentally surface note infrastructure as visible "Comment" annotations. Test name: `MarkdownBlockParserTests.testCommentInfrastructureTagsNeverReachHTMLCommentParser`.++In addition, `HTMLCommentParser.parseBlock` defensively rejects any input whose inner text matches `^\s*/?comment:[a-f0-9]+\s*$` (the exact `comment:HASH` or `/comment:HASH` shape) as a second line of defence — returning `nil` so the block routes to the `.html` fallback (which then strips it via `HTMLCommentStripping`).++### Pattern extension audit++**Adding `MarkdownBlock.htmlComment(rawText:)`** — every switch over the enum gets a new case. Confirmed sites:++| File | Site | Behaviour for `.htmlComment` |+|---|---|---|+| `Models/MarkdownBlock.swift` `contentForHashing` | `"htmlComment:<text>"` |+| `Models/MarkdownBlock.swift` `supportsNotes` | `false` (Req 6.5) |+| `Models/MarkdownBlock.swift` `debugTypeName` | `"Comment"` |+| `Models/MarkdownBlock.swift` `textContent` | return `rawText` |+| `Models/MarkdownBlock.swift` `searchableText(in:)` | `context.showHTMLComments ? rawText : ""` |+| `Models/DocumentSession.swift` | iteration sites that build heading lists ignore `.htmlComment` |+| `Services/TOCCoordinator.swift` | unchanged (only iterates headings) |+| `Services/MarkdownSectionBuilder.swift` | treat as non-section (group with surrounding paragraphs) |+| `Models/MarkdownSection.swift` | include in member arrays |+| `Models/NoteGrouping.swift` | exclude (parallel to `.thematicBreak`) |+| `Models/SearchMatch.swift` | exclude when `showHTMLComments` is `false` |+| `Views/SharedCollapsibleSections.swift` | route to `HTMLCommentBlockView` |+| `Views/SidebarContentsView.swift` | not affected (headings only) |+| `Views/FootnotePopoverView.swift` | popovers do not render comments — exclude (parallel to other non-text blocks) |+| `Views/TextualBlockView.swift` (line 154) | new case → `HTMLCommentBlockView(rawText:)` |+| `Views/DetailsBlockView.childBlockView` | route to `HTMLCommentBlockView` (comments inside `<details>` should render the same way) |+| `Services/RelocationEngine.swift` | non-note-bearing (skip in anchor resolution) |+| `Services/NotesManager.swift` | exclude from note-eligible iteration |+| `Services/FlattenedSearchBlock` | include when `showHTMLComments` is `true`, exclude when `false` |+| `Views/SearchOverlaySheet` / `SearchExcerptExtractor` | excerpt path uses the same `SearchContext`-aware `searchableText(in:)` |+| `Services/ExportSourceMapper.swift` | the existing comment-tag regex stays unchanged; new `.htmlComment` blocks produce no source-map entries (they have no `comment:HASH` tag) |++**Adding `HTMLCommentStripping.strip(_:)`** call sites (composed with `FootnoteStripping.strip` where both apply; HTML comment stripping runs first because comments may contain `[^x]` tokens):++| Site | Reason |+|---|---|+| `Models/DocumentSession.swift:336` | heading text for ToC/anchor |+| `Models/DocumentSession.swift:527` | heading text for window title |+| `Services/TOCCoordinator.swift:106` | ToC entry text |+| `MarkdownBlockParser.convertParagraph` image-only path | image `alt` and `title` (Req 4.5) |+| `Services/HTMLImageParser` output assembly | image `alt`, `title` when HTML img path used (Req 4.5) |+| `MarkdownBlockParser.convertParagraph` paragraph path — NEW post-processor `stripCommentsInsideLinkLabels(_:)` | strips `<!--…-->` from inside `[…](…)` label brackets only, before storing `.paragraph(markdown:)`. Outside-link comments remain for the Textual extension to handle (Req 4.5) |+| `MarkdownBlockParser.convert` HTMLBlock fall-back to `.html(content:)` | so that mixed-content raw HTML no longer leaks `<!--` markers regardless of toggle (mixed-content surprise mitigation) |++### Link-label stripping detail++The new `stripCommentsInsideLinkLabels(_:)` is a single targeted regex pass over the paragraph markdown that finds `[...](url)` and `[...](url "title")` shapes and removes `<!--[\s\S]*?-->` from the bracketed label portion only. Reference / shortcut link forms (`[label][ref]`, `[ref]`) are also covered by the same outer-bracket detection. Comments inside the `(url)` portion are left untouched because they would have already been treated as part of the URL by swift-markdown — they cannot legitimately appear there in valid markdown.++This approach is chosen over making the Textual extension link-aware because (a) Textual's pattern processor does not currently expose link-range context to extensions, and (b) link labels are a stable parse-time structure that's easier to manipulate in markdown source than in an attributed string.++### List-item separation behaviour (Req 4.4)++swift-markdown's behaviour, verified empirically in the existing codebase and confirmed in CommonMark: an HTML block between two adjacent list items at the same level interrupts the list. swift-markdown emits two `UnorderedList`/`OrderedList` siblings with an `HTMLBlock` between them. The new design produces:++```+.list(items: [item1])+.htmlComment(rawText: "between")     // when ON, renders the styled annotation+.list(items: [item2])+```++Req 4.4 says the items "SHALL remain part of the same list". To honour this literally we would need to either (i) lift HTMLBlock siblings into the surrounding list at AST-walk time, or (ii) accept the two-list rendering. Option (i) is invasive — it requires synthesising parent-list membership for nodes that swift-markdown did not place inside a list — and breaks the parser invariant that each MarkdownBlock corresponds to a single AST node.++**Decision**: amend the implementation interpretation of Req 4.4 to "the items SHALL remain visually contiguous and SHALL continue numbering / bullet style across the comment". Visual contiguity is achieved by zero extra vertical spacing around the rendered comment block (per Req 2.9 which already says comment-block spacing matches paragraph spacing). Continued numbering is delivered by an `ordinalOffset` parameter passed to the second list when its preceding sibling is `.htmlComment` AND the first list was ordered: the second list's items pick up at `firstList.itemCount + 1`. For unordered lists, no numbering is needed and visual contiguity is enough.++If the user disagrees with this interpretation, the requirement should be relaxed in `requirements.md` rather than carrying option (i) into implementation.++## Components and Interfaces++### MarkdownBlock additions++```swift+extension MarkdownBlock {+    case htmlComment(rawText: String)+}+```++- `rawText` is the inner content with `<!--` / `-->` removed and outer whitespace trimmed. Internal line breaks are preserved (Req 2.5).+- The parser never emits `.htmlComment(rawText: "")` — empty comment-only blocks return `nil` from `parseBlock`, so the caller proceeds to the existing `.html` fall-back, which strips them via `HTMLCommentStripping`. This removes the previous design's duplicated trim-check on both parse and render sides.++### HTMLCommentParser++```swift+enum HTMLCommentParser {+    /// Returns the joined inner text of a comment-only HTML block, or nil+    /// for any input that should not be classified as a comment-only block.+    static func parseBlock(_ rawHTML: String) -> String?+}+```++Contract:+- Returns `nil` (caller falls back to the existing `.html` path) when:+  - the input contains any non-comment, non-whitespace tokens, OR+  - an unterminated `<!--` is found (Req 2.7), OR+  - the input mixes comments with other HTML content (Req 2.8), OR+  - any comment body starts with `[if` or `[endif` (case-insensitive, optional whitespace) — conditional comments are out of scope per Non-Goals, OR+  - any comment body matches `^\s*/?comment:[a-f0-9]+\s*$` (note infrastructure double-defence), OR+  - the result, after trimming, would be empty (so callers proceed to fall-back which strips the markers).+- Reject `<![CDATA[` and `<!DOCTYPE` by simple prefix check; neither uses the `<!--` opening so the structural regex naturally rejects them, but the parser asserts this in a test for defence-in-depth.+- Returns the inner text of all comments joined with the whitespace that separates them in the source (so a multi-line block preserves line breaks; multiple consecutive comments separated by blank lines preserve those blank lines). Outer whitespace is trimmed.++Implementation strategy: structural validation via `^(?:\s*<!--(?!\[if\b|\[endif\b)[\s\S]*?-->\s*)+$` plus the note-tag rejection; content extraction via `<!--([\s\S]*?)-->`.++### HTMLCommentStripping++```swift+enum HTMLCommentStripping {+    static func strip(_ text: String) -> String+}+```++Removes `<!--[\s\S]*?-->` matches and collapses adjacent whitespace to a single space, then trims edges. Same shape as `FootnoteStripping`.++### AppSettings++```swift+@ObservationIgnored+@AppStorage("showHTMLComments") private var showHTMLCommentsRaw: Bool = false++var showHTMLComments: Bool {+    get { access(keyPath: \.showHTMLComments); return showHTMLCommentsRaw }+    set { withMutation(keyPath: \.showHTMLComments) { showHTMLCommentsRaw = newValue } }+}+```++Toggling invalidates `AppSettings` for any view observing it via `@Environment(AppSettings.self)`. Views already in the environment graph that read `settings.showHTMLComments`:+- `HighlightedInlineText` — already injects `@Environment(AppSettings.self)` (verified at `prism/Views/HighlightedInlineText.swift:54`), so the new dependency is additive, not new.+- `HTMLCommentBlockView` (new) — reads it directly from environment.++### Textual extension: SyntaxExtension.htmlComments(visible:appearance:)++Lives in `arjenschwarz/textual`. The fork is under the project owner's control; updating the pinned commit is part of the implementation work for this feature, and a single PR in the fork delivers all the changes below.++The existing `footnoteReferences(provider:)` extension is the precedent. Reading the fork (`Sources/Textual/Markdown/SyntaxExtensions/FootnoteReferences.swift` per the project's CLAUDE.md "Textual Fork Details" section), the public surface looks like:++```swift+public extension AttributedStringMarkdownParser.SyntaxExtension {+    static func footnoteReferences(provider: FootnoteDataProvider) -> Self+}+```++The new extension mirrors this:++```swift+public extension AttributedStringMarkdownParser.SyntaxExtension {+    static func htmlComments(visible: Bool, appearance: HTMLCommentAppearance) -> Self+}++public struct HTMLCommentAppearance: Sendable {+    public var foregroundColor: Color+    public var prefixSymbolName: String              // SF Symbol name; consumer passes "info.circle"+    public var italic: Bool+    public var accessibilityCommentLabel: String       // localised "Comment"+    public var accessibilityEndCommentLabel: String    // localised "End comment"+    public init(foregroundColor: Color, prefixSymbolName: String, italic: Bool,+                accessibilityCommentLabel: String, accessibilityEndCommentLabel: String)+}+```++Behaviour:+- Operates on the post-parse AttributedString. Scans for `<!--[\s\S]*?-->` ranges.+- Skips ranges that fall entirely inside a code-span run. Code spans are identified by the presence of Textual's `InlineStyle.code` attribute on the range; this is the same mechanism the footnote extension already uses, exposed as a small helper in the fork (`PatternProcessor.isInsideCodeSpan(range:in:)`). The helper is part of this fork PR if it does not already exist.+- Fenced and indented code blocks never reach this extension — they are rendered through `.codeBlock` and HighlightSwift, not through `HighlightedInlineText`. The extension does not need to defend against them.+- When `visible == false`: replaces each match with an empty attributed substring.+- When `visible == true` AND the inner text is non-empty after trimming:+  - Replaces the match with a styled run: an SF Symbol attachment for `prefixSymbolName`, then a single space, then the inner text.+  - Sets italic + foreground = `appearance.foregroundColor` on the text portion.+  - Tags the entire replacement range with a new `HTMLCommentRangeAttribute` carrying the inner text string. The attribute is what the accessibility composer reads at the view layer.+- When `visible == true` AND the inner text is empty: replaces with empty (Req 3.6).++The symbol attachment is decorative for accessibility (the `accessibilityCommentLabel` carries the announcement). Textual already supports image attachments in attributed strings (footnote badges use them); the extension reuses the same attachment construction.++### HighlightedInlineText changes++```swift+@Environment(AppSettings.self) private var settings   // already present (line 54)+```++`syntaxExtensions` becomes:++```swift+private var inlineSyntaxExtensions: [AttributedStringMarkdownParser.SyntaxExtension] {+    var exts: [AttributedStringMarkdownParser.SyntaxExtension] = []+    if !footnoteData.isEmpty {+        exts.append(.footnoteReferences(provider: footnoteData))+    }+    exts.append(.htmlComments(visible: settings.showHTMLComments, appearance: htmlCommentAppearance))+    return exts+}+```++The search-highlight path (lines 102+ in `updateHighlights`) must run the comment transformation BEFORE search highlighting so that hidden comments do not contribute spurious search ranges and visible comments are highlightable:++```+parse markdown with extensions (footnote + htmlComments)+        │+        ▼+AttributedString (with comments already transformed)+        │+        ▼+SearchHighlightApplicator.applyHighlights(...)+        │+        ▼+final AttributedString+```++`TaskIdentifier` is extended to include `showHTMLComments` so that toggling the setting while a search is active recomputes the highlighted string and the match count.++`HTMLCommentAccessibilityModifier` (new) sits next to the existing `FootnoteAccessibilityModifier` in `body`. It receives the rendered AttributedString and:+- Iterates `HTMLCommentRangeAttribute` runs.+- If any are present, composes a single `.accessibilityLabel` for the inline text view by walking the AttributedString and emitting plain text for non-comment runs, the `accessibilityCommentLabel` once at the start of the first comment, the inner texts in order, and the `accessibilityEndCommentLabel` once after the last comment.+- Sets `.accessibilityElement(children: .ignore)` so the composed label is what assistive tech reads.+- If no `HTMLCommentRangeAttribute` runs exist, the modifier is a no-op.++This pattern mirrors `FootnoteAccessibilityModifier`. Both block-level and inline accessibility composition use the same primitive (an `accessibilityLabel(_:)` over the whole text view), which is the supported AppKit/UIKit-bridge pattern under SwiftUI.++### HTMLCommentBlockView++```swift+struct HTMLCommentBlockView: View {+    let rawText: String+    @Environment(AppSettings.self) private var settings+    @Environment(\.themeColors) private var colors+}+```++- When `settings.showHTMLComments == false`: `EmptyView()`. No trim check — the parser invariant guarantees `rawText` is non-empty when the block is emitted.+- When `true`: `HStack(alignment: .firstTextBaseline, spacing: 4)` of `Image(systemName: "info.circle")` (decorative; `imageScale(.medium)` so it tracks Dynamic Type) and `Text(verbatim: rawText)` with `.italic()` and `.foregroundStyle(colors.commentDimmedForeground)`.+- `Text(verbatim:)` is required — `Text(LocalizedStringKey(rawText))` would treat the author content as a catalog key (project localisation rules in CLAUDE.md ban this).+- Accessibility: `.accessibilityElement(children: .combine)` plus `.accessibilityLabel(LocalizedStringKey("Comment, \(rawText)"))` so VoiceOver reads `"Comment, <text>"`. Symbol stays decorative.+- Vertical spacing inherits from the same block-spacing modifier applied to paragraph blocks (no custom padding).++### Visual parity between block-level and inline rendering++Block-level rendering uses an `HStack` of `Image + Text`; inline rendering uses an attributed-string image attachment + styled run. The two paths can produce subtly different baseline and spacing. To keep them visually consistent:+- Inline runs use a single space between symbol attachment and text (matching the `HStack` `spacing: 4` visually under default body font).+- The block-level `HStack` uses `firstTextBaseline` alignment so the symbol sits on the text baseline, the same as the attachment in the inline path.+- Both paths read the same `colors.commentDimmedForeground` so the colour cannot drift.++If a snapshot test reveals a baseline drift after implementation, the block view should be switched to use the same `AttributedString`-with-attachment construction as the inline path (one `Text(attributedString:)` call instead of an `HStack`) to eliminate the parity question.++## Search pipeline++```swift+struct SearchContext: Sendable {+    var showHTMLComments: Bool+    var footnoteData: FootnoteData+}+```++`FootnoteData` is `Sendable` already (it conforms to `Sendable` per `Models/FootnoteData.swift` — the design assumes this; verify in the first implementation task and resolve if not).++The canonical search-text method:++```swift+extension MarkdownBlock {+    func searchableText(in context: SearchContext) -> String { … }+}+```++Behaviour per case:+- `.htmlComment(text)` — `context.showHTMLComments ? text : ""`.+- `.paragraph(md)` and any other inline-text-bearing case — pre-strip `<!--[\s\S]*?-->` from `md` via `HTMLCommentStripping.strip`. When `context.showHTMLComments == true`, also extract each comment's inner text and append. This keeps the searchable string symmetric with what the user sees on screen.+- `.heading(_, text)` — same treatment as paragraphs: strip from base, append inner text when ON. (The earlier draft proposed an asymmetric "always strip from search even when rendered"; that violated Req 5.1 for heading-embedded comments. Corrected.)+- All other cases — unchanged from today.++Compatibility shims (so existing call sites keep compiling):++```swift+extension MarkdownBlock {+    var searchableText: String {+        searchableText(in: SearchContext(showHTMLComments: false, footnoteData: .empty))+    }+    func searchableText(with footnoteData: FootnoteData) -> String {+        searchableText(in: SearchContext(showHTMLComments: false, footnoteData: footnoteData))+    }+}+```++Active search call sites that should switch to the new context-aware method (rather than relying on the shim's `showHTMLComments: false`): `SearchService`, `FlattenedSearchBlock`, `SearchExcerptExtractor`, `SearchOverlaySheet`'s excerpt path. The compatibility shims keep tests and incidental callers compiling.++Match-cursor reset on toggle change (Req 5.4) wires into `SearchService` (the controller that owns the current-match index). It already observes search-query changes; add a parallel observer:++```swift+.onChange(of: settings.showHTMLComments) { _, _ in+    searchService.recomputeAfterVisibilityChange()+}+```++`recomputeAfterVisibilityChange()` rebuilds the match list using the current query and the new `SearchContext`, sets total to the new count, and resets the cursor to 0 (1-of-N or 0-of-0 as Req 5.4 specifies). `SearchOverlaySheet`'s display already supports "0 of 0" because that's the empty-result state.++## Export++`InlineNotesExporter` and the clipboard / share paths walk blocks. New behaviour:+- `.htmlComment(rawText)` blocks contribute nothing when `showHTMLComments == false`. When `true`:+  - Plain-text path: append the localised string `"Comment: \(rawText)"` followed by a newline.+  - Rich path: render via the same `HTMLCommentBlockView` styling embedded in the rich output.+- For paragraph blocks with inline comments, plain-text export uses a helper `HTMLCommentExport.flatten(_ md: String, visible: Bool) -> String`:+  - `visible == false`: removes `<!--…-->` ranges (calls `HTMLCommentStripping.strip`).+  - `visible == true`: replaces each `<!-- x -->` with `Comment: x ` (localised prefix, one trailing space) inline within the surrounding text.+- `ExportSourceMapper` is unaffected: its regex specifically targets `<!--\s*/?comment:[a-f0-9]+\s*-->` and does not match author comments.++## Data Models++Only one addition (covered in Components): `MarkdownBlock.htmlComment(rawText:)`. No new persistent storage, no schema migration. `AppSettings.showHTMLComments` is the only new UserDefaults key.++## Error Handling++No new failure modes that require user-visible error reporting:+- Malformed HTML (unterminated `<!--`) routes through the existing `.html` fall-back path with `HTMLCommentStripping` applied — the surrounding HTML still renders raw, the dangling `<!--` is stripped if a matching `-->` exists later in the string or remains in the output if not.+- Conditional comments / CDATA / DOCTYPE route through the existing `.html` fall-back (also stripped of any nested standard comments via `HTMLCommentStripping`).+- The Textual extension is total — it always returns a transformed AttributedString.++## Testing Strategy++### Unit tests (`prismTests`, Swift Testing)++`HTMLCommentParserTests`:+- comment-only single-line block returns inner text.+- comment-only multi-line block preserves internal line breaks.+- multiple consecutive `<!-- a --><!-- b -->` joined with source whitespace.+- empty `<!---->` and whitespace-only `<!--   -->` return `nil` (caller falls back to `.html`).+- unterminated `<!--` returns `nil`.+- mixed `<!-- x --> <p>real html</p>` returns `nil`.+- conditional `<!--[if IE]>x<![endif]-->` returns `nil`.+- `<![CDATA[…]]>` and `<!DOCTYPE …>` return `nil`.+- input matching `<!-- comment:abc123 -->` (note infrastructure double-defence) returns `nil`.++`HTMLCommentStrippingTests`:+- removes single and multiple comments; collapses adjacent whitespace to a single space.+- input without comments returned unchanged.+- inside-link stripping helper (`stripCommentsInsideLinkLabels`) removes from `[label<!--x-->more](url)` and `[label<!--x-->more][ref]` but leaves comments OUTSIDE link brackets in place.++`MarkdownBlockSearchableTextTests`:+- `.htmlComment` with `showHTMLComments == true` returns text; with `false` returns empty.+- paragraph with inline comments has comment text appended when ON; markers stripped from base in both states.+- heading with embedded comment has comment text appended when ON; ToC / anchor extraction strips it.++`MarkdownBlockParserTests`:+- stand-alone `<!-- note -->` paragraph → one `.htmlComment(rawText: "note")` block.+- `<!--note--><img src="x">` → `.html` block (fall-back), with the `<!--note-->` stripped from the stored content.+- inline `<!-- inside -->` inside a paragraph keeps the comment in the paragraph markdown.+- inline comment inside a markdown link label is stripped at parse time (`[foo<!--x-->bar](url)` produces a paragraph whose stored markdown is `[foobar](url)`).+- list with a stand-alone comment between two items: the comment becomes a `.htmlComment` block between two list blocks; if the lists were ordered, the second list's ordinal offset starts where the first ended.+- **`testCommentInfrastructureTagsNeverReachHTMLCommentParser`** — feeds the parser a document containing `<!-- comment:abcdef123 -->` and asserts that no `.htmlComment` block is emitted (the tag is stripped by `stripCommentTags` before swift-markdown sees it).+- **Round-trip test**: `Paragraph.format()` preserves `InlineHTML` verbatim for `*foo<!--x-->bar*`, `[label<!--x-->more](url)`, `> quote <!--x--> more`, and `**bold<!--y-->again**`. Asserts the literal `<!--…-->` text appears in the formatted output. Pins the load-bearing invariant.++`HTMLCommentBlockViewTests`:+- OFF → block renders to zero-height (`EmptyView`).+- ON with text → block renders symbol + italic dimmed text; accessibility label is exactly `"Comment, <text>"`.+- ON with content the localisation catalog would otherwise interpret (e.g., `"%d items"`) renders verbatim — verifies the `Text(verbatim:)` choice.++`HighlightedInlineTextTests` (extended):+- a paragraph containing `foo<!--x-->bar` renders, with toggle ON, as `foo` + symbol + `x` + `bar`; with OFF, as `foobar` (Req 3.6 + 3.4).+- backtick-wrapped `` `<!--x-->` `` survives the htmlComments extension literally regardless of toggle.+- a paragraph containing multiple inline comments composes one paragraph-scoped `Comment … End comment` accessibility label.+- task identifier change: toggling the setting while a search query is active produces a new highlighted AttributedString and an updated match count.++`SettingsViewTests`: toggle reads/writes the setting; default is `false`.++`HTMLCommentExportTests`:+- plain-text export with toggle ON produces `"Comment: x"` (localised prefix) for both block-level and inline comments.+- plain-text export with toggle OFF produces no marker and no inner text.+- rich export with toggle ON renders the SF Symbol path (snapshot or attributed-string structural assertion).++### Property-based candidates++`HTMLCommentParser.parseBlock` is a small total function over a structured grammar. Worth a property test using Swift Testing parameterised tests:++- Property 1 — "comment-only is recovered": for any `n ∈ [0, 5]` comment bodies drawn from a fixed inner-text alphabet (excluding `[if`, `[endif`, `comment:` prefixes, and `-->` substrings) and any choice of separator whitespace, `parseBlock(rebuildSource(...))` returns the expected concatenated inner text or `nil` for the empty-result case.+- Property 2 — "mixed always nil": for any comment body interspersed with at least one non-whitespace non-comment character, `parseBlock(...) == nil`.++Generators can be hand-rolled with `Swift.RandomNumberGenerator`; no external PBT framework needed.++### UI tests (`prismUITests`)++- Toggle the setting from Settings while a document with `<!-- ... -->` is open; the document re-renders without dismissing the window or losing scroll position.+- Document search finds and highlights comment text only when the toggle is ON; toggling during an active search updates the match count and resets the cursor.++### Performance++- The existing parsing performance test in `prismTests` runs over a 500 KB document. Add an assertion variant that runs the same test with the new `.htmlComment` branch in place; budget per Req 9.1.+- Add a new lightweight benchmark `HighlightedInlineTextRenderBench` that asserts re-rendering a paragraph with N inline comments (N ∈ {0, 10, 100}) does NOT scale super-linearly with N when the toggle is toggled — i.e., that the per-render regex cost is bounded. Required because the toggle change re-runs the inline pipeline for every visible paragraph; this benchmark catches a regression where the regex would become O(N²) or where the accessibility composer would walk runs quadratically.
specs/render-html-comments/implementation.md Added +128 / -0
diff --git a/specs/render-html-comments/implementation.md b/specs/render-html-comments/implementation.mdnew file mode 100644index 0000000..e24b03c--- /dev/null+++ b/specs/render-html-comments/implementation.md@@ -0,0 +1,99 @@+# Implementation: Render HTML Comments++Three-level walk-through of the T-450 change set, used as both onboarding and pre-push self-review.++## Beginner Level++### What Changed / What This Does++Markdown files can have hidden notes written like `<!-- this is a note -->`. Until now, Prism showed these notes as plain text with the brackets visible — readers saw `<!--` and `-->` cluttering the prose.++This change adds a Settings switch called **"Show HTML comments"** under a new **"Markdown Rendering"** section. The switch is off by default, so most readers won't see those notes at all. If a reader turns it on, the notes appear as dimmed italic text with a small info icon (ⓘ) in front, both as standalone blocks between paragraphs and inline inside paragraphs.++The switch also affects search, copy, and export: turn it on, notes are findable and appear in copied/exported text; turn it off, they're hidden everywhere. Flipping the switch updates the open document instantly — no re-opening required.++### Why It Matters++HTML comments are how authors leave private side-notes in markdown ("TODO: revisit", "this paragraph needs a citation"). Prism's old behaviour leaked those private notes into the reading experience. Hiding them by default respects author intent; making them opt-in lets curious readers see what the author was thinking without forcing it on everyone.++### Key Concepts++- **HTML comment** — a `<!-- ... -->` snippet in a markdown file. Treated as invisible by HTML browsers; markdown viewers sometimes leak it.+- **Block-level vs inline** — a *block* comment lives on its own line between paragraphs; an *inline* comment sits inside a sentence (`foo<!--note-->bar`).+- **Toggle / preference** — a user setting that flips behaviour on/off. Saved persistently per app install.+- **Re-render** — the act of refreshing how a parsed document is shown on screen, without re-reading the file from disk.++---++## Intermediate Level++### Changes Overview++44 files touched, ~+4500/-100 lines. The change splits into four layers:++1. **Parse-time helpers** (`prism/Services/`)+   - `HTMLCommentParser.parseBlock(_:)` — accepts a raw `HTMLBlock`, returns its joined inner text iff the block is comment-only; rejects mixed content, conditional comments, CDATA, DOCTYPE, and note-infrastructure `comment:HASH` markers.+   - `HTMLCommentStripping.strip(_:)` — removes `<!--…-->` and collapses whitespace; used by every site that derives a navigation surface (heading text, image alt/title, raw-HTML fall-back).+   - `MarkdownBlockParser.stripCommentsInsideLinkLabels(_:)` — strips comments from inside `[…]` portions only, leaving outside-link comments for the inline renderer.++2. **Model variant** (`prism/Models/`)+   - New `MarkdownBlock.htmlComment(rawText: String)` case. Every exhaustive switch over the enum got an explicit case; consumers that should ignore it (note grouping, footnote popovers, relocation) do so explicitly rather than via a `default:`.+   - New `SearchContext { showHTMLComments, footnoteData }` value drives `MarkdownBlock.searchableText(in:)`; the legacy `searchableText` and `searchableText(with:)` remain as thin shims delegating to the new method.+   - New `MarkdownBlock.ordinalOffsetForList(at:in:)` resumes ordered-list numbering across an intervening `.htmlComment` block (Decision 17).++3. **Textual fork extension** (pinned at `arjenschwarz/textual@5c9f477`)+   - New `SyntaxExtension.htmlComments(visible:appearance:)`, `HTMLCommentAppearance`, `HTMLCommentRangeAttribute`, and a shared `PatternProcessor.isInsideCodeSpan(range:in:)` helper. The extension operates on the post-parse `AttributedString`: it skips code-span runs, replaces matched ranges with an SF Symbol attachment + styled run when `visible == true`, replaces with empty text when `false`, and tags the resulting range with `HTMLCommentRangeAttribute` so the consumer can read the inner text back.++4. **Rendering, search, export, accessibility** (`prism/Views/`, `prism/Services/`)+   - New `HTMLCommentBlockView` for block-level comments.+   - `HighlightedInlineText` adds the new Textual extension to its pipeline unconditionally, reorders `updateHighlights` so the extension transforms the string *before* search highlighting, extends `TaskIdentifier` with `showHTMLComments` so toggling refreshes mid-search, and attaches an `HTMLCommentAccessibilityModifier` that composes a paragraph-scoped `Comment … End comment` boundary label (Decision 14).+   - `SearchCoordinator.recomputeAfterVisibilityChange()` rebuilds match totals and resets the cursor when the toggle flips with a query active (Req 5.4).+   - `HTMLCommentExport.flatten(_:visible:)` rewrites comments for plain-text export: removes when off, replaces with `Comment: <inner>` localised prefix when on.++### Implementation Approach++- **Parse-time vs render-time split** — author content is parsed once into blocks; toggling the preference never re-runs the swift-markdown AST parse (Req 9.2). The toggle is read at render time by `HighlightedInlineText`, `HTMLCommentBlockView`, and `MarkdownBlock.searchableText(in:)`.+- **Mirror the footnote system** — the footnote system already pairs a parser/preprocessor (`FootnotePreprocessor`), a stripping helper (`FootnoteStripping`), a Textual extension (`SyntaxExtension.footnoteReferences`), an accessibility modifier (`FootnoteAccessibilityModifier`), and a `[^id]` regex in `MarkdownBlock`. The HTML-comments system mirrors that shape one-for-one.+- **Centralised regex** — one `HTMLCommentStripping.inlineCommentRegex` powers `HTMLCommentStripping`, `HTMLCommentExport`, `MarkdownBlock.searchableText`, and `MarkdownBlockParser.stripCommentsInsideLinkLabels`. The parser's own four regexes (structural, extract, note-infrastructure, conditional) are hoisted to `static let` so they compile once.+- **Compatibility shims for search** — `searchableText` and `searchableText(with:)` keep compiling for existing callers; the new `searchableText(in: SearchContext)` is the canonical method.++### Trade-offs++- **Mixed-content HTML blocks pass through unchanged** (Decision 10). Splitting them would require synthetic block boundaries with no clean source-order anchor. The raw-HTML fall-back path now also runs through `HTMLCommentStripping` so the toggle-off promise holds for mixed content (Decision 16).+- **List-item continuity via ordinal offset** (Decision 17). swift-markdown emits two list nodes when an `HTMLBlock` separates items; lifting siblings into the parent list at AST-walk time would break the one-AST-node-per-block invariant. The chosen rendering uses an `ordinalOffset` so the second ordered list resumes numbering instead of restarting at 1.+- **Link-label stripping at parse time** rather than in the Textual extension (Decision 18). Textual's pattern processor doesn't expose link-range context; a targeted regex pass on the markdown source is far smaller than extending Textual's API.+- **Visibility default flips behaviour** for the small TestFlight cohort that already saw raw `<!--` markers. Accepted because the previous behaviour was a defect of the fall-through path, not a deliberate UX choice (Decision 3).++---++## Expert Level++### Technical Deep Dive++- **Note-tag invariant** (Decision 20). `MarkdownBlockParser.stripCommentTags` removes the `<!-- comment:HASH -->` note-infrastructure markers BEFORE swift-markdown parses the source. `HTMLCommentParser.parseBlock` also rejects bodies matching `^\s*/?comment:[a-f0-9]+\s*$` as defence-in-depth, and a regression test `testCommentInfrastructureTagsNeverReachHTMLCommentParser` pins the ordering. A future refactor that re-orders the pipeline can't accidentally surface internal note hashes as visible "Comment, abc123" annotations.+- **Heading symmetry** (Decision 19). Headings treat HTML comments the same as paragraphs for search: strip markers from base, append inner text when ON. ToC entries, anchor IDs, and window titles continue to use `FootnoteStripping(HTMLCommentStripping(text))` — stable navigation surfaces independent of the visibility toggle.+- **Inline accessibility composer**. `HTMLCommentAccessibilityModifier` parses the markdown through the same pipeline (footnote + htmlComments extensions) and walks the resulting `AttributedString` for `HTMLCommentRangeAttribute` runs. It emits a single `Comment` marker at the start of the first comment span and a single `End comment` after the last (Decision 14), so VoiceOver narration stays coherent with multiple inline comments. The modifier shares the parent's `htmlCommentAppearance` so the inline accessibility appearance cannot drift from the rendering appearance.+- **Search-task identifier**. `HighlightedInlineText.TaskIdentifier` includes `showHTMLComments`. When the toggle flips while a search query is active, the new `TaskIdentifier` differs from the previous one and SwiftUI re-runs the `.task(id:)` that recomputes `highlightedContent` + `matchCount`. The `SearchCoordinator.recomputeAfterVisibilityChange()` path takes care of total counts at the document level (Req 5.4).+- **List-continuity offset implementation**. `MarkdownBlock.ordinalOffsetForList(at:in:)` is an O(1) probe that inspects `blocks[blockIndex - 2]` and `blocks[blockIndex - 1]`. `ListBlockView` and `SharedBlockViews` thread the offset to ordered-list rendering. Unordered lists ignore the offset (no numbering needed). Nested list/comment/list sequences are unchanged because the offset only triggers when both lists are ordered AND the preceding sibling is a `.htmlComment`.++### Architecture Impact++- **No new global state**. The toggle is a single `@AppStorage("showHTMLComments")` Bool on `AppSettings`. Existing views that already injected `@Environment(AppSettings.self)` (notably `HighlightedInlineText`) reuse the same injection point; new views (`HTMLCommentBlockView`, the new `markdownRenderingSection` of `SettingsView`) read from the same environment.+- **Search pipeline migration is additive**. `searchableText` and `searchableText(with:)` remain as compatibility shims so incidental callers keep compiling. Active call sites (`SearchService`, `SearchCoordinator`, `SearchOverlaySheet`, `FlattenedSearchBlock`, `SearchExcerptExtractor`) switched to `searchableText(in:)`. `FootnoteData` already conformed to `Sendable`; `SearchContext` is `Sendable` too so it can flow across actor boundaries unchanged.+- **Notes / export integration**. `NotesManager.exportWithInlineNotes`, `InlineNotesShareHelper.share`, the macOS file export path, AND the "Copy all notes" path (`SidebarNotesView`/`NotesPanel` → `NotesManager.exportMarkdown` → `NotesExporter.export`) now thread `showHTMLComments` through. Context quotes saved at note-creation time get filtered through `HTMLCommentExport.flatten` so Req 6.2 holds even for notes authored before the toggle existed.+- **Textual fork pin**. The single PR in `arjenschwarz/textual` bumps the pin to `5c9f477`. Future fork bumps should preserve the new `HTMLCommentRangeAttribute` and `PatternProcessor.isInsideCodeSpan(range:in:)` public symbols.++### Edge Cases / Potential Issues++- **Conditional comments / CDATA / DOCTYPE** (Decision 21) — explicitly rejected by `HTMLCommentParser.parseBlock`; fall through to the raw-HTML path with `HTMLCommentStripping` applied so any genuine `<!--…-->` markers in those constructs still don't leak.+- **Mixed-content HTML still leaks the non-comment portion** — Decision 10 chose this trade-off explicitly. Authors who want comments hidden in a mixed block can split the block in the source.+- **Comments inside fenced/indented code blocks** — never reach `HighlightedInlineText`; the code path uses HighlightSwift directly. The Textual extension does not need a code-block guard. Inline code spans (backtick) ARE skipped via `PatternProcessor.isInsideCodeSpan(range:in:)` so `` `<!--x-->` `` survives literally in both states.+- **Multi-line comments preserve internal whitespace** (Req 2.5). `HTMLCommentParser` joins comment bodies using source-separating whitespace (so blank lines between comments survive); outer whitespace is trimmed.+- **Author content used as a format key** — `HTMLCommentBlockView` uses `String(localized: "Comment, \(rawText)")` (Swift catalog-aware interpolation) for the accessibility label, never `LocalizedStringKey("Comment, \(rawText)")` (which would interpolate at construction time and miss the catalog key). The block view text itself uses `Text(verbatim: rawText)` to avoid accidental catalog lookup on content like `%d items`.+- **Performance budgets** — `HTMLCommentParser`'s four regexes are `static let` so each document open compiles them once. `HTMLCommentStripping.strip` uses precompiled regexes via `stringByReplacingMatches`. A new XCTest performance bench (`HighlightedInlineTextRenderBench`) asserts sub-quadratic per-render scaling at N ∈ {0, 10, 100} inline comments per paragraph. The 500 KB parsing benchmark gains a comment-bearing variant pinning the existing budget.++### Completeness Assessment++- **Fully implemented**: every requirement 1.1–9.3 against the design and decision log; the 22-task spec checklist; the Pattern Audit table from the design.+- **Partially implemented**: Req 6.4 (rich export uses on-screen styling). Prism has no rich-text export path today; the requirement is vacuously true. Should be tracked in the decision log as deferred until Prism gains a rich-text export route.+- **Missing**: nothing blocking. The pre-push review surfaced and fixed a copy-notes path leak (context quotes captured before the feature existed now flow through `HTMLCommentExport.flatten` so Req 6.2 holds retroactively).
specs/render-html-comments/requirements.md Added +127 / -0
diff --git a/specs/render-html-comments/requirements.md b/specs/render-html-comments/requirements.mdnew file mode 100644index 0000000..cfe565e--- /dev/null+++ b/specs/render-html-comments/requirements.md@@ -0,0 +1,127 @@+# Requirements: Render HTML Comments++## Introduction++Prism currently passes HTML comments (`<!-- ... -->`) straight through the raw-HTML path, so they render as regular body text with the `<!--` and `-->` markers still visible. This looks like noise: most readers do not want raw comment markup intermixed with the prose. This feature replaces that fall-through behaviour with an explicit toggle. When the toggle is off, HTML comments are hidden entirely (the new default). When the toggle is on, comments render as visually distinct, dimmer annotations — both as block-level callouts and inline within paragraphs — with the markers stripped. Switching the default from "shown as raw text" to "hidden" is a deliberate change from current behaviour.++## Non-Goals++- Editing or authoring HTML comments — Prism is read-only.+- Rendering other HTML elements that Prism currently passes through as raw `.html` (e.g., `<details>` siblings, arbitrary tag soup) — only HTML comments are affected.+- Attaching annotation notes to rendered HTML comments.+- Treating HTML comments as semantic admonitions (info / warning / tip styling variants).+- Modifying Raw Source view behaviour — the raw view continues to display the file verbatim.+- Persisting per-document overrides — the toggle is a global preference.+- Interpreting MDX / JSX-style comments (`{/* ... */}`) or conditional comments (`<!--[if IE]>`); these remain handled by existing logic.+- Telemetry, analytics, or onboarding callouts for the new toggle.++## Requirements++### 1. Settings Toggle++**User Story:** As a reader, I want a Settings preference that controls whether HTML comments are rendered, so that I can opt into seeing hidden author notes without affecting other users or documents.++**Acceptance Criteria:**++1. <a name="1.1"></a>The system SHALL expose a boolean preference labelled "Show HTML comments" in the Settings UI.  +2. <a name="1.2"></a>The system SHALL default the preference to OFF.  +3. <a name="1.3"></a>The system SHALL persist the preference across app launches.  +4. <a name="1.4"></a>The toggle SHALL provide an accessibility label and accessibility value (On / Off) using localised strings.  +5. <a name="1.5"></a>WHEN the preference changes WHILE a document is open, THEN the document SHALL re-render to reflect the new state without requiring the user to close and reopen the file.  +6. <a name="1.6"></a>The preference SHALL be application-scoped (not per-document); the same value applies to every open document and to documents opened later.++### 2. Block-Level HTML Comment Rendering++**User Story:** As a reader, I want stand-alone HTML comment blocks to appear as visible annotations when the toggle is on, so that I can read author notes inline with the document.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHEN the preference is OFF, THEN HTML comment blocks SHALL produce no visible output (including no raw `<!--` / `-->` markers, no comment text, and no placeholder).  +2. <a name="2.2"></a>WHEN the preference is ON AND an HTML block consists entirely of one or more HTML comments (with optional surrounding whitespace), THEN the system SHALL render each comment's text content with the `<!--` and `-->` markers stripped.  +3. <a name="2.3"></a>The rendered comment SHALL be visually distinct from regular body text: dimmer foreground colour and a typographic treatment (such as italic) that a sighted reader can identify at a glance.  +4. <a name="2.4"></a>The rendered comment SHALL be prefixed with a visual indicator glyph that communicates "informational note" (specific symbol chosen in the design phase), placed before the comment text with a single space of separation.  +5. <a name="2.5"></a>WHERE the comment spans multiple source lines, the rendered block SHALL preserve internal line breaks; leading and trailing whitespace SHALL be trimmed; the indicator glyph SHALL appear once at the start of the block.  +6. <a name="2.6"></a>IF a block-level HTML comment is empty or contains only whitespace, THEN the system SHALL render nothing for it even when the preference is ON.  +7. <a name="2.7"></a>IF an HTML block contains `<!--` without a matching `-->`, THEN the system SHALL leave the source unchanged and render it through the existing raw-HTML path rather than guessing a closing point.  +8. <a name="2.8"></a>IF an HTML block contains a mix of HTML comments and other HTML content, THEN the entire block SHALL render through the existing raw-HTML path unchanged; comment rendering applies only to blocks that are comment-only.  +9. <a name="2.9"></a>Vertical spacing between a rendered comment block and its neighbours SHALL match the spacing used between adjacent paragraph blocks in the same document.++### 3. Inline HTML Comment Rendering++**User Story:** As a reader, I want HTML comments embedded inside paragraphs and other inline contexts to also render when the toggle is on, so that mid-sentence author notes are not silently dropped.++**Acceptance Criteria:**++1. <a name="3.1"></a>WHEN the preference is OFF, THEN inline HTML comments SHALL produce no visible output (the surrounding text SHALL render as if the comment were not present in the source).  +2. <a name="3.2"></a>WHEN the preference is ON AND a paragraph contains one or more inline HTML comments, THEN each comment's text SHALL be rendered visually distinct from body text (dimmer colour and italic-equivalent typographic treatment) with markers stripped and the same indicator glyph used in §2 immediately preceding the text.  +3. <a name="3.3"></a>The rendered inline comment SHALL flow with the surrounding paragraph text rather than starting a new line.  +4. <a name="3.4"></a>WHERE an inline comment appears between word characters (e.g., `foo<!--x-->bar`), the rendered output SHALL show `foo`, the rendered comment, and `bar` in source order without joining `foo` and `bar` into a single token.  +5. <a name="3.5"></a>Inline HTML comments inside fenced or indented code blocks SHALL render literally as part of the code content regardless of the preference.  +6. <a name="3.6"></a>IF an inline HTML comment is empty or whitespace-only, THEN no glyph or text SHALL be rendered for it; the surrounding text SHALL appear as if the empty comment were not present (e.g., `foo<!---->bar` renders the same as `foobar`).  +7. <a name="3.7"></a>The indicator glyph SHALL scale with the surrounding text under Dynamic Type and SHALL mirror horizontally in right-to-left layouts according to platform conventions.++### 4. HTML Comments in Structured Contexts++**User Story:** As a reader of richly-structured documents, I want HTML comments inside headings, list items, table cells, blockquotes, and link/image labels to behave predictably, so that the feature is consistent across the whole document.++**Acceptance Criteria:**++1. <a name="4.1"></a>HTML comments inside heading text SHALL be stripped from anchor IDs, table-of-contents entries, navigation titles, and accessibility labels for the heading, regardless of the preference state.  +2. <a name="4.2"></a>WHEN the preference is ON, THEN an HTML comment inside a heading's visible text SHALL render inline as defined in §3, but SHALL NOT appear in derived navigation surfaces listed in 4.1.  +3. <a name="4.3"></a>HTML comments inside list items, blockquotes, and table cells SHALL be rendered inline within the enclosing element (paragraph, list item, cell) per §3 when the preference is ON, and SHALL be hidden when OFF.  +4. <a name="4.4"></a>WHERE a stand-alone HTML comment appears between adjacent list items in the source, the comment SHALL be rendered (when the preference is ON) without breaking the list structure: the items above and below SHALL remain part of the same list.  +5. <a name="4.5"></a>HTML comments inside link labels, link titles, image alt text, or image titles SHALL NOT be rendered as visible glyphs; the comment text SHALL be stripped from the label/alt/title regardless of the preference.++### 5. Document Search Integration++**User Story:** As a reader who has enabled comment rendering, I want document search to find text inside rendered comments, so that I can locate author notes the same way I locate other content.++**Acceptance Criteria:**++1. <a name="5.1"></a>WHEN the preference is ON, THEN text content inside rendered HTML comments SHALL be included in document search results.  +2. <a name="5.2"></a>WHEN the preference is OFF, THEN comment text SHALL NOT contribute matches to document search.  +3. <a name="5.3"></a>WHEN a search match falls inside a rendered comment, THEN the matched range SHALL be highlighted using the same search-highlight styling applied to other body text.  +4. <a name="5.4"></a>WHEN the preference changes WHILE a search query is active, THEN the system SHALL recompute matches against the new visibility, update the total match count, and reset the current-match cursor to the first match in document order; if no matches remain, the cursor SHALL show zero of zero.++### 6. Copy, Export, and Annotation Notes++**User Story:** As a reader, I want copy and export operations to reflect what I see on screen, so that hidden comments only appear in exported content when I have chosen to render them, and so that I do not get unexpected behaviour from a feature that is purely visual.++**Acceptance Criteria:**++1. <a name="6.1"></a>WHEN the preference is ON AND the user exports a document or copies notes, THEN rendered comment text SHALL appear in the output with the `<!--` / `-->` markers stripped.  +2. <a name="6.2"></a>WHEN the preference is OFF, THEN comment text SHALL NOT appear in any copy, share, or export output produced by the app.  +3. <a name="6.3"></a>For plain-text export and clipboard outputs, the indicator glyph SHALL be omitted from the output and replaced with a localised prefix such as "Comment: " (final wording chosen in the design phase) so that the export remains readable in plain text contexts.  +4. <a name="6.4"></a>For rich (attributed-text / formatted) export, the indicator glyph SHALL appear in the output exactly as it appears on screen.  +5. <a name="6.5"></a>Rendered HTML comments SHALL NOT be eligible targets for user annotation notes regardless of the preference state.++### 7. Accessibility++**User Story:** As a user relying on assistive technology, I want rendered HTML comments to be discoverable and clearly identified as comments, so that I do not confuse them with regular body text and so that mid-sentence comments do not fragment the surrounding speech.++**Acceptance Criteria:**++1. <a name="7.1"></a>Each rendered block-level HTML comment SHALL be exposed as a single accessibility element whose label begins with a localised "Comment" prefix followed by the comment content (e.g., VoiceOver reads "Comment, This is a hidden author note").  +2. <a name="7.2"></a>WHERE a paragraph contains one or more inline HTML comments, the paragraph's accessibility text SHALL announce a localised "Comment" marker only once at the start of the first inline comment in the paragraph, and SHALL announce a localised "End comment" marker after the last inline comment in the paragraph, so that the speech remains coherent.  +3. <a name="7.3"></a>The indicator glyph SHALL be marked decorative for assistive technology so that screen readers do not announce it separately from the localised "Comment" label.  +4. <a name="7.4"></a>The rendered text colour SHALL meet at least WCAG 2.1 AA contrast (4.5:1) against the document background in every supported theme and appearance mode.  +5. <a name="7.5"></a>Rendered comments SHALL convey their distinction from body text through more than colour alone (e.g., typographic style and the glyph), so that users with the differentiate-without-colour accommodation enabled can still tell them apart.++### 8. Localisation++**User Story:** As a non-English user, I want every new user-facing string from this feature to honour the app's localisation rules, so that translations stay consistent.++**Acceptance Criteria:**++1. <a name="8.1"></a>Every user-facing string added by this feature (toggle label, section header, accessibility prefixes, plain-text export prefix) SHALL be defined in `prism/Localizable.xcstrings`.  +2. <a name="8.2"></a>Accessibility modifiers carrying literal strings SHALL wrap them with `LocalizedStringKey` per project convention.++### 9. Performance and Compatibility++**User Story:** As a reader of large documents, I want the new feature to not introduce a noticeable regression in opening or rendering files, so that toggling it does not make the viewer feel slower.++**Acceptance Criteria:**++1. <a name="9.1"></a>WHEN parsing a 500 KB markdown document with the preference OFF, THEN the time-to-first-block SHALL NOT regress against the matching pre-change benchmark beyond the existing performance-test tolerance (the benchmark and tolerance are defined by the project's existing parsing performance tests in `prismTests`).  +2. <a name="9.2"></a>WHEN toggling the preference on a 500 KB document that is already parsed, THEN the system SHALL apply the change without re-running the swift-markdown AST parse (existing parsed blocks SHALL be reused; only the rendering / filtering layer SHALL change).  +3. <a name="9.3"></a>The feature SHALL work on both iOS 26+ and macOS 26+; rendering rules SHALL match across platforms, while Settings layout MAY follow each platform's native idioms.
specs/render-html-comments/tasks.md Added +205 / -0
diff --git a/specs/render-html-comments/tasks.md b/specs/render-html-comments/tasks.mdnew file mode 100644index 0000000..79a6461--- /dev/null+++ b/specs/render-html-comments/tasks.md@@ -0,0 +1,205 @@+---+references:+    - specs/render-html-comments/requirements.md+    - specs/render-html-comments/design.md+    - specs/render-html-comments/decision_log.md+---+# Tasks: Render HTML Comments++## Textual fork extension++- [x] 1. Add Textual extension tests for htmlComments syntax extension <!-- id:76me69o -->+  - In the Textual fork (github.com/arjenschwarz/textual) add Swift Testing cases for SyntaxExtension.htmlComments(visible:appearance:) mirroring the existing FootnoteReferences extension tests.+  - Cases: visible=true renders symbol + inner text run; visible=false replaces with empty; backtick-wrapped <code><!--x--></code> survives literally; empty/whitespace-only comments produce empty output even when visible; ranges are tagged with the new HTMLCommentRangeAttribute.+  - Also add tests for the reusable PatternProcessor.isInsideCodeSpan(range:in:) helper.+  - Stream: 1+  - Requirements: [2.4](requirements.md#2.4), [3.2](requirements.md#3.2), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [7.3](requirements.md#7.3)++- [x] 2. Implement htmlComments syntax extension in Textual fork and bump Prism pin <!-- id:76me69p -->+  - Implement SyntaxExtension.htmlComments(visible:appearance:); HTMLCommentAppearance struct; HTMLCommentRangeAttribute; and the PatternProcessor.isInsideCodeSpan helper in the fork.+  - Symbol attachment uses Textual's existing image-attachment construction; styled run applies italic and the appearance foreground colour.+  - Open and merge the PR in the Textual fork repo then update Package.swift in Prism to pin to the new commit hash.+  - Blocked-by: 76me69o (Add Textual extension tests for htmlComments syntax extension)+  - Stream: 1+  - Requirements: [2.4](requirements.md#2.4), [3.2](requirements.md#3.2), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [7.3](requirements.md#7.3)++## Foundation++- [x] 3. Add tests for HTMLCommentStripping and link-label stripping helper <!-- id:76me69q -->+  - New Swift Testing file prismTests/Services/HTMLCommentStrippingTests.swift.+  - Cover: single/multiple comment removal; whitespace collapsing; no-op when no comments present; link-label-only stripping ([foo<!--x-->bar](url) becomes [foobar](url)); reference-form link labels ([foo<!--x-->bar][ref]).+  - Verify comments OUTSIDE link brackets are NOT stripped by the link-label helper.+  - Stream: 2+  - Requirements: [2.2](requirements.md#2.2), [4.1](requirements.md#4.1), [4.5](requirements.md#4.5)++- [x] 4. Implement HTMLCommentStripping and link-label stripping helper <!-- id:76me69r -->+  - New prism/Services/HTMLCommentStripping.swift enum with static func strip(_:) -> String.+  - Add MarkdownBlockParser.stripCommentsInsideLinkLabels(_ markdown: String) -> String as a nonisolated static helper (in the same file as the existing stripCommentTags).+  - Blocked-by: 76me69q (Add tests for HTMLCommentStripping and link-label stripping helper)+  - Stream: 2+  - Requirements: [2.2](requirements.md#2.2), [4.1](requirements.md#4.1), [4.5](requirements.md#4.5)++- [x] 5. Add tests for HTMLCommentParser (example + property-based) <!-- id:76me69s -->+  - New Swift Testing file prismTests/Services/HTMLCommentParserTests.swift.+  - Example cases: single-line comment-only; multi-line preserving line breaks; multiple consecutive comments joined with source whitespace; empty <!----> returns nil; whitespace-only returns nil; unterminated <!-- returns nil; mixed <!-- x --> <p>real</p> returns nil; conditional <!--[if IE]>...<![endif]--> returns nil; <![CDATA[...]]> returns nil; <!DOCTYPE ...> returns nil; note-infrastructure body comment:abc123 returns nil.+  - Property tests: comment-only is recovered; mixed always nil. Use Swift Testing parameterised tests with hand-rolled generators.+  - Stream: 2+  - Requirements: [2.2](requirements.md#2.2), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8)++- [x] 6. Implement HTMLCommentParser.parseBlock <!-- id:76me69t -->+  - New prism/Services/HTMLCommentParser.swift enum with static func parseBlock(_ rawHTML: String) -> String?.+  - Structural regex anchored over <!--...--> with conditional-comment lookahead rejection; plus explicit <![CDATA[ / <!DOCTYPE prefix rejection and comment:HASH body rejection.+  - Content extraction via <!--([\s\S]*?)--> joined by source-separating whitespace; outer whitespace trimmed; empty results return nil.+  - Blocked-by: 76me69s (Add tests for HTMLCommentParser (example + property-based))+  - Stream: 2+  - Requirements: [2.2](requirements.md#2.2), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8)++- [x] 7. Add MarkdownBlock.htmlComment case and update all switch sites <!-- id:76me69u -->+  - Add case htmlComment(rawText: String) to MarkdownBlock (in prism/Models/MarkdownBlock.swift).+  - Update every switch listed in design Pattern Audit: contentForHashing (htmlComment:<text>); supportsNotes (false); debugTypeName (Comment); textContent (rawText); searchableText placeholder returning rawText (will be replaced by searchableText(in:) in phase 5).+  - Update consumers: MarkdownSectionBuilder; MarkdownSection; NoteGrouping; SearchMatch; SharedCollapsibleSections; FootnotePopoverView; RelocationEngine; NotesManager; FlattenedSearchBlock; DetailsBlockView.childBlockView.+  - Wiring task — no preceding TDD pair required. Existing block-iteration tests should still pass.+  - Blocked-by: 76me69t (Implement HTMLCommentParser.parseBlock)+  - Stream: 2+  - Requirements: [2.5](requirements.md#2.5), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [6.5](requirements.md#6.5)++- [x] 8. Add integration tests for MarkdownBlockParser HTML comment handling <!-- id:76me69v -->+  - Extend prismTests/MarkdownBlockParserTests.swift (or add a sibling file) with cases below.+  - Stand-alone <!-- note --> paragraph produces one .htmlComment(rawText: note) block.+  - <!--note--><img src=x> produces an .html block whose content is stripped of the comment markers.+  - Inline <!-- inside --> inside a paragraph keeps the comment in .paragraph(markdown:) verbatim.+  - Link label case: a paragraph containing the link `[foo<!--x-->bar](url)` stores `[foobar](url)` (link-label stripping applied).+  - List items separated by a comment-only block produce .list .htmlComment .list with the second list's ordinalOffset equal to the first list's item count when both are ordered.+  - testCommentInfrastructureTagsNeverReachHTMLCommentParser: feeding a document containing <!-- comment:abcdef123 --> produces no .htmlComment block (stripCommentTags removes it before swift-markdown parses).+  - swift-markdown round-trip pins: *foo<!--x-->bar* / [label<!--x-->more](url) / > quote <!--x--> more / **bold<!--y-->again** — assert Paragraph.format() preserves the literal <!--...--> characters in each case.+  - Blocked-by: 76me69u (Add MarkdownBlock.htmlComment case and update all switch sites)+  - Stream: 2+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [4.1](requirements.md#4.1), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)++- [x] 9. Wire HTMLCommentParser and stripping into MarkdownBlockParser <!-- id:76me69w -->+  - MarkdownBlockParser.convert() HTMLBlock branch: call HTMLCommentParser.parseBlock after HTMLImageParser and before the .html fall-back; on success return .htmlComment(rawText:). Apply HTMLCommentStripping.strip to the content stored in the .html(content:) fall-back.+  - convertParagraph: after stripEmptyAnchors run stripCommentsInsideLinkLabels over the markdown before storing.+  - Heading text used at DocumentSession.swift:336 / DocumentSession.swift:527 / TOCCoordinator.swift:106: compose FootnoteStripping.strip(HTMLCommentStripping.strip(text)).+  - Image alt/title in convertParagraph image-only path and in HTMLImageParser output: apply HTMLCommentStripping.strip to alt and title arguments before constructing .image.+  - Add ordinalOffset parameter to ordered-list rendering so a list following a .htmlComment block continues numbering from the previous list's item count.+  - Blocked-by: 76me69v (Add integration tests for MarkdownBlockParser HTML comment handling)+  - Stream: 2+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [4.1](requirements.md#4.1), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)++## Settings++- [x] 10. Add AppSettings property, Settings UI section, and toggle tests <!-- id:76me69x -->+  - Add AppSettings.showHTMLComments: Bool with @AppStorage(showHTMLComments) backing and default false; follow the existing property accessor pattern.+  - Add markdownRenderingSection to SettingsView between notesSection and rawSourceSection; include in both iOS form and macOS detail navigation arrays; add .accessibilityLabel(LocalizedStringKey(Show HTML comments)) + .accessibilityValue(LocalizedStringKey(On/Off)).+  - Add Swift Testing cases asserting default value is false and toggle round-trips through @AppStorage.+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.6](requirements.md#1.6), [8.1](requirements.md#8.1), [8.2](requirements.md#8.2)++## Rendering++- [x] 11. Add tests for HTMLCommentBlockView <!-- id:76me69y -->+  - New prismTests/Views/HTMLCommentBlockViewTests.swift.+  - OFF renders EmptyView (no padding no size).+  - ON + non-empty text renders symbol + italic dimmed text; accessibility element is combined with label Comment, <text>.+  - ON with text that resembles a localisation key or format specifier (e.g. %d items) renders verbatim — pins the Text(verbatim:) choice required by CLAUDE.md localisation rules.+  - Blocked-by: 76me69u (Add MarkdownBlock.htmlComment case and update all switch sites)+  - Stream: 2+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.6](requirements.md#2.6), [7.1](requirements.md#7.1), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5)++- [x] 12. Implement HTMLCommentBlockView and wire to block routing <!-- id:76me69z -->+  - New prism/Views/HTMLCommentBlockView.swift. Reads @Environment(AppSettings.self); uses Text(verbatim: rawText) with italic + theme-dim foreground; HStack(alignment: .firstTextBaseline spacing: 4) with Image(systemName: info.circle).+  - Add case .htmlComment(let rawText): to TextualBlockView's switch at line 154 returning HTMLCommentBlockView(rawText: rawText).+  - Add the same case in DetailsBlockView.childBlockView so comments nested inside <details> route through the same view.+  - Add a commentDimmedForeground colour to ThemeColors (or reuse an existing dim body colour if one exists with WCAG AA contrast in every theme).+  - Blocked-by: 76me69y (Add tests for HTMLCommentBlockView)+  - Stream: 2+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.6](requirements.md#2.6), [7.1](requirements.md#7.1), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5)++- [x] 13. Add inline rendering tests for HighlightedInlineText with HTML comments <!-- id:76me6a0 -->+  - Extend prismTests/Views/HighlightedInlineTextTests.swift (or add new file).+  - Toggle OFF: foo<!--x-->bar renders as foobar. Toggle ON: foo + symbol + x + bar with a visible break.+  - Backtick-wrapped <code><!--x--></code> survives literally in both states (code-span skip).+  - Multiple inline comments compose a single paragraph-level accessibility label of the form <before> Comment <c1> ... <cN> End comment <after>.+  - Changing showHTMLComments while a search query is active triggers a new TaskIdentifier and refreshes the highlighted AttributedString + match count.+  - Blocked-by: 76me69p (Implement htmlComments syntax extension in Textual fork and bump Prism pin), 76me69u (Add MarkdownBlock.htmlComment case and update all switch sites)+  - Stream: 2+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [7.2](requirements.md#7.2)++- [x] 14. Implement HighlightedInlineText updates and accessibility composer <!-- id:76me6a1 -->+  - Add .htmlComments(visible: settings.showHTMLComments appearance: htmlCommentAppearance) to inlineSyntaxExtensions (unconditional — both states do work).+  - Reorder updateHighlights: parse markdown with the full extension list FIRST then run SearchHighlightApplicator on the transformed AttributedString.+  - Extend the TaskIdentifier struct to include showHTMLComments so the task re-runs on toggle change while a query is active.+  - Add HTMLCommentAccessibilityModifier next to FootnoteAccessibilityModifier: walks HTMLCommentRangeAttribute runs in the rendered AttributedString and applies a composed .accessibilityLabel with Comment / End comment boundary markers; no-op when no comment runs present.+  - Blocked-by: 76me6a0 (Add inline rendering tests for HighlightedInlineText with HTML comments)+  - Stream: 2+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [7.2](requirements.md#7.2)++## Search++- [x] 15. Add tests for SearchContext and searchableText(in:) <!-- id:76me6a2 -->+  - Extend prismTests/Models/MarkdownBlockTests.swift with cases for searchableText(in:).+  - .htmlComment block: text included when showHTMLComments == true; empty when false.+  - .paragraph with inline comments: markers stripped from base in both states; inner text appended when on.+  - .heading with embedded comment: same symmetric treatment as paragraphs.+  - Compatibility shims: bare searchableText returns the same value as searchableText(in: SearchContext(showHTMLComments: false footnoteData: .empty)); searchableText(with:) delegates with showHTMLComments: false.+  - Blocked-by: 76me69u (Add MarkdownBlock.htmlComment case and update all switch sites)+  - Stream: 2+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [4.2](requirements.md#4.2)++- [x] 16. Implement SearchContext and update search call sites <!-- id:76me6a3 -->+  - New SearchContext: Sendable value with showHTMLComments: Bool and footnoteData: FootnoteData. Verify FootnoteData is already Sendable; if not add the conformance as part of this task.+  - Implement MarkdownBlock.searchableText(in:) per design (extract-and-append for paragraph/heading; gated for .htmlComment).+  - Keep searchableText and searchableText(with:) as compatibility shims delegating to the new method.+  - Switch SearchService / FlattenedSearchBlock / SearchExcerptExtractor / SearchOverlaySheet's excerpt path to use searchableText(in:) constructed from current AppSettings.showHTMLComments and the document's FootnoteData.+  - Blocked-by: 76me6a2 (Add tests for SearchContext and searchableText(in:))+  - Stream: 2+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [4.2](requirements.md#4.2)++- [x] 17. Add tests for SearchService.recomputeAfterVisibilityChange <!-- id:76me6a4 -->+  - Test that toggling showHTMLComments while a query is active: rebuilds matches against the new context; updates total count; resets current-match cursor to first match in document order; shows 0 of 0 when no matches remain.+  - Blocked-by: 76me6a3 (Implement SearchContext and update search call sites)+  - Stream: 2+  - Requirements: [5.4](requirements.md#5.4)++- [x] 18. Implement recomputeAfterVisibilityChange and wire to settings change <!-- id:76me6a5 -->+  - Add SearchService.recomputeAfterVisibilityChange() that re-runs match computation with the current query and context.+  - Hook onChange(of: settings.showHTMLComments) in the view layer that owns search (the same view that owns SearchService) to call this method.+  - Blocked-by: 76me6a4 (Add tests for SearchService.recomputeAfterVisibilityChange)+  - Stream: 2+  - Requirements: [5.4](requirements.md#5.4)++## Export++- [x] 19. Add export tests for HTMLCommentExport.flatten and exporter integration <!-- id:76me6a6 -->+  - New prismTests/Services/HTMLCommentExportTests.swift.+  - flatten(_ md:visible:) with visible=false strips all <!--...-->; visible=true replaces with Comment: <inner> using the localised prefix.+  - Block export: .htmlComment blocks contribute the localised Comment: <text> line in plain-text export when on; nothing when off.+  - Rich export: .htmlComment blocks render via the same styling path as the block view (snapshot or structural assertion).+  - Blocked-by: 76me69u (Add MarkdownBlock.htmlComment case and update all switch sites)+  - Stream: 2+  - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4)++- [x] 20. Implement HTMLCommentExport.flatten and wire into export paths <!-- id:76me6a7 -->+  - New prism/Services/HTMLCommentExport.swift with static func flatten(_ md: String visible: Bool) -> String.+  - Update InlineNotesExporter and any clipboard/share paths that walk blocks: include .htmlComment text when on; skip when off; apply flatten to paragraph markdown.+  - Confirm ExportSourceMapper's comment:HASH regex is unaffected (no code change there).+  - Blocked-by: 76me6a6 (Add export tests for HTMLCommentExport.flatten and exporter integration)+  - Stream: 2+  - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4)++## Localisation and integration tests++- [x] 21. Add catalog strings and run localisation validation <!-- id:76me6a8 -->+  - Add entries to prism/Localizable.xcstrings: Show HTML comments (toggle label); Markdown Rendering (section header); Comment (accessibility prefix); End comment (paragraph-scoped boundary marker); Comment: %@ (plain-text export prefix with substitution).+  - Run make test-locales to validate; add any required en-AU overrides in specs/localisation/en-AU-overrides.json if validation flags them.+  - Blocked-by: 76me69x (Add AppSettings property, Settings UI section, and toggle tests), 76me69z (Implement HTMLCommentBlockView and wire to block routing), 76me6a1 (Implement HighlightedInlineText updates and accessibility composer), 76me6a7 (Implement HTMLCommentExport.flatten and wire into export paths)+  - Stream: 2+  - Requirements: [8.1](requirements.md#8.1), [8.2](requirements.md#8.2)++- [x] 22. Add UI tests and rendering performance benchmark <!-- id:76me6a9 -->+  - prismUITests: open a document with HTML comments; toggle the setting from Settings while the document is visible; assert the body re-renders without losing scroll position; with a search active assert match count and highlight refresh after the toggle change.+  - New XCTest performance benchmark HighlightedInlineTextRenderBench: re-render a paragraph with N inline comments (N in {0, 10, 100}) and assert per-render time is bounded (no super-linear scaling in N).+  - Extend the existing 500 KB parsing performance test in prismTests to include comment-bearing input; assert the existing regression budget.+  - Blocked-by: 76me69z (Implement HTMLCommentBlockView and wire to block routing), 76me6a1 (Implement HighlightedInlineText updates and accessibility composer), 76me6a3 (Implement SearchContext and update search call sites), 76me6a5 (Implement recomputeAfterVisibilityChange and wire to settings change)+  - Stream: 2+  - Requirements: [1.5](requirements.md#1.5), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3)

Things to double-check

Make test on a clean macOS environment

The macOS test runner (com.apple.testmanagerd.control) died with the known SIGSEGV-shaped flake during this review's verification pass. iOS simulator launched the runner but the prism app failed with RequestDenied from SBMainWorkspace — also environmental. Run make test-quick on a clean shell before merging to confirm the new NotesExporterHTMLCommentTests suite and all impacted existing suites pass.

Visual parity between block-level and inline rendering

The design (Visual parity between block-level and inline rendering) noted that block-level uses an HStack of Image+Text while inline uses an attributed-string attachment. If a snapshot test reveals baseline drift after this lands, the block view should switch to the same AttributedString-with-attachment construction as the inline path. The performance bench at HighlightedInlineTextRenderBench covers inline; no equivalent visual snapshot exists for the block path.

Textual fork pin reproducibility

Pin bumped to 5c9f477fa07ecc5d68d1fa9f21aff8c4ddc65b31. The fork PR landed via squash-merge; the resolved commit should be reachable via git fetch https://github.com/arjenschwarz/textual main. Anyone consuming this branch from a clean checkout needs network access to that fork for the first build.