---
phase: 25-code-syntax-highlighting
plan: 01
type: implement
wave: 1
depends_on: []
files_modified: [web/package.json, web/src/styles/tokens.css, web/src/lib/cm/highlight.ts, web/src/lib/cm/highlight.test.ts, web/src/lib/cm/markdown.ts, web/src/lib/cm/mode.ts, web/src/lib/cm/widgets.ts, web/src/lib/cm/widgets.test.ts, web/src/components/LivePreviewEditor.css, web/vite-plugins/assertLazyChunks.ts, web/vite-plugins/assertLazyChunks.test.ts, web/vite.config.ts]
autonomous: true
requirements: [ED-04a]

must_haves:
  truths:
    - "ONE shared HighlightStyle (HighlightStyle.define with explicit class: names → --syntax-* tokens) drives BOTH surfaces: the editor via syntaxHighlighting(style) and the read CodeBlockWidget via highlightCode(code, tree, style, putText, putBreak)"
    - "A ```go block shows highlighted Go tokens in the live editor AND read mode; colors are var(--syntax-*) (tokens-only, no inline hex); the curated set highlights (js/ts, python, go, rust, json, yaml, sql, html, css, bash/shell, markdown, diff)"
    - "Highlighter output spans are built with createElement + textContent ONLY (no innerHTML / no HTML strings) — extends the widgets.ts T-06-08 invariant; tests assert element structure, never HTML strings"
    - "Languages are code-split via LanguageDescription.of({ load: () => import(...) }); the read widget renders plain FIRST then re-highlights when the parser resolves; the async write is teardown-safe (destroyed flag + dom.isConnected guard — never writes into a stale/detached node)"
    - "An unknown/absent language falls back to plain monospace with no error/throw; a ```mermaid fence is NOT highlighted (left plain — routed to Phase 26)"
    - "An automated build assertion (vite generateBundle plugin reading chunk.moduleIds, wired into vite.config + unit-tested) PROVES no @codemirror/lang-* or @codemirror/legacy-modes grammar lands in any entry chunk, and caps the entry-chunk size at a measured(core+style) budget; the plugin takes a forbidden-regex list so Phase 26 reuses it for mermaid"
    - "The plain→highlighted swap is zero-layout-shift (color-only — same font/size/padding) + a subtle CSS fade-in; extends the layout-neutral-reveal invariant to code blocks"
    - "The GFM-only parser contract is preserved: markdown.ts keeps base: commonmarkLanguage + tagParserExtensions; codeLanguages is purely additive (editor ≡ server Goldmark ≡ read view unchanged for non-code constructs)"
    - "Frontend-only: no Go/backend change; --syntax-* tokens are authored for dark and structured so a future light theme is a pure token swap"
---

<objective>
Add language-aware syntax highlighting to fenced code on both CM6 surfaces (live editor +
read-only LivePreviewEditor) via ONE shared, token-driven HighlightStyle, with every grammar
code-split out of the entry chunk and a reusable automated chunk assertion. Plain→highlighted
is zero-layout-shift + a subtle fade-in. Frontend-only. ED-04a.
</objective>

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

<context>
@.planning/phases/25-code-syntax-highlighting/25-CONTEXT.md
@.planning/REQUIREMENTS.md §ED-04a
@.planning/ROADMAP.md §"Phase 25" §"Phase 26"
</context>

