---
phase: 26-mermaid-diagrams
plan: 01
type: implement
wave: 1
depends_on: []
files_modified: [web/package.json, web/src/lib/cm/mermaid.ts, web/src/lib/cm/mermaid.test.ts, web/src/lib/cm/widgets.ts, web/src/lib/cm/widgets.test.ts, web/src/lib/cm/livePreview.ts, web/src/lib/cm/livePreview.test.ts, web/src/components/LivePreviewEditor.css, web/vite.config.ts, web/vite-plugins/assertLazyChunks.test.ts]
autonomous: true
requirements: [ED-05a]

must_haves:
  truths:
    - "A valid ```mermaid block (flowchart/sequence/class/state/ER) renders as inline SVG in BOTH read mode AND the live editor, themed dark; a ```mermaid fence is rendered as a diagram, not highlighted/plain"
    - "mermaid loads ONLY via dynamic import() (its own async chunk); the automated build assertion (assertLazyChunks, forbidden extended with mermaid + its heavy transitive deps) PROVES mermaid/d3/dagre-d3-es/cytoscape/dompurify/katex/stylis/marked/etc are absent from every entry chunk; npm run build FAILS if any leak"
    - "Rendering uses initialize({ startOnLoad:false, securityLevel:'strict', theme:'dark', suppressErrorRendering:true }) called once (guarded); the returned SVG is injected via new DOMParser().parseFromString(svg,'image/svg+xml') + appendChild after a parsererror check — NEVER innerHTML of page content; bindFunctions is not called under strict"
    - "A parse/render error falls back to the existing CodeBlockWidget (raw fenced code) — never blank, never a thrown error escaping toDOM, and no leftover temp <div id=\"d…\"> on document.body (suppressErrorRendering removes it; mermaid.parse pre-validates)"
    - "Live-edit mode reveals the raw ```mermaid source when the selection intersects the block (active-line reveal) and renders the diagram otherwise; rendering is therefore not per-keystroke; a stale-result guard discards a resolved SVG if the block's text changed or the widget was destroyed mid-render"
    - "MermaidWidget reuses the Phase-25 async-widget pattern: a synchronous raw-code fallback DOM FIRST, then async load+render, then an atomic detached swap guarded by a destroyed flag + dom.isConnected, inside a whole-pass try/catch; eq() keyed on (code, theme) so unchanged diagrams are not re-rendered"
    - "The change is additive: buildCodeBlockDecorations branches lang===\"mermaid\" → MermaidWidget else CodeBlockWidget (unchanged); non-mermaid code blocks, Phase-25 syntax highlighting, and the GFM-only parser are untouched"
    - "Frontend-only (no Go/backend diff); the Phase-25 entry-chunk budget is NOT raised (mermaid stays lazy); dark theme now, structured for a future light swap"
    - "Tests MOCK import('mermaid') (jsdom cannot getBBox): assert our DOM handling (inject a canned svg → <svg> in the widget; reject → raw-code fallback; destroy mid-render → no throw/no stale write) + the lazy-import (no static mermaid import) + the build assertion's mermaid coverage"
---