<research_facts>
Verified against this repo's node_modules .d.ts + npm (2026-06-30) — TRUST these; do not re-derive from memory:
- `HighlightStyle implements Highlighter` (public `style(tags): string|null`). `HighlightStyle.define(specs: TagStyle[], opts?)`; `TagStyle = { tag: Tag|Tag[]; class?: string; ... }`. With `class:` set, the emitted class is that fixed name (NO StyleModule.mount needed — this is why we use explicit classes). `syntaxHighlighting(style): Extension`.
- `highlightCode(code: string, tree: Tree, highlighter: Highlighter|Highlighter[], putText: (code: string, classes: string) => void, putBreak: () => void, from?, to?): void`. There is NO putStyle. putText fires per styled run; putBreak fires per newline.
- Get the Tree: `langSupport.language.parser.parse(code)` (Parser.parse accepts a plain string).
- `LanguageDescription.of({ name, alias?, load?: () => Promise<LanguageSupport> })`; resolve in the widget via `LanguageDescription.matchLanguageName(codeLanguages, info)?.load()`. `markdown({ codeLanguages: LanguageDescription[] })` consumes the array directly (matches the fence info string).
- legacy-modes: `import { shell } from "@codemirror/legacy-modes/mode/shell"` (this IS bash/sh — there is no "bash" mode), `import { diff } from ".../mode/diff"`; wrap: `new LanguageSupport(StreamLanguage.define(m.shell))`.
- js AND ts both from `@codemirror/lang-javascript`: `javascript({ jsx: true, typescript: true })`.
- lang-markdown self-highlights (its grammar carries styleTags) — but in READ mode the block is a widget (not CM-rendered), and we scope the HighlightStyle to CODE-token tags so the editor's existing .cm-heading/.cm-strong decorations are untouched.
- Versions (add to web/package.json deps): @lezer/highlight 1.2.3, @codemirror/lang-javascript 6.2.5, @codemirror/lang-python 6.2.1, @codemirror/lang-go 6.0.1, @codemirror/lang-rust 6.0.2, @codemirror/lang-json 6.0.2, @codemirror/lang-yaml 6.1.3, @codemirror/lang-sql 6.10.0, @codemirror/legacy-modes 6.5.3. Already present: @codemirror/lang-html 6.4.11, @codemirror/lang-css 6.3.1, @codemirror/lang-markdown 6.5.0, @codemirror/autocomplete ^6 (peer of every lang-*, satisfied). Pin to caret of these.
- Vite chunk assertion: a Rollup `generateBundle(_opts, bundle)` plugin — for each `file.type==='chunk' && file.isEntry`, check `file.moduleIds` against forbidden regexes and `Buffer.byteLength(file.code)` against a max. moduleIds is the only module-level signal (manifest.json is too coarse). Vite 8.0.16 / Rollup 4.62.0.
- WidgetType has toDOM/eq/destroy(dom)/updateDOM; toDOM is sync → render plain, async-load, guard with a per-instance destroyed flag + dom.isConnected. (updateDOM call-order is unverified for this version — use the rebuild-on-!eq path, do NOT rely on updateDOM.)
</research_facts>

<tasks>