<objective>
Render ```mermaid fenced blocks as inline SVG on both CM6 surfaces (read + live, Obsidian-style
reveal) via a lazy-loaded, strict-sandboxed MermaidWidget with a raw-code fallback, build-asserted
out of the main bundle. Frontend-only. Reuses Phase-25's async-widget pattern + assertLazyChunks.
ED-05a. This is the milestone's heaviest/riskiest phase — the live-mode emission + reveal is the
load-bearing new complexity.
</objective>

<execution_context>
@$HOME/.claude/anvil/workflows/execute-plan.md
</execution_context>

<context>
@.planning/phases/26-mermaid-diagrams/26-CONTEXT.md
@.planning/REQUIREMENTS.md §ED-05a
@.planning/ROADMAP.md §"Phase 26"
@.planning/phases/25-code-syntax-highlighting/25-01-PLAN-SUMMARY.md
</context>

<research_facts>
Verified against mermaid@11.16.0 (published types + bundled dist) — TRUST these; do not re-derive:
- Install `mermaid@^11.16.0` (ESM-only). Lazy load: `const mermaid = (await import("mermaid")).default;` — the default export has `initialize`, `parse`, `render`. Import types separately: `import type { RenderResult } from "mermaid"`.
- `initialize(config: MermaidConfig): void` — SYNC, call ONCE guarded (it overwrites site config each call; not usefully idempotent). theme enum includes `'dark'`. `securityLevel:'strict'` ⇒ mermaid runs the output through `DOMPurify.sanitize(...)` before returning the svg (so DOMParser injection of untrusted diagram source is safe) AND disables click/interactivity.
- **CRITICAL: set `suppressErrorRendering: true`.** By default `render` does NOT throw on an invalid diagram — it returns a "Syntax error" error-SVG and LEAVES a temp `<div id="d{id}">` on document.body. With `suppressErrorRendering:true`, render `removeTempElements()` + rethrows on error (clean fallback, no leftover node).
- `render(id: string, text: string, svgContainingElement?: Element): Promise<{ svg: string; diagramType: string; bindFunctions?: (el: Element) => void }>`. `id` must be a UNIQUE valid CSS id — generate fresh per render (`mmd-${counter++}`). On success the temp node is removed before return.
- `parse(text: string, opts?: { suppressErrors?: boolean }): Promise<ParseResult | false>` — throws on invalid by default (DOM-free); with `{suppressErrors:true}` returns false. Use as a cheap pre-validation gate.
- Safe injection: `const doc = new DOMParser().parseFromString(svg, "image/svg+xml"); if (doc.querySelector("parsererror")) → fallback; el = doc.documentElement; el.setAttribute("style","max-width:100%;height:auto"); wrapper.appendChild(el);` — never innerHTML. Skip bindFunctions under strict. Call CM `requestMeasure` after append so line height updates (avoid layout shift).
- Tests: `mermaid.render` CANNOT run under jsdom (no getBBox). MOCK `import("mermaid")` (vi.mock) — test only our DOM handling (canned-svg inject / reject→fallback / teardown), NOT mermaid's renderer. mermaid needs `document.body` to exist; no requestAnimationFrame needed.
- Debounce/serial: render is stateless per call + "enqueued to run serially"; use a fresh id per render + a generation/stale guard (discard resolved svg if the block text changed or widget destroyed mid-render). The reveal model (raw while editing the block) already prevents per-keystroke renders.
- assertLazyChunks `forbidden` additions (the heavy transitive deps that must NOT reach entry):
  `/node_modules[\\/]mermaid[\\/]/`, `/node_modules[\\/]d3(-[a-z]+)?[\\/]/`, `/node_modules[\\/]dagre-d3-es[\\/]/`,
  `/node_modules[\\/]cytoscape(-[a-z-]+)?[\\/]/`, `/node_modules[\\/]khroma[\\/]/`, `/node_modules[\\/]dompurify[\\/]/`,
  `/node_modules[\\/]katex[\\/]/`, `/node_modules[\\/]stylis[\\/]/`, `/node_modules[\\/]marked[\\/]/`,
  `/node_modules[\\/]dayjs[\\/]/`, `/node_modules[\\/]roughjs[\\/]/`, `/node_modules[\\/]@mermaid-js[\\/]parser[\\/]/`.
  A single dynamic `import("mermaid")` splits cleanly under Vite 8/Rollup 4 (mermaid internally lazy-loads diagram renderers); the ONLY thing that breaks laziness is a stray static `import "mermaid"` reachable from entry — the assertion catches exactly that. `optimizeDeps:{ include:['mermaid'] }` is a dev-server-only QoL (does not affect prod splitting).
</research_facts>

<tasks>

<task type="implement" tdd="true">
  <name>Task 1: mermaid lazy loader + MermaidWidget + read-mode wiring + raw-code fallback</name>
  <files>web/package.json, web/src/lib/cm/mermaid.ts, web/src/lib/cm/mermaid.test.ts, web/src/lib/cm/widgets.ts, web/src/lib/cm/widgets.test.ts, web/src/lib/cm/livePreview.ts, web/src/lib/cm/livePreview.test.ts, web/src/components/LivePreviewEditor.css</files>
  <read_first>
    - web/src/lib/cm/widgets.ts — CodeBlockWidget (line 101): the raw-code fallback + the widget shape; the no-innerHTML/textContent invariant
    - web/src/lib/cm/highlight.ts + the Phase-25 CodeBlockWidget async highlight in widgets.ts — the async-widget pattern (plain-first → async → atomic detached swap → destroyed/isConnected guard) to mirror
    - web/src/lib/cm/livePreview.ts (≈480-492) — buildCodeBlockDecorations: the FencedCode → CodeBlockWidget mount to branch; parseFenced
    - .planning/phases/25-code-syntax-highlighting/25-01-PLAN-SUMMARY.md — the reusable pattern + assertLazyChunks
  </read_first>
  <behavior>
    Add `mermaid@^11.16.0` to web/package.json deps (loaded ONLY via dynamic import — never a static import).
    `mermaid.ts` (NEW): a tiny lazy wrapper — `renderMermaid(code: string): Promise<string>` that
    `const mermaid = (await import("mermaid")).default`, calls `initOnce(mermaid)` (module-level guard →
    `mermaid.initialize({ startOnLoad:false, securityLevel:'strict', theme:'dark', suppressErrorRendering:true, themeVariables:{ background: <dark token-equiv>, ... } })`), `await mermaid.parse(code)` then
    `const { svg } = await mermaid.render("mmd-"+nextId(), code)` and returns the svg string; throws on
    invalid (suppressErrorRendering + parse). NO top-level mermaid import in this file.
    `widgets.ts`: add `MermaidWidget extends WidgetType` (constructor `(code: string, theme?: string)`,
    `eq` on (code, theme)). `toDOM`: build the raw-code fallback DOM FIRST (reuse the CodeBlockWidget
    render or a `<pre class="cm-md-codeblock"><code textContent></code></pre>`), then async:
    `renderMermaid(this.code).then(svg => { if (this.destroyed || !wrapper.isConnected) return; parse via
    DOMParser image/svg+xml; if parsererror → keep fallback; else atomic-swap in the <svg> (style
    max-width:100%;height:auto) }).catch(() => { /* keep raw-code fallback */ })`. `destroy(){ this.destroyed = true }`.
    Whole async pass in try/catch. NEVER innerHTML of page content. Skip bindFunctions.
    `livePreview.ts` buildCodeBlockDecorations: branch — `lang === "mermaid"` → `new MermaidWidget(code, theme)`
    (block replace), else `new CodeBlockWidget(code, lang)` (unchanged). THIS TASK wires READ mode only
    (the existing cleanRender path); live-mode emission is Task 3.
    `LivePreviewEditor.css`: `.cm-md-mermaid` wrapper (centered, dark surface, max-width, a min-height to
    blunt layout shift, + a subtle fade-in when the svg mounts).
  </behavior>
  <verify>
    `mermaid.test.ts` + `widgets.test.ts` with `vi.mock("mermaid")` (jsdom can't getBBox): (a) read-mode
    MermaidWidget.toDOM returns the raw-code fallback synchronously; after a mocked render RESOLVES with a
    canned `<svg>…</svg>` string, the wrapper contains a parsed `<svg>` (assert tagName/namespace/structure,
    NOT innerHTML strings) and the fallback is replaced; (b) mocked render REJECTS → the raw-code fallback
    remains, no throw, no error-SVG; (c) destroy() before resolve → no throw, no write into the detached
    node; (d) a parsererror svg → fallback. `livePreview.test.ts`: a ```mermaid FencedCode in read mode
    yields a MermaidWidget (not a CodeBlockWidget); a ```go block still yields a CodeBlockWidget. Assert
    `mermaid.ts` has NO static top-level mermaid import (only the dynamic import inside renderMermaid).
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 2: bundle assertion — prove mermaid + heavy deps never reach an entry chunk</name>
  <files>web/vite.config.ts, web/vite-plugins/assertLazyChunks.test.ts</files>
  <read_first>
    - web/vite-plugins/assertLazyChunks.ts — the Phase-25 plugin ({ forbidden: RegExp[]; maxEntryBytes }); reused as-is, only its config grows
    - web/vite.config.ts — the existing assertLazyChunks({ forbidden, maxEntryBytes }) call from Phase 25
    - <research_facts> — the exact forbidden-regex additions
  </read_first>
  <behavior>
    `vite.config.ts`: extend the existing `assertLazyChunks({ forbidden: [...] })` `forbidden` array with the
    mermaid dep regexes from <research_facts> (mermaid, d3(-*), dagre-d3-es, cytoscape(-*), khroma, dompurify,
    katex, stylis, marked, dayjs, roughjs, @mermaid-js/parser). Keep the Phase-25 lang-* rules and the
    `maxEntryBytes` budget UNCHANGED (mermaid must not inflate the entry chunk — do NOT raise the cap). The
    plugin source needs NO change (it's parameterized).
    `assertLazyChunks.test.ts`: add cases proving the new rules have teeth — a synthetic entry chunk whose
    moduleIds include `…/node_modules/mermaid/…` (and one with `…/d3-shape/…`) → the plugin errors; a clean
    build where those ids are only in non-entry chunks → passes.
  </behavior>
  <verify>
    Unit cases above pass. Then `npm run build` (tsc -b && vite build) is GREEN with the extended assertion
    active — which is the end-to-end proof that the dynamic import() kept mermaid + d3 + dompurify + … out of
    every entry chunk (the criterion-2 "automated build assertion, not eyeballing"). If any leaked, the build
    would fail. Note in the SUMMARY which chunk mermaid landed in + the unchanged entry size.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 3: live-mode rendering + active-line reveal + stale-guard (the read+live decision)</name>
  <files>web/src/lib/cm/livePreview.ts, web/src/lib/cm/livePreview.test.ts</files>
  <read_first>
    - web/src/lib/cm/livePreview.ts — the codeBlockField StateField, the cleanRender facet gate (≈480), the atomicRanges provide (≈460-475); how the field maps over transactions (must also recompute on SELECTION change for the reveal)
    - web/src/lib/cm/mode.ts — how liveExtensions vs the read-only surface compose (to know the live vs read gating)
    - 26-CONTEXT.md D-02 (the read+live decision + reveal rule)
  </read_first>
  <behavior>
    Make a ```mermaid block render in LIVE/edit mode too, with active-line reveal. Currently
    buildCodeBlockDecorations returns nothing when `!cleanRender` (read-only). Change the mermaid branch so
    a MermaidWidget is emitted when: read mode (cleanRender) OR (edit mode AND the selection does NOT
    intersect the block's [from,to]). When edit mode AND selection intersects the block → emit NO decoration
    (reveal the raw ```mermaid source for editing). Non-mermaid code blocks keep their existing read-only
    behavior EXACTLY (do not start rendering ```go blocks in edit mode). The codeBlockField must recompute on
    selection changes (not just doc changes) so moving the cursor in/out of a mermaid block toggles
    reveal/render. eq(code,theme) means an unchanged diagram is not re-rendered when the cursor leaves; the
    reveal model means no render happens while you're typing inside the block → not per-keystroke (the
    debounce criterion holds by construction). Add a stale-result guard in the widget (or via eq + destroy):
    if the block text changed (new widget instance) or the widget was destroyed while render was in flight,
    discard the resolved svg (already handled by destroyed/isConnected from Task 1; confirm it covers the
    cursor-exit→new-instance case). Keep block widgets non-cursor-trapping in edit mode.
  </behavior>
  <verify>
    `livePreview.test.ts`: (a) in EDIT mode with the selection OUTSIDE a ```mermaid block → a MermaidWidget
    decoration is emitted (live render); (b) in EDIT mode with the selection INSIDE the block → NO decoration
    (raw source revealed); (c) moving the selection out → the widget reappears; (d) a ```go block in EDIT mode
    still emits NO widget (unchanged); (e) read mode always emits the widget. Drive these via EditorState with
    set selections (no real mermaid render needed — assert on the decoration set / widget type). Existing
    CodeBlockWidget read-mode tests + the Phase-25 highlight tests stay green.
  </verify>
</task>

</tasks>

<success_criteria>
1. A valid ```mermaid (flowchart/sequence/class/state/ER) renders as inline SVG in read mode AND the live
   editor (revealing raw source when the cursor is in the block), themed dark; an invalid diagram falls back
   to the raw fenced code — never blank, never a crash, no leftover temp DOM node.
2. mermaid loads only on first display via dynamic import(); the automated build assertion proves mermaid +
   its heavy transitive deps are absent from every entry chunk (npm run build fails on a leak); the entry
   budget is unchanged.
3. Rendering uses securityLevel:'strict' + suppressErrorRendering:true; the sanitized SVG is injected via
   DOMParser + appendChild (never innerHTML of page content); the change is additive (CodeBlockWidget,
   Phase-25 highlighting, and the GFM parser untouched); frontend-only.
4. From web/: `npx tsc -b` clean; `npx vitest run` green (mermaid mocked; no regression in cm/livePreview/
   PageView/Phase-25 suites); `npm run build` clean WITH the extended assertion active.
</success_criteria>