<task type="implement" tdd="true">
  <name>Task 1: deps + shared HighlightStyle + code-split codeLanguages + --syntax-* tokens</name>
  <files>web/package.json, web/src/styles/tokens.css, web/src/lib/cm/highlight.ts, web/src/lib/cm/highlight.test.ts, web/src/components/LivePreviewEditor.css</files>
  <read_first>
    - web/src/lib/cm/theme.ts — the tokens-only contract (every value a var(--…)); .cm-code-block styling to harmonize with
    - web/src/styles/tokens.css — existing --color-* tokens (bg #0b0d12, accent #8b5cf6, --color-code-bg); add --syntax-* near them
    - web/src/lib/cm/markdown.ts — the existing markdown() config codeLanguages will plug into (Task 2)
  </read_first>
  <behavior>
    Install the deps at the pinned versions in <research_facts> (npm i, commit the lockfile).
    `tokens.css`: add a `--syntax-*` block (muted/Obsidian-like, ~5 roles) — e.g. `--syntax-keyword`
    (soft blue), `--syntax-string` (soft green), `--syntax-comment` (grey), `--syntax-number`
    (amber), `--syntax-function` (soft purple harmonizing with --color-accent). Desaturated, legible
    on #0b0d12. Author dark values; group/name them so a future light theme is a pure swap (D-01).
    `highlight.ts` (NEW) exports:
      • `codeHighlightStyle = HighlightStyle.define([...], )` mapping @lezer/highlight `tags` →
        explicit `class:` names (`tok-keyword`, `tok-string`, `tok-comment`, `tok-number`,
        `tok-function`, …). Use the modifier `tags.function(tags.variableName)` for function names;
        group typeName+className under tok-function/type. SCOPE to CODE tags only — do NOT map
        heading/emphasis/strong (the editor's .cm-heading/.cm-strong decorations own those).
      • `codeLanguages: LanguageDescription[]` — one `LanguageDescription.of({ name, alias, load })`
        per curated language, each `load: () => import("@codemirror/lang-…").then(m => m.xxx())`
        (js/ts via `javascript({ jsx:true, typescript:true })`, alias js/ts/jsx/tsx). bash/shell + diff
        via `import("@codemirror/legacy-modes/mode/…").then(m => new LanguageSupport(StreamLanguage.define(m.shell|m.diff)))`.
        NO static top-level import of any grammar in this file (only dynamic import() inside load).
    `LivePreviewEditor.css`: add `.tok-keyword{color:var(--syntax-keyword)}` … one rule per role
    (var() only, no hex); `.tok-comment` also `font-style: italic`. These apply both inside the read
    widget <pre> and inside .cm-content (live editor). Add the fade-in here too (used in Task 3).
  </behavior>
  <verify>
    `highlight.test.ts`: `codeHighlightStyle` is a HighlightStyle (style(...) callable); `codeLanguages`
    contains the curated names + expected aliases (js/ts→javascript, bash/sh→shell); each `load()`
    resolves to a LanguageSupport for a representative subset (go, python, shell, diff) WITHOUT a
    static import (the test dynamic-imports via load). Assert NO grammar is imported at module top
    (the only import()s are inside load). `npx tsc -b` clean.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 2: wire the live editor — markdown({ codeLanguages }) + syntaxHighlighting(style)</name>
  <files>web/src/lib/cm/markdown.ts, web/src/lib/cm/mode.ts</files>
  <read_first>
    - web/src/lib/cm/markdown.ts — markdownExtension = markdown({ base: commonmarkLanguage, extensions: tagParserExtensions }); the GFM-only contract comment
    - web/src/lib/cm/mode.ts — liveExtensions/sourceExtensions composition (markdownExtension + livePreviewExtension + theme)
    - web/src/lib/cm/highlight.ts — codeHighlightStyle + codeLanguages (Task 1)
  </read_first>
  <behavior>
    `markdown.ts`: add `codeLanguages` to the `markdown({...})` call — KEEP `base: commonmarkLanguage`
    and `extensions: tagParserExtensions` exactly (GFM-only + hashtag preserved; codeLanguages is
    additive). CM's markdown extension lazy-loads the nested grammar and re-parses on resolve, so the
    editor re-highlights a ```go block automatically once lang-go's chunk arrives.
    `mode.ts`: add `syntaxHighlighting(codeHighlightStyle)` to `liveExtensions` (the edit/live surface).
    Because the HighlightStyle is scoped to code tags, this colors only nested fenced-code tokens; the
    existing heading/bold decorations are unaffected. Do NOT add it to sourceExtensions (Source mode
    stays raw mono, per UI-SPEC) unless trivially harmless — default: live only.
  </behavior>
  <verify>
    Extend `mode.test.ts` (or markdown.test.ts): liveExtensions includes the syntaxHighlighting
    extension; markdownExtension still parses the four GFM constructs + hashtag (no regression — reuse
    the existing parser assertions); a fenced ```go region produces nested-language tokens in the tree
    (parse a doc, assert a Go keyword node carries a highlight tag). Existing livePreview/markdown tests
    stay green.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 3: read CodeBlockWidget — async highlight (plain→highlighted), teardown-safe, textContent-only</name>
  <files>web/src/lib/cm/widgets.ts, web/src/lib/cm/widgets.test.ts, web/src/components/LivePreviewEditor.css</files>
  <read_first>
    - web/src/lib/cm/widgets.ts — CodeBlockWidget (line 101): current plain <pre data-lang><code>textContent</code></pre>; the no-innerHTML invariant shared by all widgets; eq(code,lang)
    - web/src/lib/cm/highlight.ts — codeHighlightStyle + codeLanguages + matchLanguageName resolution
    - web/src/lib/cm/livePreview.ts (≈480-492) — where `new CodeBlockWidget(code, lang)` is mounted (constructor signature MUST stay (code, lang) so this mount is unchanged)
  </read_first>
  <behavior>
    Extend `CodeBlockWidget` (keep the (code, lang) constructor + eq). In `toDOM`: build the plain
    `<pre class="cm-md-codeblock" data-lang><code>` with `code.textContent = this.code` FIRST (today's
    behavior — synchronous, safe). THEN, if `this.lang` resolves a curated language
    (`LanguageDescription.matchLanguageName(codeLanguages, this.lang)`) and is NOT mermaid: call
    `.load()`, and on resolve — GUARDED by `if (this.destroyed || !codeEl.isConnected) return;` — clear
    the <code> and re-emit tokens via `highlightCode(this.code, support.language.parser.parse(this.code),
    codeHighlightStyle, putText, putBreak)`, where putText creates a `<span class={classes}>` with
    `textContent` (NO innerHTML) and putBreak appends a newline ("\n" text node or a line wrapper).
    Add `destroy() { this.destroyed = true; }`. Unknown/absent lang OR mermaid → skip load, stay plain
    (no throw; .catch on the import leaves plain text). Add a `.cm-md-codeblock--highlighted` class (or
    similar) toggled when tokens are mounted, with a short CSS opacity/color transition (fade-in) and
    NO metric change (zero layout shift — same font/size/padding as plain).
  </behavior>
  <verify>
    `widgets.test.ts`: (a) toDOM returns plain <pre><code> with textContent === code synchronously
    (no spans yet) and data-lang set; (b) after the async load resolves (await a flushed microtask /
    mock the language load), the <code> contains tok-* <span>s whose textContent concatenates back to
    the code, asserted via element structure (tagName/className/textContent) — NEVER on innerHTML
    strings; (c) an unknown lang and a ```mermaid stay plain (no spans, no throw); (d) teardown safety:
    call destroy() (or detach the node) before the load resolves → no throw and no write into the
    detached node. livePreview.test.ts CodeBlockWidget tests stay green (constructor unchanged).
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 4: automated bundle assertion — reusable generateBundle plugin (no grammar in entry chunk + size cap)</name>
  <files>web/vite-plugins/assertLazyChunks.ts, web/vite-plugins/assertLazyChunks.test.ts, web/vite.config.ts</files>
  <read_first>
    - web/vite.config.ts — current plugin list / build config
    - web/package.json — "build": "tsc -b && vite build" (the assertion must run as part of this)
    - <research_facts> — the generateBundle/moduleIds approach + the exact OutputChunk fields
  </read_first>
  <behavior>
    `assertLazyChunks.ts` (NEW): export `assertLazyChunks(opts: { forbidden: RegExp[]; maxEntryBytes?: number }): Plugin`.
    In `generateBundle(_options, bundle)`: for each `file` where `file.type === 'chunk' && file.isEntry`:
    for every `id` in `file.moduleIds`, if any `forbidden` regex matches → `this.error(\`${id} leaked
    into entry chunk ${file.fileName}\`)`; if `maxEntryBytes` set and `Buffer.byteLength(file.code) >
    maxEntryBytes` → `this.error(...)`. (Designed for reuse: Phase 26 passes `forbidden:[/mermaid/]`.)
    `vite.config.ts`: add `assertLazyChunks({ forbidden: [/node_modules\/@codemirror\/lang-/, /node_modules\/@codemirror\/legacy-modes\//], maxEntryBytes: <BUDGET> })` to plugins so `npm run build`
    FAILS on a grammar leak. BUDGET = measure the real entry-chunk byte size from a clean build after
    Tasks 1-3 land (grammars lazy), then set the cap to that + ~20% headroom; record the measured number
    + the cap in the SUMMARY (D-05; the figure is empirical, not guessed).
  </behavior>
  <verify>
    `assertLazyChunks.test.ts`: unit-test the plugin's generateBundle against SYNTHETIC bundle objects
    (no real build needed — fast/deterministic): (a) an entry chunk whose moduleIds include a forbidden
    path → the plugin calls this.error (throws via a fake PluginContext); (b) a clean entry (grammar only
    in a non-entry chunk) → no error; (c) oversize entry code → error; under-budget → pass; (d)
    non-entry chunks and assets are ignored. THEN prove it end-to-end: run `npm run build` (tsc -b &&
    vite build) and confirm it exits 0 with the assertion active (grammars are split). The build green
    IS the integration evidence that no grammar leaked + entry ≤ budget.
  </verify>
</task>

</tasks>

<success_criteria>
1. A ```go block highlights Go tokens in BOTH the live editor and read mode; colors are
   var(--syntax-*) dark tokens; read-widget spans are textContent-only. The curated set highlights;
   an unknown language → plain monospace, no error; a ```mermaid fence is NOT highlighted.
2. ONE shared HighlightStyle (explicit class: → --syntax-* tokens) drives both surfaces; the read
   widget renders plain then re-highlights when the lazy grammar resolves, teardown-safe, with a
   zero-layout-shift fade-in.
3. Languages are code-split; the automated assertion (vite plugin, unit-tested + wired into the build)
   proves no @codemirror/lang-*/legacy-modes grammar is in any entry chunk and caps the entry size;
   the plugin is parameterized for Phase 26 reuse.
4. The GFM-only parser contract is intact; frontend-only (no backend diff); tokens-only (no inline hex).
5. From web/: `npx vitest run` green; `npx tsc -b` clean; `npm run build` clean WITH the assertion
   active (a grammar leak would fail it). No regression in the existing cm/livePreview/PageView suites.
</success_criteria>
