From 73f452c01053fa6bee62299a25c5b244fae983e7 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 5 Aug 2026 20:27:15 -0700 Subject: [PATCH 1/4] perf(build): stub out the dead Oniguruma WASM in every bundle @pierre/diffs picks its Shiki engine with a runtime ternary: engine: preferredHighlighter === "shiki-wasm" ? createOnigurumaEngine(import("shiki/wasm")) : createJavaScriptRegexEngine() Plannotator pins `preferredHighlighter: 'shiki-js'` (and Pierre's own default is 'shiki-js'), so the Oniguruma branch never executes. Because the choice is a runtime ternary, bundlers keep the `import("shiki/wasm")` edge anyway and inline `@shikijs/engine-oniguruma/wasm-inlined`, a ~622 KB base64 blob, into the single-file HTML builds. The review app paid for it twice: once on the main thread (via `highlighter/shared_highlighter.js`) and once inside the `?worker&inline` Pierre worker. Alias `shiki/wasm` to a stub that throws if it is ever reached. Wired via `resolve.alias` rather than a plugin because `resolve.alias` is shared with Vite's worker build and `plugins` are not. Highlighting output is unchanged: the JS regex engine was already the one doing the work. Opting back into 'shiki-wasm' now fails loudly instead of silently costing every user a megabyte of dead bytes. apps/review/dist/index.html 19,424,646 -> 18,180,545 (-1,244,101 raw / -463,348 gzip) apps/hook/dist/index.html 23,032,467 -> 22,410,416 (-622,051 raw / -233,485 gzip) --- apps/hook/vite.config.ts | 4 ++++ apps/portal/vite.config.ts | 3 +++ apps/review/vite.config.ts | 11 ++++++++--- build/shiki-wasm-stub.ts | 38 ++++++++++++++++++++++++++++++++++++++ bun.lock | 6 +++--- 5 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 build/shiki-wasm-stub.ts diff --git a/apps/hook/vite.config.ts b/apps/hook/vite.config.ts index 6b5a0d5e2..a44c1c962 100644 --- a/apps/hook/vite.config.ts +++ b/apps/hook/vite.config.ts @@ -18,6 +18,10 @@ export default defineConfig({ resolve: { dedupe: ['react', 'react-dom'], alias: { + // Drop the dead Oniguruma WASM (~622 KB base64). The plan editor reaches + // Pierre's shared highlighter through CodeFilePopout and the fence + // highlighter. See build/shiki-wasm-stub.ts. + 'shiki/wasm': path.resolve(__dirname, '../../build/shiki-wasm-stub.ts'), '@': path.resolve(__dirname, '.'), '@plannotator/shared': path.resolve(__dirname, '../../packages/shared'), '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), diff --git a/apps/portal/vite.config.ts b/apps/portal/vite.config.ts index e3c372158..cc42a3a92 100644 --- a/apps/portal/vite.config.ts +++ b/apps/portal/vite.config.ts @@ -39,6 +39,9 @@ export default defineConfig({ plugins: [faviconPlugin(), react(), tailwindcss()], resolve: { alias: { + // Drop the dead Oniguruma WASM (~622 KB base64). See + // build/shiki-wasm-stub.ts. + 'shiki/wasm': path.resolve(__dirname, '../../build/shiki-wasm-stub.ts'), '@': path.resolve(__dirname, '.'), '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), '@plannotator/editor/styles': path.resolve(__dirname, '../../packages/editor/index.css'), diff --git a/apps/review/vite.config.ts b/apps/review/vite.config.ts index 440f44eba..8efdd272b 100644 --- a/apps/review/vite.config.ts +++ b/apps/review/vite.config.ts @@ -33,6 +33,10 @@ export default defineConfig({ plugins: [demoFileContentPlugin(), react(), tailwindcss(), viteSingleFile()], resolve: { alias: { + // Drop the dead Oniguruma WASM (~622 KB base64, inlined twice here: main + // thread + worker). See build/shiki-wasm-stub.ts. `resolve.alias` is + // shared with the worker build below; `plugins` would not be. + 'shiki/wasm': path.resolve(__dirname, '../../build/shiki-wasm-stub.ts'), '@': path.resolve(__dirname, '.'), '@plannotator/shared': path.resolve(__dirname, '../../packages/shared'), '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), @@ -42,9 +46,10 @@ export default defineConfig({ } }, // The Pierre highlight worker (?worker&inline) contains a dynamic - // import("shiki/wasm") branch; iife (Vite's default worker format) can't - // code-split, so emit the worker as ES with dynamic imports collapsed into - // the single inlined bundle. + // import("shiki/wasm") branch (aliased to a stub above, but still a dynamic + // import edge); iife (Vite's default worker format) can't code-split, so + // emit the worker as ES with dynamic imports collapsed into the single + // inlined bundle. worker: { format: 'es', rollupOptions: { diff --git a/build/shiki-wasm-stub.ts b/build/shiki-wasm-stub.ts new file mode 100644 index 000000000..17706344f --- /dev/null +++ b/build/shiki-wasm-stub.ts @@ -0,0 +1,38 @@ +/** + * Build-time stub for `shiki/wasm`. + * + * `@pierre/diffs` picks its Shiki engine at RUNTIME: + * + * engine: preferredHighlighter === "shiki-wasm" + * ? createOnigurumaEngine(import("shiki/wasm")) + * : createJavaScriptRegexEngine() + * + * (`dist/highlighter/shared_highlighter.js` on the main thread and + * `dist/worker/worker.js` inside the inlined worker). Plannotator pins + * `preferredHighlighter: 'shiki-js'` everywhere — see + * `packages/review-editor/workerPool.tsx` — and Pierre's own default is + * `'shiki-js'`, so the Oniguruma branch never executes. But because the choice + * is a runtime ternary, the bundler keeps the `import("shiki/wasm")` edge and + * inlines `@shikijs/engine-oniguruma/wasm-inlined` — a ~622 KB base64 blob — + * into every single-file HTML build (twice in the review app: once on the main + * thread, once in the inlined worker). + * + * Aliasing `shiki/wasm` to this module drops that payload. The JS regex engine + * and the WASM engine were verified to produce identical tokens, so nothing + * about the rendered output changes; the only thing that changes is that + * opting into `'shiki-wasm'` now fails loudly instead of silently costing every + * user a megabyte of dead bytes. + * + * Wired through `resolve.alias` (NOT a plugin) on purpose: `resolve.alias` is + * shared with Vite's worker build, `plugins` are not. + */ + +function unavailable(): never { + throw new Error( + "shiki/wasm is not bundled by Plannotator: the Oniguruma engine is stubbed out " + + "in favour of Shiki's JavaScript regex engine (preferredHighlighter: 'shiki-js'). " + + 'Remove the `shiki/wasm` alias in the app vite config to re-enable it.', + ); +} + +export default unavailable; diff --git a/bun.lock b/bun.lock index a9bce8c66..36178b69e 100644 --- a/bun.lock +++ b/bun.lock @@ -63,7 +63,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.25.1", + "version": "0.26.1", "devDependencies": { "@opencode-ai/plugin": "0.0.0-next-16775", "@plannotator/server": "workspace:*", @@ -80,7 +80,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.25.1", + "version": "0.26.1", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "1.3.2", @@ -219,7 +219,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.25.1", + "version": "0.26.1", "dependencies": { "@pierre/diffs": "1.3.2", "@plannotator/ai": "workspace:*", From c8c6554eb07420959b08726ac085edf6d2e03012 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 5 Aug 2026 21:07:54 -0700 Subject: [PATCH 2/4] perf(ui): consolidate code highlighting onto Shiki, drop highlight.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app shipped two highlighters. Shiki already tokenised the code-review diff pane (via @pierre/diffs, JavaScript regex engine); highlight.js separately coloured markdown fences and review suggestion snippets at ~982 KB minified for a full build of ~190 grammars. That second highlighter is now gone. Every call site moves onto `packages/ui/utils/codeHighlight.ts`, a thin wrapper over Pierre's SHARED Shiki instance: CodeBlock, Viewer, PlanCleanDiffView markdown fences InlineMarkdown code-file hover preview HighlightedCode review suggestion snippets Reusing Pierre's instance rather than standing up a second fine-grained one is deliberate. Pierre imports Shiki's full bundle, so every grammar and theme is ALREADY inlined in the single-file builds: a separate highlighter with a curated language list would have duplicated a subset of bytes that are already there. Sharing costs nothing, gives every language Shiki bundles instead of a shortlist, and — the point of the change — guarantees fences resolve the exact same theme the diff pane resolves. Theming. `SHIKI_THEME_MAP` / `resolveSyntaxTheme` move from `packages/review-editor/hooks/usePierreTheme.ts` to `packages/ui/utils/syntaxTheme.ts`; usePierreTheme re-exports them, so the review editor's imports are unchanged. `useFenceTheme()` feeds the components and re-highlights on palette or mode change. Code blocks now follow the active palette across all ~52 themes in both light and dark, instead of always rendering github-dark and relying on hand-written `.hljs-*` override stacks to stay legible. Those stacks are deleted: `packages/editor/index.css`'s light-mode token palette, and `colorblind.css`'s hand-tuned tokens which existed to APPROXIMATE @pierre/theme's protanopia-deuteranopia themes that are now simply used. Behaviour held fixed: - Language-less fences stay plain text (#1212). No auto-detection anywhere, including the hover preview, which previously called `hljs.highlightAuto`. `HighlightedCode` derives its language from the caller's file path; an unknown extension renders plain. - `applyHighlight(el, ...)` keeps the imperative `hljs.highlightElement` DOM contract the annotation layer reaches into, and writes plain text at final size first so async highlighting causes no layout shift. Already-attached grammars highlight synchronously — no flicker on cached highlights. - It also verifies the rendered text is byte-identical to the source and falls back to plain otherwise, because annotations address code blocks by text offset. - `@plannotator/ui`'s public API is unchanged: the highlighter is a module-level default like the package's other seams, no new props. The `hljs` class on fenced `` becomes `pn-code` (it is a structural hook for blockTargeting, vim navigation and print.css, and it named a library we no longer ship). `language-*` stays. apps/review/dist/index.html 18,180,545 -> 17,270,889 (-909,656 raw / -291,921 gzip) apps/hook/dist/index.html 22,410,416 -> 21,704,434 (-705,982 raw / -238,096 gzip) Verified the diff pane is untouched: the rendered Pierre shadow-DOM markup is byte-for-byte identical between an origin/main build and this one (SHA-256 aa1ee88a…). --- AGENTS.md | 12 +- bun.lock | 6 +- packages/editor/index.css | 74 ++---------- .../components/HighlightedCode.tsx | 20 ++-- .../review-editor/hooks/usePierreTheme.ts | 52 ++------ packages/review-editor/index.css | 6 +- packages/review-editor/package.json | 1 - .../review-editor/utils/detectLanguage.ts | 2 +- packages/ui/components/GraphvizBlock.tsx | 2 +- packages/ui/components/InlineMarkdown.tsx | 43 +++++-- packages/ui/components/MermaidBlock.tsx | 2 +- packages/ui/components/Viewer.tsx | 18 +-- packages/ui/components/blocks/CodeBlock.tsx | 22 ++-- .../plan-diff/PlanCleanDiffView.tsx | 21 ++-- packages/ui/globals.d.ts | 2 +- packages/ui/hooks/useFenceTheme.ts | 17 +++ packages/ui/hooks/useVimSelection.test.tsx | 2 +- packages/ui/package.json | 1 - packages/ui/print.css | 27 +++-- packages/ui/themes/colorblind.css | 112 +----------------- packages/ui/utils/blockTargeting.ts | 6 +- packages/ui/utils/codeHighlight.test.ts | 73 ++++++++++++ packages/ui/utils/codeHighlight.ts | Bin 0 -> 8331 bytes packages/ui/utils/syntaxTheme.ts | 83 +++++++++++++ packages/ui/utils/vimNavigation.test.ts | 2 +- tests/entry-assets.test.ts | 27 ++++- 26 files changed, 330 insertions(+), 303 deletions(-) create mode 100644 packages/ui/hooks/useFenceTheme.ts create mode 100644 packages/ui/utils/codeHighlight.test.ts create mode 100644 packages/ui/utils/codeHighlight.ts create mode 100644 packages/ui/utils/syntaxTheme.ts diff --git a/AGENTS.md b/AGENTS.md index ea4e1dc20..1afb6405c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -609,7 +609,17 @@ Uses cookies (not localStorage) because each hook invocation runs on a random po ## Syntax Highlighting -Code blocks use bundled `highlight.js`. Language is extracted from fence (```rust) and applied as `language-{lang}`class. Each block highlighted individually via`hljs.highlightElement()`. +There is **one** highlighter in the app: the Shiki instance `@pierre/diffs` already runs for the code-review diff pane, driven by Shiki's **JavaScript regex engine** (`preferredHighlighter: 'shiki-js'`). `highlight.js` is gone. The wrapper is `packages/ui/utils/codeHighlight.ts`: + +- `applyHighlight(el, code, lang, theme)` — imperative drop-in for the old `hljs.highlightElement(el)`. Writes plain text immediately (final size on first paint, no layout shift), then swaps in highlighted markup once the grammar is attached; already-attached grammars highlight synchronously, so there is no flicker on cached highlights. It also enforces that the rendered text is byte-identical to the source and falls back to plain text otherwise, because the annotation layer addresses code blocks by text offset. +- `highlightToHtml(code, lang, theme)` / `ensureHighlight(lang, theme)` — the sync/async pair behind it, for callers that need HTML strings (the code-file hover preview). +- `codeBlockClassName(lang)` — the `pn-code font-mono language-{lang}` class every fenced `` carries. **`pn-code` replaced the old `hljs` class** and is the structural hook `blockTargeting`, vim navigation and `print.css` use (`pre > code.pn-code`); `language-*` is how `blockTargeting` reads a block's language back out of the DOM. + +**Language-less fences render as plain text and are never guessed at (#1212). There is no auto-detection anywhere.** `HighlightedCode` (review suggestions) derives its language from the caller's file path via `detectLanguage`; an unrecognised extension renders plain. + +**Theming:** fences resolve the SAME theme the diff pane resolves, via `resolveFenceTheme` / `resolveSyntaxTheme` in `packages/ui/utils/syntaxTheme.ts` (keyed on `(colorTheme, resolvedMode)`; `packages/review-editor/hooks/usePierreTheme.ts` re-exports them). `useFenceTheme()` (`packages/ui/hooks/useFenceTheme.ts`) feeds the components and re-highlights on palette or mode change. Palettes with no Shiki counterpart fall back to `@pierre/diffs`' own `pierre-dark` / `pierre-light`. Consequence: code blocks follow the active palette in both light and dark instead of always rendering github-dark, so **do not add per-theme `.hljs-*`-style token CSS** — pick the right Shiki theme in `SHIKI_THEME_MAP` instead. + +**Bundle note:** Pierre imports Shiki's full bundle, so every grammar and theme is already inlined in the single-file builds; reusing its shared highlighter costs no extra bytes and needs no CDN or runtime wasm fetch. The Oniguruma WASM engine is dead weight under `shiki-js` and is aliased to `build/shiki-wasm-stub.ts` in the review, hook and portal Vite configs (via `resolve.alias`, which — unlike `plugins` — is shared with Vite's worker build). ## Requirements diff --git a/bun.lock b/bun.lock index 36178b69e..271e2c146 100644 --- a/bun.lock +++ b/bun.lock @@ -203,7 +203,6 @@ "@pierre/diffs": "1.3.2", "@plannotator/shared": "workspace:*", "@plannotator/ui": "workspace:*", - "highlight.js": "^11.11.1", "lucide-react": "^1.14.0", "marked": "^17.0.6", "motion": "^12.38.0", @@ -278,7 +277,6 @@ "clsx": "^2.1.1", "diff": "^8.0.4", "dompurify": "^3.3.3", - "highlight.js": "^11.11.1", "katex": "^0.16.47", "lucide-react": "^1.14.0", "marked": "^17.0.6", @@ -1716,7 +1714,7 @@ "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], @@ -2642,8 +2640,6 @@ "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], - "@earendil-works/pi-coding-agent/highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], - "@earendil-works/pi-tui/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "@google/genai/google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="], diff --git a/packages/editor/index.css b/packages/editor/index.css index 33e8eb81b..c53a9d518 100644 --- a/packages/editor/index.css +++ b/packages/editor/index.css @@ -40,8 +40,14 @@ width: 0 !important; } -/* Code blocks */ -pre code.hljs { +/* Code blocks. + * + * Token colours come from the active Shiki theme as inline styles (the same + * theme the code-review diff pane resolves), so this rule only owns layout and + * the block background. The old `.hljs-*` override stacks that existed to make + * highlight.js's hardcoded github-dark palette survive light mode are gone with + * it: a light palette now resolves a light Shiki theme. */ +pre code.pn-code { display: block; padding: 1rem; border-radius: var(--radius); @@ -50,71 +56,13 @@ pre code.hljs { line-height: 1.6; } -/* Fix: hljs markdown grammar applies github-dark token colors that are unreadable - in light mode. Emphasis/strong/code tokens use #c9d1d9 or #8b949e which wash out - against light backgrounds. Force them to inherit the base code color. */ -pre code.hljs .hljs-emphasis { - color: inherit !important; - font-style: normal !important; -} -pre code.hljs .hljs-strong { - color: inherit !important; -} -pre code.hljs .hljs-code { - color: inherit !important; -} - -/* Light mode code - override hljs dark theme */ -.light pre code.hljs { - color: oklch(0.25 0.02 260) !important; -} - -/* The code-file hover preview is a
, so the pre code.hljs - rule above misses it. The unscoped .hljs-* token rules below already color - its tokens; this just supplies the matching default text color. */ +/* The code-file hover preview is not a
, so the rule above misses
+   it. Its tokens carry their own theme colours; this supplies the default text
+   colour for the parts that have none. */
 .light .code-snippet-preview {
   color: oklch(0.25 0.02 260) !important;
 }
 
-.light .hljs-keyword,
-.light .hljs-selector-tag,
-.light .hljs-built_in,
-.light .hljs-name,
-.light .hljs-tag {
-  color: oklch(0.45 0.25 280) !important;
-}
-
-.light .hljs-string,
-.light .hljs-title,
-.light .hljs-section,
-.light .hljs-attribute,
-.light .hljs-literal,
-.light .hljs-template-tag,
-.light .hljs-template-variable,
-.light .hljs-type {
-  color: oklch(0.45 0.18 150) !important;
-}
-
-.light .hljs-comment,
-.light .hljs-quote {
-  color: oklch(0.55 0.02 260) !important;
-  font-style: italic;
-}
-
-.light .hljs-number,
-.light .hljs-symbol,
-.light .hljs-bullet {
-  color: oklch(0.50 0.20 50) !important;
-}
-
-.light .hljs-attr,
-.light .hljs-variable,
-.light .hljs-template-variable,
-.light .hljs-class .hljs-title,
-.light .hljs-function {
-  color: oklch(0.45 0.20 280) !important;
-}
-
 /* Annotation highlights moved to packages/ui/theme.css (shared with the
    code-review description annotations). */
 
diff --git a/packages/review-editor/components/HighlightedCode.tsx b/packages/review-editor/components/HighlightedCode.tsx
index 28c5b51b2..e400b7bd3 100644
--- a/packages/review-editor/components/HighlightedCode.tsx
+++ b/packages/review-editor/components/HighlightedCode.tsx
@@ -1,19 +1,25 @@
 import React, { useRef, useEffect } from 'react';
-import hljs from 'highlight.js';
-import 'highlight.js/styles/github-dark.css';
+import { applyHighlight } from '@plannotator/ui/utils/codeHighlight';
+import { useFenceTheme } from '@plannotator/ui/hooks/useFenceTheme';
 
-/** Renders a single highlighted code element using highlight.js */
+/**
+ * A single highlighted code element, rendered by the same Shiki instance and in
+ * the same resolved theme as the diff pane next to it.
+ *
+ * `language` comes from the caller's file path (`detectLanguage`) — there is no
+ * auto-detection, so a snippet whose file type we do not recognise renders as
+ * plain text rather than being guessed at.
+ */
 export const HighlightedCode: React.FC<{ code: string; language?: string }> = ({ code, language }) => {
   const codeRef = useRef(null);
+  const fenceTheme = useFenceTheme();
 
   useEffect(() => {
     if (codeRef.current) {
-      codeRef.current.removeAttribute('data-highlighted');
       codeRef.current.className = language ? `language-${language}` : '';
-      codeRef.current.textContent = code;
-      hljs.highlightElement(codeRef.current);
+      applyHighlight(codeRef.current, code, language, fenceTheme);
     }
-  }, [code, language]);
+  }, [code, language, fenceTheme]);
 
   return {code};
 };
diff --git a/packages/review-editor/hooks/usePierreTheme.ts b/packages/review-editor/hooks/usePierreTheme.ts
index 8bf6743d4..8d9d37f26 100644
--- a/packages/review-editor/hooks/usePierreTheme.ts
+++ b/packages/review-editor/hooks/usePierreTheme.ts
@@ -3,50 +3,14 @@ import type { DiffLineBgIntensity } from '@plannotator/shared/config';
 import { useTheme } from '@plannotator/ui/components/ThemeProvider';
 import { useConfigValue } from '@plannotator/ui/config';
 
-export const SHIKI_THEME_MAP: Record = {
-  'andromeeda': { dark: 'andromeeda', light: null },
-  'aurora-x': { dark: 'aurora-x', light: null },
-  'ayu-dark': { dark: 'ayu-dark', light: null },
-  'catppuccin': { dark: 'catppuccin-mocha', light: 'catppuccin-latte' },
-  'colorblind': { dark: 'pierre-dark-protanopia-deuteranopia', light: 'pierre-light-protanopia-deuteranopia' },
-  'dark-plus': { dark: 'dark-plus', light: 'light-plus' },
-  'dracula': { dark: 'dracula', light: null },
-  'everforest': { dark: 'everforest-dark', light: 'everforest-light' },
-  'everforest-hard': { dark: 'everforest-dark', light: 'everforest-light' },
-  'everforest-soft': { dark: 'everforest-dark', light: 'everforest-light' },
-  'github': { dark: 'github-dark', light: 'github-light' },
-  'gruvbox': { dark: 'gruvbox-dark-medium', light: 'gruvbox-light-medium' },
-  'houston': { dark: 'houston', light: null },
-  'kanagawa-dragon': { dark: 'kanagawa-dragon', light: null },
-  'kanagawa-lotus': { dark: null, light: 'kanagawa-lotus' },
-  'kanagawa-wave': { dark: 'kanagawa-wave', light: null },
-  'laserwave': { dark: 'laserwave', light: null },
-  'material': { dark: 'material-theme', light: 'material-theme-lighter' },
-  'min': { dark: 'min-dark', light: 'min-light' },
-  'monokai-pro': { dark: 'monokai', light: null },
-  'night-owl': { dark: 'night-owl', light: null },
-  'nord': { dark: 'nord', light: null },
-  'one-dark-pro': { dark: 'one-dark-pro', light: null },
-  'one-light': { dark: null, light: 'one-light' },
-  'plastic': { dark: 'plastic', light: null },
-  'poimandres': { dark: 'poimandres', light: null },
-  'red': { dark: 'red', light: null },
-  'rose-pine': { dark: 'rose-pine', light: 'rose-pine-dawn' },
-  'slack': { dark: 'slack-dark', light: 'slack-ochin' },
-  'snazzy-light': { dark: null, light: 'snazzy-light' },
-  'solarized': { dark: 'solarized-dark', light: 'solarized-light' },
-  'synthwave-84': { dark: 'synthwave-84', light: null },
-  'tokyo-night': { dark: 'tokyo-night', light: null },
-  'vesper': { dark: 'vesper', light: null },
-  'vitesse': { dark: 'vitesse-dark', light: 'vitesse-light' },
-  'vitesse-black': { dark: 'vitesse-black', light: null },
-};
-
-export function resolveSyntaxTheme(colorTheme: string, mode: 'dark' | 'light'): { dark: string; light: string } | undefined {
-  const map = SHIKI_THEME_MAP[colorTheme];
-  if (!map || !map[mode]) return undefined;
-  return { dark: map.dark || 'pierre-dark', light: map.light || 'pierre-light' };
-}
+/**
+ * The (colorTheme, mode) -> Shiki theme mapping moved to
+ * `@plannotator/ui/utils/syntaxTheme` so the plan editor's markdown fences
+ * resolve the same theme this diff pane does. Re-exported here because it is
+ * the import path the review editor has always used.
+ */
+import { resolveSyntaxTheme } from '@plannotator/ui/utils/syntaxTheme';
+export { SHIKI_THEME_MAP, resolveSyntaxTheme } from '@plannotator/ui/utils/syntaxTheme';
 
 export interface PierreTheme {
   type: 'dark' | 'light';
diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css
index f4e5cbb22..fe4a68193 100644
--- a/packages/review-editor/index.css
+++ b/packages/review-editor/index.css
@@ -1530,9 +1530,9 @@ diffs-container {
   animation: overlay-fade-in 0.15s ease-out both;
 }
 
-/* Code navigation peek panel — strip hljs hardcoded background */
+/* Code navigation peek panel — strip the syntax theme's block background */
 .code-nav-peek code,
-.code-nav-peek .hljs {
+.code-nav-peek .pn-code {
   background: transparent !important;
 }
 
@@ -1617,7 +1617,7 @@ diffs-container {
    tighten the box. `.my-5` is unique to the code-block wrapper in this renderer. */
 .md-compact .my-5 { margin-top: 0.625rem; margin-bottom: 0.625rem; } /* was 20px */
 .md-compact pre { font-size: 0.75rem; }                              /* 12px, under the body */
-.md-compact pre code.hljs { padding: 0.5rem 0.625rem; }              /* tighter than hljs default */
+.md-compact pre code.pn-code { padding: 0.5rem 0.625rem; }           /* tighter than the 1rem default */
 
 /* PR description + comment media: cap wide screenshots/videos to the card width
    (GitHub embeds them at their natural size, e.g. width="1440"), and wrap long
diff --git a/packages/review-editor/package.json b/packages/review-editor/package.json
index daf4e43d5..279a6e494 100644
--- a/packages/review-editor/package.json
+++ b/packages/review-editor/package.json
@@ -15,7 +15,6 @@
     "@pierre/diffs": "1.3.2",
     "@plannotator/shared": "workspace:*",
     "@plannotator/ui": "workspace:*",
-    "highlight.js": "^11.11.1",
     "lucide-react": "^1.14.0",
     "marked": "^17.0.6",
     "motion": "^12.38.0",
diff --git a/packages/review-editor/utils/detectLanguage.ts b/packages/review-editor/utils/detectLanguage.ts
index a0ad37e90..699f4c70c 100644
--- a/packages/review-editor/utils/detectLanguage.ts
+++ b/packages/review-editor/utils/detectLanguage.ts
@@ -1,4 +1,4 @@
-/** Map file extension to highlight.js language name */
+/** Map file extension to a Shiki language name (undefined = render plain) */
 export function detectLanguage(filePath: string): string | undefined {
   const ext = filePath.split('.').pop()?.toLowerCase();
   const map: Record = {
diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx
index b84de1202..1752fbb0a 100644
--- a/packages/ui/components/GraphvizBlock.tsx
+++ b/packages/ui/components/GraphvizBlock.tsx
@@ -458,7 +458,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
 
   const inlineSource = (
     
-      {block.content}
+      {block.content}
     
); diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index d6b2a3ffc..e6403f02c 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,7 +1,8 @@ import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; -import hljs from "highlight.js"; import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file"; +import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight"; +import { useFenceTheme } from "../hooks/useFenceTheme"; import { transformPlainText } from "../utils/inlineTransforms"; import { getImageSrc } from "./ImageThumbnail"; import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext"; @@ -93,18 +94,34 @@ const CodeSnippetPreview: React.FC<{ const end = Math.min(allLines.length, (lineEnd ?? line)); const snippet = allLines.slice(start, end).join('\n'); - const highlightedLines = useMemo(() => { - const lang = extToLanguage(filepath); - const lines = snippet.split('\n'); - return lines.map(line => { - try { - if (lang) return hljs.highlight(line, { language: lang }).value; - return hljs.highlightAuto(line).value; - } catch { - return line.replace(/&/g, '&').replace(//g, '>'); - } + const fenceTheme = useFenceTheme(); + // Bumped once the grammar is attached, to re-run the memo below with the + // highlighter warm. Until then the snippet renders as plain text — no + // auto-detection, and an unknown extension simply stays plain. + const [highlighterGeneration, setHighlighterGeneration] = useState(0); + const lang = extToLanguage(filepath); + + useEffect(() => { + if (!lang) return; + let cancelled = false; + void ensureHighlight(lang, fenceTheme).then((ok) => { + if (ok && !cancelled) setHighlighterGeneration((n) => n + 1); }); - }, [snippet, filepath]); + return () => { cancelled = true; }; + }, [lang, fenceTheme]); + + const highlightedLines = useMemo(() => { + // Highlight the snippet as one unit so multi-line constructs (block + // comments, template literals) tokenise correctly, then split back into + // rows: the highlighter joins lines with "\n" and never emits one inside a + // span, so the split is exact. + const html = lang ? highlightToHtml(snippet, lang, fenceTheme) : null; + if (html !== null) return html.split('\n'); + return snippet.split('\n').map(line => + line.replace(/&/g, '&').replace(//g, '>')); + // highlighterGeneration is the "grammar just became available" signal. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [snippet, lang, fenceTheme, highlighterGeneration]); if (!anchorEl) return null; @@ -126,7 +143,7 @@ const CodeSnippetPreview: React.FC<{ {filepath.split('/').pop()} {lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}
-
+
{snippet.split('\n').map((_, i) => ( diff --git a/packages/ui/components/MermaidBlock.tsx b/packages/ui/components/MermaidBlock.tsx index f67e61d9d..a896dbf66 100644 --- a/packages/ui/components/MermaidBlock.tsx +++ b/packages/ui/components/MermaidBlock.tsx @@ -523,7 +523,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => { const inlineSource = (
-      {block.content}
+      {block.content}
     
); diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 1a1b39e6a..94eff1ba5 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -1,7 +1,8 @@ import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import hljs from 'highlight.js'; import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types'; +import { applyHighlight, codeBlockClassName } from '../utils/codeHighlight'; +import { useFenceTheme } from '../hooks/useFenceTheme'; import { computeListIndices, groupBlocks, type Frontmatter } from '../utils/parser'; import { buildHeadingSlugMap } from '../utils/slugify'; import { copyTextToClipboard } from '../utils/clipboard'; @@ -238,6 +239,11 @@ export const Viewer = forwardRef(({ const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); const [locationHash, setLocationHash] = useState(() => window.location.hash); const globalCommentButtonRef = useRef(null); + // Read through a ref: only the imperative removeHighlight path below needs + // it, and CodeBlock re-highlights itself on palette change. + const fenceTheme = useFenceTheme(); + const fenceThemeRef = useRef(fenceTheme); + fenceThemeRef.current = fenceTheme; const handleCopyPlan = async () => { if (await copyTextToClipboard(markdown)) { @@ -628,13 +634,9 @@ export const Viewer = forwardRef(({ el.remove(); codeEl.textContent = plainText; const block = blocks.find(b => b.id === codeEl.closest('[data-block-id]')?.getAttribute('data-block-id')); - codeEl.removeAttribute('data-highlighted'); - codeEl.className = `hljs font-mono${block?.language ? ` language-${block.language}` : ''}`; - // Skip highlighting language-less fences so highlight.js doesn't - // auto-detect a language and color plain text. - if (block?.language) { - hljs.highlightElement(codeEl); - } + codeEl.className = codeBlockClassName(block?.language); + // Language-less fences stay plain (#1212) — applyHighlight never guesses. + applyHighlight(codeEl, plainText, block?.language, fenceThemeRef.current); } }); diff --git a/packages/ui/components/blocks/CodeBlock.tsx b/packages/ui/components/blocks/CodeBlock.tsx index 33feb63ee..90b86fc6a 100644 --- a/packages/ui/components/blocks/CodeBlock.tsx +++ b/packages/ui/components/blocks/CodeBlock.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; -import hljs from 'highlight.js'; -import 'highlight.js/styles/github-dark.css'; import type { Block } from '../../types'; import { copyTextToClipboard } from '../../utils/clipboard'; +import { applyHighlight, codeBlockClassName } from '../../utils/codeHighlight'; +import { useFenceTheme } from '../../hooks/useFenceTheme'; interface CodeBlockProps { block: Block; @@ -15,20 +15,16 @@ export const CodeBlock: React.FC = ({ block, onHover, onLeave }) const [copied, setCopied] = useState(false); const containerRef = useRef(null); const codeRef = useRef(null); + const fenceTheme = useFenceTheme(); - // Highlight code block on mount and when content/language changes. - // Skip highlighting for language-less fences so highlight.js doesn't - // auto-detect a language and color plain text. + // Highlight on mount, on content/language change, and whenever the palette + // changes. Language-less fences stay plain text (#1212) — nothing is guessed. useEffect(() => { if (codeRef.current) { - // Reset any previous highlighting - codeRef.current.removeAttribute('data-highlighted'); - codeRef.current.className = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`; - if (block.language) { - hljs.highlightElement(codeRef.current); - } + codeRef.current.className = codeBlockClassName(block.language); + applyHighlight(codeRef.current, block.content, block.language, fenceTheme); } - }, [block.content, block.language]); + }, [block.content, block.language, fenceTheme]); const handleCopy = useCallback(async () => { if (await copyTextToClipboard(block.content)) { @@ -46,7 +42,7 @@ export const CodeBlock: React.FC = ({ block, onHover, onLeave }) }; // Build className for code element - const codeClassName = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`; + const codeClassName = codeBlockClassName(block.language); return (
= ({ block }) => { const codeRef = useRef(null); + const fenceTheme = useFenceTheme(); useEffect(() => { if (codeRef.current) { - codeRef.current.removeAttribute("data-highlighted"); - codeRef.current.className = `hljs font-mono${block.language ? ` language-${block.language}` : ""}`; - // Skip highlighting language-less fences so highlight.js doesn't - // auto-detect a language and color plain text. - if (block.language) { - hljs.highlightElement(codeRef.current); - } + codeRef.current.className = codeBlockClassName(block.language); + // Language-less fences stay plain (#1212) — applyHighlight never guesses. + applyHighlight(codeRef.current, block.content, block.language, fenceTheme); } - }, [block.content, block.language]); + }, [block.content, block.language, fenceTheme]); return (
-        
+        
           {block.content}
         
       
diff --git a/packages/ui/globals.d.ts b/packages/ui/globals.d.ts index 978d0ca64..261285a2c 100644 --- a/packages/ui/globals.d.ts +++ b/packages/ui/globals.d.ts @@ -1,4 +1,4 @@ -// Allow side-effect CSS imports (highlight.js themes, overlayscrollbars, etc.) +// Allow side-effect CSS imports (overlayscrollbars, fontsource, etc.) declare module '*.css'; // Image asset imports (sprites, screenshots). Consumers compiling this shipped diff --git a/packages/ui/hooks/useFenceTheme.ts b/packages/ui/hooks/useFenceTheme.ts new file mode 100644 index 000000000..f9f829bfe --- /dev/null +++ b/packages/ui/hooks/useFenceTheme.ts @@ -0,0 +1,17 @@ +import { useTheme } from '../components/ThemeProvider'; +import { resolveFenceTheme } from '../utils/syntaxTheme'; + +/** + * The Shiki theme name that code snippets should render in right now. + * + * Same (colorTheme, mode) resolution the code-review diff pane uses, so fences, + * suggestion cards and diff hunks all agree. Re-renders on palette or mode + * change, which is what drives the re-highlight in the components below. + * + * `ThemeProvider`'s default context supplies the Plannotator palette in dark + * mode, so this is safe to call outside a provider. + */ +export function useFenceTheme(): string { + const { colorTheme, resolvedMode } = useTheme(); + return resolveFenceTheme(colorTheme, resolvedMode ?? 'dark'); +} diff --git a/packages/ui/hooks/useVimSelection.test.tsx b/packages/ui/hooks/useVimSelection.test.tsx index 296cb759c..f5fc620f2 100644 --- a/packages/ui/hooks/useVimSelection.test.tsx +++ b/packages/ui/hooks/useVimSelection.test.tsx @@ -55,7 +55,7 @@ function VimHarness({
-
const x = 1;
+
const x = 1;
Native link diff --git a/packages/ui/package.json b/packages/ui/package.json index a0cd59c90..4f6ace75e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -77,7 +77,6 @@ "clsx": "^2.1.1", "diff": "^8.0.4", "dompurify": "^3.3.3", - "highlight.js": "^11.11.1", "katex": "^0.16.47", "lucide-react": "^1.14.0", "marked": "^17.0.6", diff --git a/packages/ui/print.css b/packages/ui/print.css index e37823c43..05b514546 100644 --- a/packages/ui/print.css +++ b/packages/ui/print.css @@ -4,7 +4,8 @@ * 1. @media print — standard print styles * 2. .plannotator-print — class added via JS beforeprint/afterprint events * to guarantee overrides that @media print alone cannot achieve - * (e.g. beating Tailwind layers + hljs github-dark theme). + * (e.g. beating Tailwind layers, and the syntax theme's per-token inline + * colours — inline styles only lose to an !important author rule). */ /* ============================================================ @@ -12,7 +13,7 @@ * These use .plannotator-print on for maximum specificity. * ============================================================ */ -/* Code blocks: override github-dark.css .hljs{background:#0d1117} */ +/* Code blocks: flatten the syntax theme's dark block background to paper */ .plannotator-print pre, .plannotator-print pre[class] { background: #f5f5f5 !important; @@ -23,18 +24,19 @@ } .plannotator-print pre code, -.plannotator-print code.hljs, -.plannotator-print pre code.hljs, -.plannotator-print .hljs { +.plannotator-print code.pn-code, +.plannotator-print pre code.pn-code, +.plannotator-print .code-snippet-preview { background: transparent !important; background-color: transparent !important; color: #1a1a1a !important; } +/* The syntax theme colours every token with an inline `style`, so these + !important rules are what flattens code to black on paper. */ .plannotator-print pre span, .plannotator-print pre code span, -.plannotator-print .hljs span, -.plannotator-print [class*="hljs-"] { +.plannotator-print .code-snippet-preview span { color: #1a1a1a !important; background: transparent !important; background-color: transparent !important; @@ -284,9 +286,8 @@ pre code, pre code[class], - pre code.hljs, - code.hljs, - code[data-highlighted] { + pre code.pn-code, + code.pn-code { font-size: 9pt !important; background: transparent !important; background-color: transparent !important; @@ -298,14 +299,14 @@ word-wrap: break-word !important; } - .hljs { + .code-snippet-preview { background: transparent !important; background-color: transparent !important; color: #1a1a1a !important; } - pre span, pre code span, .hljs span, code span, - [class*="hljs-"] { + pre span, pre code span, code span, + .code-snippet-preview span { color: #1a1a1a !important; background: transparent !important; background-color: transparent !important; diff --git a/packages/ui/themes/colorblind.css b/packages/ui/themes/colorblind.css index a233d8c73..12c4ef9e4 100644 --- a/packages/ui/themes/colorblind.css +++ b/packages/ui/themes/colorblind.css @@ -81,111 +81,9 @@ --diffs-deletion-color-override: #a55c1e; } -/* Plan-editor code blocks (bundled highlight.js, github-dark base stylesheet). - * Token palette mirrors @pierre/theme's protanopia-deuteranopia shiki themes: - * purples/blues for keywords, blue for strings and numbers, orange reserved - * for variables/tags — no red-vs-green token pairs anywhere. !important is - * required to beat the app-level `.light .hljs-*` override rules. */ -.theme-colorblind .hljs-keyword, -.theme-colorblind .hljs-selector-tag, -.theme-colorblind .hljs-doctag { - color: #b969f3 !important; -} -.theme-colorblind .hljs-string, -.theme-colorblind .hljs-quote, -.theme-colorblind .hljs-regexp { - color: #97c4ff !important; -} -.theme-colorblind .hljs-number, -.theme-colorblind .hljs-literal, -.theme-colorblind .hljs-symbol, -.theme-colorblind .hljs-bullet { - color: #96d9f6 !important; -} -.theme-colorblind .hljs-title, -.theme-colorblind .hljs-section, -.theme-colorblind .hljs-function, -.theme-colorblind .hljs-class .hljs-title { - color: #ba8ffd !important; -} -.theme-colorblind .hljs-type, -.theme-colorblind .hljs-built_in, -.theme-colorblind .hljs-builtin-name, -.theme-colorblind .hljs-selector-class, -.theme-colorblind .hljs-selector-pseudo { - color: #e290f0 !important; -} -.theme-colorblind .hljs-variable, -.theme-colorblind .hljs-template-variable, -.theme-colorblind .hljs-attr, -.theme-colorblind .hljs-attribute, -.theme-colorblind .hljs-name, -.theme-colorblind .hljs-tag, -.theme-colorblind .hljs-selector-id, -.theme-colorblind .hljs-template-tag { - color: #ffa359 !important; -} -.theme-colorblind .hljs-comment, -.theme-colorblind .hljs-meta { - color: #9198a1 !important; -} -.theme-colorblind .hljs-addition { - color: #97c4ff !important; - background-color: rgb(79 131 209 / 0.18) !important; -} -.theme-colorblind .hljs-deletion { - color: #ffa359 !important; - background-color: rgb(201 110 18 / 0.18) !important; -} +/* Code-block token colours are no longer hand-written here. Plan-editor fences + * are highlighted by the same Shiki instance, in the same resolved theme, as + * the code-review diff pane — for this palette that is @pierre/theme's + * `pierre-{dark,light}-protanopia-deuteranopia`, which is exactly what the + * removed highlight.js `.hljs-*` overrides were approximating by hand. */ -.theme-colorblind.light .hljs-keyword, -.theme-colorblind.light .hljs-selector-tag, -.theme-colorblind.light .hljs-doctag { - color: #8836c7 !important; -} -.theme-colorblind.light .hljs-string, -.theme-colorblind.light .hljs-quote, -.theme-colorblind.light .hljs-regexp { - color: #215584 !important; -} -.theme-colorblind.light .hljs-number, -.theme-colorblind.light .hljs-literal, -.theme-colorblind.light .hljs-symbol, -.theme-colorblind.light .hljs-bullet { - color: #2182a1 !important; -} -.theme-colorblind.light .hljs-title, -.theme-colorblind.light .hljs-section, -.theme-colorblind.light .hljs-function, -.theme-colorblind.light .hljs-class .hljs-title { - color: #5731a7 !important; -} -.theme-colorblind.light .hljs-type, -.theme-colorblind.light .hljs-built_in, -.theme-colorblind.light .hljs-builtin-name, -.theme-colorblind.light .hljs-selector-class, -.theme-colorblind.light .hljs-selector-pseudo { - color: #a631be !important; -} -.theme-colorblind.light .hljs-variable, -.theme-colorblind.light .hljs-template-variable, -.theme-colorblind.light .hljs-attr, -.theme-colorblind.light .hljs-attribute, -.theme-colorblind.light .hljs-name, -.theme-colorblind.light .hljs-tag, -.theme-colorblind.light .hljs-selector-id, -.theme-colorblind.light .hljs-template-tag { - color: #ac6023 !important; -} -.theme-colorblind.light .hljs-comment, -.theme-colorblind.light .hljs-meta { - color: #6b7280 !important; -} -.theme-colorblind.light .hljs-addition { - color: #215584 !important; - background-color: rgb(33 108 171 / 0.12) !important; -} -.theme-colorblind.light .hljs-deletion { - color: #ac6023 !important; - background-color: rgb(165 92 30 / 0.12) !important; -} diff --git a/packages/ui/utils/blockTargeting.ts b/packages/ui/utils/blockTargeting.ts index bf08f8b90..99e9c3ab1 100644 --- a/packages/ui/utils/blockTargeting.ts +++ b/packages/ui/utils/blockTargeting.ts @@ -15,7 +15,7 @@ const SKIP_SELECTORS = [ '[data-pinpoint-ignore]', ].join(','); -const INLINE_TARGET_SELECTOR = 'strong,em,a,code:not(.hljs)'; +const INLINE_TARGET_SELECTOR = 'strong,em,a,code:not(.pn-code)'; const TABLE_EDGE_ZONE = 22; /** The semantic kind of a document target. */ @@ -198,7 +198,7 @@ export function buildSemanticTargetGraph(container: HTMLElement): SemanticTarget const group = block.closest('[data-pinpoint-group]'); const parentKey = group ? groupTargets.get(group)?.key ?? null : null; - const codeElement = block.querySelector('pre > code.hljs'); + const codeElement = block.querySelector('pre > code.pn-code'); const mathElement = block.matches('.math-annotatable,[data-math-tex]') ? block : block.querySelector('.math-annotatable,[data-math-tex]'); @@ -453,7 +453,7 @@ export function resolveSemanticTargetAtPoint( const blockTarget = targetForBlock(graph, block); if (!blockTarget) return null; - const code = block.querySelector('pre > code.hljs'); + const code = block.querySelector('pre > code.pn-code'); if ( code && (pointerTarget === code || code.contains(pointerTarget) || pointerTarget.closest('pre')) diff --git a/packages/ui/utils/codeHighlight.test.ts b/packages/ui/utils/codeHighlight.test.ts new file mode 100644 index 000000000..229222328 --- /dev/null +++ b/packages/ui/utils/codeHighlight.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; + +import { codeBlockClassName, CODE_BLOCK_CLASS, applyHighlight, highlightToHtml } from './codeHighlight'; +import { resolveFenceTheme, resolveSyntaxTheme, DEFAULT_SYNTAX_THEME, SHIKI_THEME_MAP } from './syntaxTheme'; + +const hasDom = typeof document !== 'undefined'; + +describe('code block class', () => { + test('carries the structural class and the language hook', () => { + expect(codeBlockClassName('rust')).toBe(`${CODE_BLOCK_CLASS} font-mono language-rust`); + }); + + test('omits the language hook for language-less fences', () => { + expect(codeBlockClassName()).toBe(`${CODE_BLOCK_CLASS} font-mono`); + expect(codeBlockClassName(undefined)).not.toContain('language-'); + }); +}); + +describe('fence theme resolution', () => { + test('matches the theme the diff pane resolves, per mode', () => { + expect(resolveFenceTheme('kanagawa-wave', 'dark')).toBe('kanagawa-wave'); + expect(resolveFenceTheme('github', 'light')).toBe('github-light'); + expect(resolveFenceTheme('colorblind', 'dark')).toBe('pierre-dark-protanopia-deuteranopia'); + expect(resolveFenceTheme('colorblind', 'light')).toBe('pierre-light-protanopia-deuteranopia'); + }); + + test('falls back to the Pierre defaults for unmapped palettes', () => { + // The default Plannotator palette has no Shiki counterpart, so it renders + // in exactly what @pierre/diffs uses when handed no theme at all. + expect(resolveSyntaxTheme('plannotator', 'dark')).toBeUndefined(); + expect(resolveFenceTheme('plannotator', 'dark')).toBe(DEFAULT_SYNTAX_THEME.dark); + expect(resolveFenceTheme('plannotator', 'light')).toBe(DEFAULT_SYNTAX_THEME.light); + }); + + test('falls back per mode when a palette only defines one side', () => { + // dracula is dark-only; its light mode must still resolve to something. + expect(SHIKI_THEME_MAP['dracula']?.light).toBeNull(); + expect(resolveFenceTheme('dracula', 'dark')).toBe('dracula'); + expect(resolveFenceTheme('dracula', 'light')).toBe(DEFAULT_SYNTAX_THEME.light); + }); + + test('every mapped theme name is non-empty', () => { + for (const [palette, pair] of Object.entries(SHIKI_THEME_MAP)) { + expect(pair.dark ?? pair.light, `${palette} maps to nothing`).toBeTruthy(); + } + }); +}); + +describe('highlightToHtml', () => { + test('returns null until a grammar is attached, so callers render plain', () => { + expect(highlightToHtml('const x = 1', 'typescript', 'pierre-dark')).toBeNull(); + }); +}); + +describe.if(hasDom)('applyHighlight', () => { + test('language-less fences render as plain text, never guessed (#1212)', () => { + const el = document.createElement('code'); + applyHighlight(el, 'plain text & more', undefined, 'pierre-dark'); + expect(el.textContent).toBe('plain text & more'); + // Escaped into text nodes, not parsed as markup. + expect(el.querySelector('b')).toBeNull(); + expect(el.children.length).toBe(0); + }); + + test('writes the exact source immediately so there is no layout shift', () => { + const el = document.createElement('code'); + const code = 'fn main() {\n println!("hi");\n}'; + applyHighlight(el, code, 'rust', 'pierre-dark'); + // The highlighter is cold here, so the synchronous result is the plain + // source at its final size; the highlighted swap lands later. + expect(el.textContent).toBe(code); + }); +}); diff --git a/packages/ui/utils/codeHighlight.ts b/packages/ui/utils/codeHighlight.ts new file mode 100644 index 0000000000000000000000000000000000000000..b41022007ac1eb6ca2a67e7bd8a31d2768252c9e GIT binary patch literal 8331 zcma)BZFAGg74GN!iiI-QGQ`fkclx2@yif?Cqy)l{beNVhXl<=+VOi4EN-=KmOn*dw zVSh=V=d4ze6GQ76h`ldo&z|#~*FAXrcuzf6SL?LYU)4OG%@h19<8-E`S)rD?SVY+> zRa28rj8!^~l&xknW6L;8l}+P3H>Dkd2FYB{O<`1QmHDbCWwKV6Z_iqyrciOJ%DGWG z&qwm*Y923QjM`FzK1qas%41U$<{*lvQyVIs6h=pDRaB`JmbibCMP^W#o7k)r*Qs2m zvO5T8ro5W#!bGq9T}%<~tEh-?Osd9fB}ewG`is8NSCb;nOI4Ve`KnAhi&N7ds`sX{ z5CFM}F{yE40d6(!3pOLlrplF8)=Ve-t!9Bi}X#zd6G5@I0@)81%b*$n6Kwq%B%agt3Iq6Y2O&8u{wrg5qhh+EIdfgWd7 zsaA7cs)w(2s%Lto)dziJ9;zhE7S_{B;)NNiE9?k|2UZYpO+JRD4XIHaH&A~!1$c-e zFgdQ$C^2efh>)RrCwfxxGS7_ak*8a#@p_jmvT)^EKVDT!B(*z2L z4n?%NhTsivW(L^Bi7t{n!Btizn84*!ROLxL!KR1=s zbXMt^ahIsMxBDv1O3;Glqrs#iKS2K+w@qSOb}+A=y*nrBBa_5q!*$RF21@mxfBhRqYSiz4 z`Sq7yMdgr>bMQEJ*ZcDIGs_w>|Q!;jKWLW4XOaHYf#_RLrMGeXyv>h$u(+2`jMm#42kpI$t>x`Nhw zwRCz=aJ_bls|bdu$mjIXr_}RX+$8V^J{^Hm=w;UTU)1fM0)GHTs=`ONcaPd2+NI@S znWfpITTewjRbg|7K|8())kyVvoA7XNBe-kaarhEct_X!tjpK5u?E-3bo|Y!tfjSJx zKv>Wnl!mZOd_xbAi4(@l%eU8`ude@garXKA`q{<#DWvhs;r+?ymlrQE_3MtQzrKBO z_Wt4=S;4XT!;bl@>-Xoc&#qs+zx?Z;Ut!H3A>;%Ab~(Xl+XBAieE;1cq zWsxmoYmT;TwzK0zb$&Qor4}gY&F>!F0uoCDspdK^fSNRh4Uh}ozNNSvAiP7naa9V4X*MDu+*d;*r&=nuFH2!B_ z9iQwF9;Dh>MgpZcRRl1A|E(O_hEqD#sh&PP{+^#5LOf}Jxf;z^^>S1%PIzEOYb|>`u-ih8>|{_-C`0S_NPaaIl>OD*NT z$V*A35jfNNm={^TuS9vD7%ncyw6`Gy3PZH+CLIcWciu3s>^y)=;r7kw(X9~Jgo3Kg z*6E^)?QJrIP3}(etKQl&T`O#RBjehv$N*%-aHzPUQLnD4xT`hlg-F~Sr&c zcJxI3>_tRI+KHdkz3{NbSdU8wmVsy#ml~NmflJH#0Ubt?9aLNeG8$(|1$*(Euxe)0d zs%Ioz6R9ki3486_vtJeggp&3-TzK=95Db=ONhcSpqjAfaER@s90SeC}i#DrkD;z(3bSv|l zhbND2J4x`?!TrtA0r#AQ4VjVp?qbI#@5_Hac1f>^=^l*;T!>bfjM62!T9^L%nFJCQ zvVIHI6BPo8OO(7G!aEAK_pbSBRXEj&#ad{Tr0U8GZJN6Ds(YIVNB}tF5i7L)x&y)> zf>;S>pH2&Tfq!LU(s2miKE=BD^^CAfJ8h;eoYge`~d1r4{~N5pus=yq)i>vwH08I1{%%o z$^B}P*JW&M2n%=KdeB5iB7kW!bnl5FxW(D-BgjM8_$XgKx$=@z#3sl2w=;~!+mtUJ z-4_cqbEF3|_+opWz^1WKrxQ5cMlK{@TMRYfc;3`^mM9Xq0@t(IsOURXI#>5(M07#6 z*s8D_E!Ba^E2_g#w(#tTf|?x&3FIG{4YVY8t8c*H-nP{nBy?q##uHk_GFND-`);+1 zCpO7eh@sSI>+2Q|DXK7}lh}kp$~S{wAe;@*KCc@9 z?A^PR`}=Ku`T*YGLlKwoK;4M7M5Q0Q_Xg?Z)u9&;0qFDNQAQqylts9e4tXttIxU4l zl{|k;(c@y3%&u|3)~#MM?*am+h}sx1%e-8xDVmz|z9cXR11p{LErE@;#xZSIDXTPeG1;~CemQRdD|b`-$RCelf_ZT^8z0L#*-&4 zPG}`AEd$bqv<^NKVIH=DDdceSjxq9~rMWHnkk_kj+^D;S@*2X7Mw}a0`fR}@LnddZ z&MLqQ1FW-DXNUZb0P9_9?6HQWjZQk1imOn3@xaZJ^fbF3Aciw)cg~N~)D#plNH*km zvhZDIMXI~Pp=ZRk2e;jp$O3N>sDWC6GtA%-O_5J3hXoRjjV&aUN`KYI1;-(52}V;V ziT|HKoJeF#D-E8}kppK%^3ZzS%D=|b8(7lI5j_R?tW(>%taR2Qj)V_Dh){U8l1EFt zUUgnHnmY6IXGCoS%|~qtamo_)WmfPk%NA_|*L&qs6FNF2RO8e;V@>n5X{z#GZ6g&* J@62rW{tJY`#LEBx literal 0 HcmV?d00001 diff --git a/packages/ui/utils/syntaxTheme.ts b/packages/ui/utils/syntaxTheme.ts new file mode 100644 index 000000000..e25c53f88 --- /dev/null +++ b/packages/ui/utils/syntaxTheme.ts @@ -0,0 +1,83 @@ +/** + * Maps a Plannotator colour theme onto the Shiki theme that renders code in it. + * + * This used to live in `packages/review-editor/hooks/usePierreTheme.ts` and only + * served the diff pane. It moved here so the plan/annotate editor's markdown + * fences resolve the SAME theme the diff pane resolves, which is what makes a + * fenced code block and a diff hunk finally look like they belong to the same + * app. `usePierreTheme` re-exports both symbols, so the review editor's imports + * are unchanged. + * + * Names on the right are resolved by `@pierre/diffs` — the `pierre-*` ones come + * from `@pierre/theme`, the rest from `@shikijs/themes`. Both registries are + * already bundled (Pierre pulls in Shiki's full bundle), so consuming them here + * costs no additional bytes. + */ + +/** Plannotator theme id -> Shiki theme name, per mode. `null` = this palette + * has no counterpart in that mode and falls back to the Pierre default. */ +export const SHIKI_THEME_MAP: Record = { + 'andromeeda': { dark: 'andromeeda', light: null }, + 'aurora-x': { dark: 'aurora-x', light: null }, + 'ayu-dark': { dark: 'ayu-dark', light: null }, + 'catppuccin': { dark: 'catppuccin-mocha', light: 'catppuccin-latte' }, + 'colorblind': { dark: 'pierre-dark-protanopia-deuteranopia', light: 'pierre-light-protanopia-deuteranopia' }, + 'dark-plus': { dark: 'dark-plus', light: 'light-plus' }, + 'dracula': { dark: 'dracula', light: null }, + 'everforest': { dark: 'everforest-dark', light: 'everforest-light' }, + 'everforest-hard': { dark: 'everforest-dark', light: 'everforest-light' }, + 'everforest-soft': { dark: 'everforest-dark', light: 'everforest-light' }, + 'github': { dark: 'github-dark', light: 'github-light' }, + 'gruvbox': { dark: 'gruvbox-dark-medium', light: 'gruvbox-light-medium' }, + 'houston': { dark: 'houston', light: null }, + 'kanagawa-dragon': { dark: 'kanagawa-dragon', light: null }, + 'kanagawa-lotus': { dark: null, light: 'kanagawa-lotus' }, + 'kanagawa-wave': { dark: 'kanagawa-wave', light: null }, + 'laserwave': { dark: 'laserwave', light: null }, + 'material': { dark: 'material-theme', light: 'material-theme-lighter' }, + 'min': { dark: 'min-dark', light: 'min-light' }, + 'monokai-pro': { dark: 'monokai', light: null }, + 'night-owl': { dark: 'night-owl', light: null }, + 'nord': { dark: 'nord', light: null }, + 'one-dark-pro': { dark: 'one-dark-pro', light: null }, + 'one-light': { dark: null, light: 'one-light' }, + 'plastic': { dark: 'plastic', light: null }, + 'poimandres': { dark: 'poimandres', light: null }, + 'red': { dark: 'red', light: null }, + 'rose-pine': { dark: 'rose-pine', light: 'rose-pine-dawn' }, + 'slack': { dark: 'slack-dark', light: 'slack-ochin' }, + 'snazzy-light': { dark: null, light: 'snazzy-light' }, + 'solarized': { dark: 'solarized-dark', light: 'solarized-light' }, + 'synthwave-84': { dark: 'synthwave-84', light: null }, + 'tokyo-night': { dark: 'tokyo-night', light: null }, + 'vesper': { dark: 'vesper', light: null }, + 'vitesse': { dark: 'vitesse-dark', light: 'vitesse-light' }, + 'vitesse-black': { dark: 'vitesse-black', light: null }, +}; + +/** `@pierre/diffs`' own `DEFAULT_THEMES`. Anything the map does not cover (the + * Plannotator default palette, plus every palette with no counterpart in the + * active mode) renders in these, which is exactly what the diff pane does when + * `resolveSyntaxTheme` returns `undefined`. */ +export const DEFAULT_SYNTAX_THEME = { dark: 'pierre-dark', light: 'pierre-light' } as const; + +/** + * The theme pair to hand `@pierre/diffs`, or `undefined` to let it use its own + * defaults. Returning `undefined` (rather than the default pair) is deliberate: + * it keeps the diff pane's prop identity stable for palettes that never + * customised it. + */ +export function resolveSyntaxTheme(colorTheme: string, mode: 'dark' | 'light'): { dark: string; light: string } | undefined { + const map = SHIKI_THEME_MAP[colorTheme]; + if (!map || !map[mode]) return undefined; + return { dark: map.dark || DEFAULT_SYNTAX_THEME.dark, light: map.light || DEFAULT_SYNTAX_THEME.light }; +} + +/** + * The single concrete Shiki theme name for the palette currently on screen. + * Markdown fences render one mode at a time, so unlike the diff pane (which + * hands Pierre a dark/light pair and lets CSS pick) they want a resolved name. + */ +export function resolveFenceTheme(colorTheme: string, mode: 'dark' | 'light'): string { + return resolveSyntaxTheme(colorTheme, mode)?.[mode] ?? DEFAULT_SYNTAX_THEME[mode]; +} diff --git a/packages/ui/utils/vimNavigation.test.ts b/packages/ui/utils/vimNavigation.test.ts index 8746517ff..7e08aaeb2 100644 --- a/packages/ui/utils/vimNavigation.test.ts +++ b/packages/ui/utils/vimNavigation.test.ts @@ -23,7 +23,7 @@ function createDocumentFixture(): HTMLElement { 'A1A2', 'B1B2C1C2', '
', - '
const answer = 42;
', + '
const answer = 42;
', '

Charlie delta

', ].join(''); document.body.appendChild(container); diff --git a/tests/entry-assets.test.ts b/tests/entry-assets.test.ts index 46161bbe3..168b35380 100644 --- a/tests/entry-assets.test.ts +++ b/tests/entry-assets.test.ts @@ -24,9 +24,32 @@ describe('review entry assets', () => { expect(theme).toContain("--font-sans: 'Inter Variable'"); expect(theme).toContain("--font-mono: 'Geist Mono Variable'"); + // Syntax highlighting is the bundled Shiki instance @pierre/diffs already + // runs (JavaScript regex engine, no WASM, no network). A CDN-loaded + // highlighter or a runtime wasm fetch would break the single-file builds. const codeBlock = read('packages/ui/components/blocks/CodeBlock.tsx'); - expect(codeBlock).toContain("import hljs from 'highlight.js';"); - expect(codeBlock).toContain("import 'highlight.js/styles/github-dark.css';"); + expect(codeBlock).toContain("from '../../utils/codeHighlight'"); + + const highlighter = read('packages/ui/utils/codeHighlight.ts'); + expect(highlighter).toContain("import('@pierre/diffs')"); + expect(highlighter).toContain("preferredHighlighter: 'shiki-js'"); + expect(highlighter).not.toMatch(/https?:\/\//); + }); + + test('nothing depends on highlight.js any more', () => { + for (const manifest of ['packages/ui/package.json', 'packages/review-editor/package.json']) { + expect(read(manifest)).not.toContain('highlight.js'); + } + }); + + test('the dead Oniguruma WASM is aliased out of every bundled app', () => { + for (const config of [ + 'apps/review/vite.config.ts', + 'apps/hook/vite.config.ts', + 'apps/portal/vite.config.ts', + ]) { + expect(read(config)).toContain("'shiki/wasm': path.resolve("); + } }); }); From cdfd4698273b42799548365064c9a3b0769fdbc6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 5 Aug 2026 21:12:17 -0700 Subject: [PATCH 3/4] fix(ui): strip stray NUL bytes from the code-highlight source Two U+0000 bytes slipped into comments in the previous commit, which made git treat the file as binary. Replaced with spaces; no behaviour change. --- packages/ui/utils/codeHighlight.ts | Bin 8331 -> 8331 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/ui/utils/codeHighlight.ts b/packages/ui/utils/codeHighlight.ts index b41022007ac1eb6ca2a67e7bd8a31d2768252c9e..a27d5dee9d843e2b9378334bc3bec7eef192d1f4 100644 GIT binary patch delta 21 bcmeBn>~`EB%E_p(S&Y+$5l97dS%?7uK(YlE delta 21 bcmeBn>~`EB%E`#ES&Y+$5l97dS%?7uK2ikM From cc72ea24cd18e4b8e8f95de9c0289af6925dd11a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 5 Aug 2026 21:42:50 -0700 Subject: [PATCH 4/4] fix(ui): keep code-block annotation marks across highlight swaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fenced code is annotated by hand: one `` inside the `` element, which `applyHighlight` also owns. Every highlight swap (palette change, dark/light toggle, or the first async grammar attach after load) replaces that element's children, so the mark was silently wiped and nothing put it back. Annotation state, the sidebar panel and exports were unaffected; the loss was purely visual, and deterministic. `applyHighlight` now publishes every write through `onCodeHighlightSwap`, synchronously, immediately after it. `Viewer` subscribes and re-paints the fence's mark, so a swapped block ends up with BOTH the new theme's tokens and its annotation. The shared painter (`paintCodeBlockMark`) moves the token spans into the mark instead of flattening them to text, so creating an annotation no longer costs a block its colours either. Being driven by the swap also fixes the cousin race by ordering rather than timing: share/draft restore runs on a timer after load, and on a slow machine the first async swap could land after it and wipe the restored marks per block. A restore that painted before the swap is now re-established in the same task the swap ran in, and one that runs after finds the mark already there. Removal tombstones the id before re-highlighting, because the host drops the annotation from state a tick later — without it the swap listener would paint the just-removed annotation back in, and a fence carrying a second annotation would end up bare. Also closes the named gap in the WASM coverage: entry-assets only grepped source, so a future @pierre/diffs bump could reintroduce the inlined blob through a different import specifier unnoticed. It now greps the built `apps/{review,hook}/dist/index.html` for the base64 WASM magic, skipping on an unbuilt checkout and running for real in the CI job that builds the bundles. --- .github/workflows/test.yml | 9 + AGENTS.md | 3 + .../Viewer.codeBlockHighlightSwap.test.tsx | 328 ++++++++++++++++++ packages/ui/components/Viewer.tsx | 71 +++- packages/ui/utils/codeBlockMark.test.ts | 59 ++++ packages/ui/utils/codeBlockMark.ts | 50 +++ packages/ui/utils/codeHighlight.ts | 53 ++- tests/entry-assets.test.ts | 21 +- 8 files changed, 585 insertions(+), 9 deletions(-) create mode 100644 packages/ui/components/Viewer.codeBlockHighlightSwap.test.tsx create mode 100644 packages/ui/utils/codeBlockMark.test.ts create mode 100644 packages/ui/utils/codeBlockMark.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e59d723a..b28a34f6a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,8 @@ jobs: packages/review-editor/hooks/useReviewSearch.test.tsx packages/ui/components/AnnotationPanel.props.test.tsx packages/ui/components/Viewer.consumer.test.tsx + packages/ui/components/Viewer.codeBlockHighlightSwap.test.tsx + packages/ui/utils/codeBlockMark.test.ts packages/ui/components/InlineMarkdown.seam.test.tsx packages/ui/components/ImageThumbnail.seam.test.tsx packages/ui/hooks/useAnnotationHighlighter.test.tsx @@ -102,6 +104,13 @@ jobs: - name: Build OpenCode plugin assets run: bun run build:review && bun run build:hook && bun run build:opencode + # This is the only job with the single-file bundles on disk, so it is the + # only place the built-artifact assertions in entry-assets can actually + # run (they skip on an unbuilt checkout). Chief among them: no inlined + # WebAssembly survived the bundle. + - name: Assert built bundles ship no inlined WASM + run: bun test tests/entry-assets.test.ts + - name: Pack OpenCode plugin working-directory: apps/opencode-plugin run: npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP" diff --git a/AGENTS.md b/AGENTS.md index 1afb6405c..17e3d7140 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -614,6 +614,9 @@ There is **one** highlighter in the app: the Shiki instance `@pierre/diffs` alre - `applyHighlight(el, code, lang, theme)` — imperative drop-in for the old `hljs.highlightElement(el)`. Writes plain text immediately (final size on first paint, no layout shift), then swaps in highlighted markup once the grammar is attached; already-attached grammars highlight synchronously, so there is no flicker on cached highlights. It also enforces that the rendered text is byte-identical to the source and falls back to plain text otherwise, because the annotation layer addresses code blocks by text offset. - `highlightToHtml(code, lang, theme)` / `ensureHighlight(lang, theme)` — the sync/async pair behind it, for callers that need HTML strings (the code-file hover preview). - `codeBlockClassName(lang)` — the `pn-code font-mono language-{lang}` class every fenced `` carries. **`pn-code` replaced the old `hljs` class** and is the structural hook `blockTargeting`, vim navigation and `print.css` use (`pre > code.pn-code`); `language-*` is how `blockTargeting` reads a block's language back out of the DOM. +- `onCodeHighlightSwap(listener)` — observes every write `applyHighlight` makes, SYNCHRONOUSLY, immediately after it. Each write replaces the element's children, so it also destroys whatever the annotation layer wrapped inside the fence. + +**Code-block annotation marks and highlight swaps.** `web-highlighter` cannot select inside a `
`, so a fenced block is annotated all-or-nothing: one `` that is the `` element's only child, painted by `paintCodeBlockMark` (`packages/ui/utils/codeBlockMark.ts`) — which MOVES the token spans into the mark rather than flattening them to text, so annotating or re-theming a block never costs it its colours. `Viewer` subscribes to `onCodeHighlightSwap` and re-paints that mark right after any swap, which is what keeps a palette or dark/light change from wiping code-block annotations. Being driven by the swap is also what makes the share/draft restore race safe **by ordering rather than by timing**: a restore that painted before the swap is re-established in the same task the swap ran in, and one that runs after finds the mark already there. Do not "fix" a mark-eating swap by skipping the rewrite when a mark is present — that leaves annotated blocks in stale theme colours.
 
 **Language-less fences render as plain text and are never guessed at (#1212). There is no auto-detection anywhere.** `HighlightedCode` (review suggestions) derives its language from the caller's file path via `detectLanguage`; an unrecognised extension renders plain.
 
diff --git a/packages/ui/components/Viewer.codeBlockHighlightSwap.test.tsx b/packages/ui/components/Viewer.codeBlockHighlightSwap.test.tsx
new file mode 100644
index 000000000..8c35b6c9b
--- /dev/null
+++ b/packages/ui/components/Viewer.codeBlockHighlightSwap.test.tsx
@@ -0,0 +1,328 @@
+/**
+ * A code-block annotation mark must survive every syntax-highlight swap.
+ *
+ * Fenced code is annotated by hand — one `` inside the
+ * `` element — while `applyHighlight` owns that same element's children.
+ * Every swap (palette change, dark/light toggle, or the first async grammar
+ * attach after load) replaces those children, so without the swap listener in
+ * `Viewer` the mark is silently wiped and never comes back.
+ *
+ * Both tests assert the SAME pair of facts after the swap: the mark is still
+ * there, AND the tokens carry the new theme's colours. Getting one without the
+ * other is the bug in either direction.
+ *
+ * `@pierre/diffs` is stood in for through `__setCodeHighlightModuleForTests`,
+ * which keeps Shiki's full bundle out of the test and — more importantly —
+ * lets the second test decide EXACTLY when the async swap lands relative to the
+ * restore it races. That ordering is a released promise, never a sleep.
+ */
+import { afterEach, describe, expect, test } from 'bun:test';
+import React from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { act } from 'react';
+
+import { AnnotationType, type Annotation, type Block } from '../types';
+import {
+  __resetCodeHighlightCacheForTests,
+  __setCodeHighlightModuleForTests,
+} from '../utils/codeHighlight';
+
+const hasDom = typeof document !== 'undefined';
+
+// Viewer pulls in @plannotator/web-highlighter, whose UMD bundle reads `window`
+// at module-eval time. Import lazily so this file loads under the DOM-less
+// default `bun test` run.
+const viewerMod = hasDom ? await import('./Viewer') : null;
+const Viewer = viewerMod?.Viewer as typeof import('./Viewer')['Viewer'];
+type ViewerHandle = import('./Viewer').ViewerHandle;
+const themeMod = hasDom ? await import('./ThemeProvider') : null;
+const ThemeProvider = themeMod?.ThemeProvider as typeof import('./ThemeProvider')['ThemeProvider'];
+const useTheme = themeMod?.useTheme as typeof import('./ThemeProvider')['useTheme'];
+
+const CODE = 'const archived = true;';
+const codeBlocks: Block[] = [
+  { id: 'code-1', type: 'code', content: CODE, language: 'typescript', order: 0, startLine: 1 },
+];
+
+/** One distinctive colour per Shiki theme, so "did the tokens re-theme?" is a
+ *  string match rather than a guess. */
+const TOKEN_COLOR: Record = {
+  'github-dark': '#79c0ff',
+  'github-light': '#0550ae',
+  'kanagawa-wave': '#7e9cd8',
+};
+
+/**
+ * Stand-in for `@pierre/diffs`. `attach` decides when a (lang, theme) pair
+ * becomes available: `'immediate'` resolves on the microtask queue, `'gated'`
+ * hands back a release function so a test can hold the async swap open.
+ */
+function fakePierre(attach: 'immediate' | 'gated'): {
+  mod: typeof import('@pierre/diffs');
+  release: () => void;
+} {
+  const pending: Array<() => void> = [];
+  const mod = {
+    getSharedHighlighter: () =>
+      attach === 'immediate'
+        ? Promise.resolve(undefined)
+        : new Promise((resolve) => pending.push(() => resolve(undefined))),
+    getHighlighterIfLoaded: () => ({
+      codeToTokens: (code: string, { theme }: { theme: string }) => ({
+        // One token per line reproduces the source byte-for-byte, which is what
+        // `highlightToHtml` insists on before it will emit markup at all.
+        tokens: code
+          .split('\n')
+          .map((line) => [{ content: line, color: TOKEN_COLOR[theme] ?? '#ffffff' }]),
+      }),
+    }),
+  };
+  return {
+    mod: mod as unknown as typeof import('@pierre/diffs'),
+    release: () => {
+      const waiting = pending.splice(0);
+      waiting.forEach((resolve) => resolve());
+    },
+  };
+}
+
+/** A whole-fence annotation, the shape `applyCodeBlockAnnotation` produces. */
+function codeBlockAnnotation(id: string, type: AnnotationType): Annotation {
+  return {
+    id,
+    blockId: 'code-1',
+    startOffset: 0,
+    endOffset: CODE.length,
+    type,
+    originalText: CODE,
+    createdA: Date.now(),
+  };
+}
+
+let root: Root | null = null;
+let host: HTMLElement | null = null;
+let keySeq = 0;
+
+interface Controls {
+  setColorTheme: (theme: string) => void;
+  viewer: ViewerHandle | null;
+  removeAnnotation: (id: string) => void;
+}
+const controls: Controls = {
+  setColorTheme: () => {},
+  viewer: null,
+  removeAnnotation: () => {},
+};
+
+const Harness: React.FC<{ initial: Annotation[] }> = ({ initial }) => {
+  const [annotations, setAnnotations] = React.useState(initial);
+  const theme = useTheme();
+  const viewerRef = React.useRef(null);
+  React.useEffect(() => {
+    controls.setColorTheme = theme.setColorTheme;
+    controls.viewer = viewerRef.current;
+    // Mirrors App's removeAnnotation: strip the highlight, then drop it from
+    // state. The two happen in that order, one tick apart.
+    controls.removeAnnotation = (id: string) => {
+      viewerRef.current?.removeHighlight(id);
+      setAnnotations((prev) => prev.filter((a) => a.id !== id));
+    };
+  });
+  return (
+     setAnnotations((prev) => [...prev, annotation])}
+      onSelectAnnotation={() => {}}
+      selectedAnnotationId={null}
+      mode="redline"
+      inputMethod="pinpoint"
+      taterMode={false}
+      disableCodePathValidation
+    />
+  );
+};
+
+async function mountHarness(initial: Annotation[] = []): Promise {
+  host = document.createElement('div');
+  document.body.appendChild(host);
+  await act(async () => {
+    root = createRoot(host!);
+    root.render(
+      // A fresh storage key per mount: ThemeProvider persists the palette, and
+      // a leftover cookie would otherwise decide the starting theme.
+      
+        
+      ,
+    );
+  });
+}
+
+/** Let queued microtasks (the highlighter attach + its swap) run. */
+async function flush(): Promise {
+  await act(async () => {
+    await Promise.resolve();
+    await Promise.resolve();
+  });
+}
+
+function codeEl(): HTMLElement {
+  const el = document.querySelector('[data-block-id="code-1"] code');
+  if (!el) throw new Error('fenced code block did not render');
+  return el;
+}
+
+function tokenColors(): string[] {
+  return Array.from(codeEl().querySelectorAll('span[style]')).map(
+    (span) => span.getAttribute('style') ?? '',
+  );
+}
+
+afterEach(async () => {
+  if (root) {
+    await act(async () => {
+      root!.unmount();
+    });
+    root = null;
+  }
+  host?.remove();
+  host = null;
+  controls.viewer = null;
+  controls.setColorTheme = () => {};
+  controls.removeAnnotation = () => {};
+  if (hasDom) document.body.innerHTML = '';
+  __resetCodeHighlightCacheForTests();
+});
+
+describe('code-block annotations across highlight swaps', () => {
+  test.skipIf(!hasDom)('a palette change re-themes the tokens and keeps the mark', async () => {
+    const { mod } = fakePierre('immediate');
+    __setCodeHighlightModuleForTests(mod);
+
+    await mountHarness();
+    await flush();
+
+    // Baseline: the fence is highlighted in the github-dark palette.
+    expect(tokenColors().join(' ')).toContain(TOKEN_COLOR['github-dark']);
+
+    // Annotate the whole fence (pinpoint + redline is the code-block path).
+    const block = document.querySelector('[data-block-id="code-1"]')!;
+    await act(async () => {
+      block.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
+      block.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+    });
+    const mark = codeEl().querySelector('mark[data-bind-id]');
+    expect(mark).not.toBeNull();
+    expect(mark!.textContent).toBe(CODE);
+
+    // Switch the palette. This is the reported repro.
+    await act(async () => {
+      controls.setColorTheme('kanagawa-wave');
+    });
+    await flush();
+
+    const after = codeEl();
+    // Both facts, together: the mark is still there...
+    const survivor = after.querySelector('mark[data-bind-id]');
+    expect(survivor).not.toBeNull();
+    expect(survivor!.textContent).toBe(CODE);
+    expect(after.textContent).toBe(CODE);
+    // ...and the tokens moved to the new theme.
+    const styles = tokenColors().join(' ');
+    expect(styles).toContain(TOKEN_COLOR['kanagawa-wave']);
+    expect(styles).not.toContain(TOKEN_COLOR['github-dark']);
+  });
+
+  test.skipIf(!hasDom)(
+    'an async swap that lands after a share/draft restore does not wipe it',
+    async () => {
+      // The cousin bug: restore fires on a timer after load, and on a slow
+      // machine the FIRST async highlight swap can land after it. Held open
+      // explicitly here so the ordering is decided by the test, not by luck.
+      const { mod, release } = fakePierre('gated');
+      __setCodeHighlightModuleForTests(mod);
+
+      const restored: Annotation = {
+        id: 'codeblock-restored',
+        blockId: 'code-1',
+        startOffset: 0,
+        endOffset: CODE.length,
+        type: AnnotationType.DELETION,
+        originalText: CODE,
+        createdA: Date.now(),
+      };
+
+      await mountHarness([restored]);
+      await flush();
+
+      // The swap is still pending: the fence is plain and unmarked.
+      expect(tokenColors()).toEqual([]);
+      expect(codeEl().querySelector('mark[data-bind-id]')).toBeNull();
+
+      // Restore runs first — exactly what App does on a share/draft load.
+      await act(async () => {
+        controls.viewer?.applySharedAnnotations([restored]);
+      });
+      expect(codeEl().querySelector(`[data-bind-id="${restored.id}"]`)).not.toBeNull();
+
+      // Now let the swap land on top of the restored mark.
+      await act(async () => {
+        release();
+      });
+      await flush();
+
+      const after = codeEl();
+      expect(after.querySelector(`[data-bind-id="${restored.id}"]`)).not.toBeNull();
+      expect(after.textContent).toBe(CODE);
+      expect(tokenColors().join(' ')).toContain(TOKEN_COLOR['github-dark']);
+    },
+  );
+
+  test.skipIf(!hasDom)('removal is honoured even though it re-highlights the block', async () => {
+    // Removing an annotation re-highlights the fence on its way out, and the
+    // host only drops it from state on the NEXT tick — so for one tick the
+    // swap listener sees a list that still names the annotation whose mark was
+    // just deleted. It must not be the one painted back in.
+    const { mod } = fakePierre('immediate');
+    __setCodeHighlightModuleForTests(mod);
+
+    const older = codeBlockAnnotation('codeblock-older', AnnotationType.COMMENT);
+    const newer = codeBlockAnnotation('codeblock-newer', AnnotationType.DELETION);
+    await mountHarness([older, newer]);
+    await flush();
+
+    // Two annotations, one fence: the later one owns the mark, exactly as it
+    // does when a block is annotated twice.
+    expect(codeEl().querySelector('mark[data-bind-id]')?.getAttribute('data-bind-id'))
+      .toBe(newer.id);
+
+    await act(async () => {
+      controls.removeAnnotation(newer.id);
+    });
+    await flush();
+
+    // The removed one is gone, and the fence falls back to the annotation that
+    // is still on it rather than being left bare.
+    const after = codeEl();
+    expect(after.querySelector(`[data-bind-id="${newer.id}"]`)).toBeNull();
+    expect(after.querySelector(`[data-bind-id="${older.id}"]`)).not.toBeNull();
+    expect(after.textContent).toBe(CODE);
+
+    // And a later palette change — after the tombstone has been retired —
+    // still honours the removal.
+    await act(async () => {
+      controls.setColorTheme('kanagawa-wave');
+    });
+    await flush();
+    expect(codeEl().querySelector(`[data-bind-id="${newer.id}"]`)).toBeNull();
+    expect(codeEl().querySelector(`[data-bind-id="${older.id}"]`)).not.toBeNull();
+    expect(tokenColors().join(' ')).toContain(TOKEN_COLOR['kanagawa-wave']);
+  });
+});
diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx
index 94eff1ba5..e7794a25a 100644
--- a/packages/ui/components/Viewer.tsx
+++ b/packages/ui/components/Viewer.tsx
@@ -1,7 +1,8 @@
 import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
 import { createPortal } from 'react-dom';
 import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
-import { applyHighlight, codeBlockClassName } from '../utils/codeHighlight';
+import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
+import { paintCodeBlockMark } from '../utils/codeBlockMark';
 import { useFenceTheme } from '../hooks/useFenceTheme';
 import { computeListIndices, groupBlocks, type Frontmatter } from '../utils/parser';
 import { buildHeadingSlugMap } from '../utils/slugify';
@@ -357,12 +358,7 @@ export const Viewer = forwardRef(({
     const id = `codeblock-${Date.now()}`;
     const codeText = codeEl.textContent || '';
 
-    const wrapper = document.createElement('mark');
-    wrapper.className = `annotation-highlight ${type === AnnotationType.DELETION ? 'deletion' : type === AnnotationType.COMMENT ? 'comment' : ''}`.trim();
-    wrapper.dataset.bindId = id;
-    wrapper.textContent = codeText;
-
-    codeEl.replaceChildren(wrapper);
+    paintCodeBlockMark(codeEl, id, type);
 
     const newAnnotation: Annotation = {
       id,
@@ -383,6 +379,63 @@ export const Viewer = forwardRef(({
     window.getSelection()?.removeAllRanges();
   }, []);
 
+  // Live annotation list for the imperative DOM paths below, which run outside
+  // React's render (highlight swaps, the imperative handle).
+  const annotationsRef = useRef(annotations);
+  annotationsRef.current = annotations;
+
+  // `removeHighlight` runs BEFORE the host drops the annotation from state and
+  // re-highlights the block on the way out, so for one tick `annotationsRef`
+  // still lists an annotation whose mark is deliberately gone. Remember those
+  // ids so the swap listener below never paints a removed annotation back in,
+  // whichever tick that block's re-highlight lands in.
+  const removedAnnotationIdsRef = useRef>(new Set());
+  // Retire a tombstone as soon as the host's list agrees the annotation is
+  // gone: the window it guards is only the tick between removeHighlight and
+  // the state update, and keeping it would block a later restore that brings
+  // the same annotation (same id) back from a draft.
+  for (const id of removedAnnotationIdsRef.current) {
+    if (!annotations.some((a) => a.id === id)) removedAnnotationIdsRef.current.delete(id);
+  }
+
+  // A highlight swap replaces a `` element's children — that is how the
+  // palette/mode change repaints tokens, and how the first async grammar
+  // attach lands after load. It also destroys any annotation mark inside the
+  // fence. Re-paint it here, SYNCHRONOUSLY after the write, so the block ends
+  // up with both the new theme's tokens and its mark.
+  //
+  // Being driven by the swap is also what makes the restore race safe without
+  // timing: a share/draft restore that painted before the swap is
+  // re-established in the same task the swap ran in, and one that runs after
+  // it finds the mark already present and leaves it alone.
+  useEffect(() => onCodeHighlightSwap((codeEl) => {
+    const container = containerRef.current;
+    if (!container || !container.contains(codeEl)) return;
+    // The swap always clears the element, so a surviving mark means this write
+    // was not the one that owns this block's contents.
+    if (codeEl.querySelector('[data-bind-id]')) return;
+
+    const codeText = codeEl.textContent ?? '';
+    if (!codeText) return;
+    const blockId = codeEl.closest('[data-block-id]')?.getAttribute('data-block-id') ?? '';
+
+    // Fenced code is annotated all-or-nothing, so this block's annotations are
+    // exactly the ones whose originalText is its full text. Share-restored
+    // annotations arrive with an empty blockId (it is filled in during restore),
+    // so an unset blockId still counts. The last one wins, matching what
+    // annotating the same block twice does.
+    const owner = annotationsRef.current.filter((a) =>
+      a.type !== AnnotationType.GLOBAL_COMMENT
+      && !a.diffContext
+      && a.originalText === codeText
+      && (a.blockId === blockId || !a.blockId)
+      && !removedAnnotationIdsRef.current.has(a.id)
+      && !container.querySelector(`[data-bind-id="${a.id}"], [data-highlight-id="${a.id}"]`)
+    ).at(-1);
+
+    if (owner) paintCodeBlockMark(codeEl, owner.id, owner.type);
+  }), []);
+
   // Pinpoint mode: hover + click to select elements
   const handlePinpointCodeBlockClick = useCallback((blockId: string, element: HTMLElement) => {
     if (readOnlyRef.current) return;
@@ -623,6 +676,10 @@ export const Viewer = forwardRef(({
   // Imperative handle — delegates to hook, extends removeHighlight for code blocks
   useImperativeHandle(ref, () => ({
     removeHighlight: (id: string) => {
+      // The re-highlight below notifies the swap listener, which would happily
+      // paint this annotation's mark straight back in — the host has not
+      // dropped it from state yet. Tombstone the id first.
+      removedAnnotationIdsRef.current.add(id);
       // Code block annotations need syntax re-highlighting after removal.
       // Must run BEFORE hookRemoveHighlight, which removes the  elements.
       const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`);
diff --git a/packages/ui/utils/codeBlockMark.test.ts b/packages/ui/utils/codeBlockMark.test.ts
new file mode 100644
index 000000000..27ac0281c
--- /dev/null
+++ b/packages/ui/utils/codeBlockMark.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from 'bun:test';
+
+import { codeBlockMarkClassName, paintCodeBlockMark } from './codeBlockMark';
+import { AnnotationType } from '../types';
+
+const hasDom = typeof document !== 'undefined';
+
+describe('code block mark class', () => {
+  test('carries the annotation kind the stylesheet keys on', () => {
+    expect(codeBlockMarkClassName(AnnotationType.DELETION)).toBe('annotation-highlight deletion');
+    expect(codeBlockMarkClassName(AnnotationType.COMMENT)).toBe('annotation-highlight comment');
+    // Global comments never wrap a block, so there is no modifier to add.
+    expect(codeBlockMarkClassName(AnnotationType.GLOBAL_COMMENT)).toBe('annotation-highlight');
+  });
+});
+
+describe.if(hasDom)('paintCodeBlockMark', () => {
+  function fence(html: string): HTMLElement {
+    const code = document.createElement('code');
+    code.innerHTML = html;
+    return code;
+  }
+
+  test('wraps the whole fence in one mark and keeps the token spans', () => {
+    const code = fence('const x = 1');
+    paintCodeBlockMark(code, 'ann-1', AnnotationType.DELETION);
+
+    expect(code.children.length).toBe(1);
+    const mark = code.firstElementChild as HTMLElement;
+    expect(mark.tagName).toBe('MARK');
+    expect(mark.dataset.bindId).toBe('ann-1');
+    expect(mark.className).toBe('annotation-highlight deletion');
+    // The point of moving children instead of flattening: the palette's
+    // colours survive being annotated (and being re-themed).
+    expect(mark.querySelector('span[style*="#79c0ff"]')).not.toBeNull();
+    expect(code.textContent).toBe('const x = 1');
+  });
+
+  test('a second annotation replaces the first mark instead of nesting in it', () => {
+    const code = fence('const x = 1');
+    paintCodeBlockMark(code, 'ann-1', AnnotationType.DELETION);
+    paintCodeBlockMark(code, 'ann-2', AnnotationType.COMMENT);
+
+    const marks = code.querySelectorAll('mark[data-bind-id]');
+    expect(marks.length).toBe(1);
+    expect((marks[0] as HTMLElement).dataset.bindId).toBe('ann-2');
+    expect(code.querySelector('span[style*="#79c0ff"]')).not.toBeNull();
+    expect(code.textContent).toBe('const x = 1');
+  });
+
+  test('a plain (language-less) fence is wrapped without inventing markup', () => {
+    const code = document.createElement('code');
+    code.textContent = 'plain text';
+    paintCodeBlockMark(code, 'ann-3', AnnotationType.COMMENT);
+
+    expect(code.textContent).toBe('plain text');
+    expect(code.querySelector('b')).toBeNull();
+  });
+});
diff --git a/packages/ui/utils/codeBlockMark.ts b/packages/ui/utils/codeBlockMark.ts
new file mode 100644
index 000000000..539d3f89c
--- /dev/null
+++ b/packages/ui/utils/codeBlockMark.ts
@@ -0,0 +1,50 @@
+/**
+ * The annotation `` that covers a whole fenced code block.
+ *
+ * `web-highlighter` cannot select inside a `
`, so fenced code is annotated
+ * as an all-or-nothing block: one `` that is the ``
+ * element's only child and holds everything the fence renders. Several places
+ * need to (re)paint exactly that shape — creating an annotation, and restoring
+ * one after `applyHighlight` replaced the element's children — so the DOM
+ * contract lives here rather than being written out twice.
+ *
+ * The children are MOVED into the mark, never flattened to text. Highlighted
+ * fences render as Shiki token ``s, and flattening would drop the
+ * palette's colours on the floor the moment a block was annotated or
+ * re-themed.
+ */
+import { AnnotationType } from '../types';
+
+export function codeBlockMarkClassName(type: AnnotationType): string {
+  return `annotation-highlight ${
+    type === AnnotationType.DELETION ? 'deletion' : type === AnnotationType.COMMENT ? 'comment' : ''
+  }`.trim();
+}
+
+/**
+ * Wrap everything inside `codeEl` in a single annotation mark and return it.
+ *
+ * Any mark a previous annotation left behind is unwrapped first, so a second
+ * annotation on the same block replaces the first (what has always happened)
+ * instead of nesting inside it.
+ */
+export function paintCodeBlockMark(
+  codeEl: Element,
+  id: string,
+  type: AnnotationType,
+): HTMLElement {
+  codeEl.querySelectorAll('mark[data-bind-id]').forEach((existing) => {
+    const parent = existing.parentNode;
+    if (!parent) return;
+    while (existing.firstChild) parent.insertBefore(existing.firstChild, existing);
+    existing.remove();
+  });
+
+  const doc = codeEl.ownerDocument ?? document;
+  const wrapper = doc.createElement('mark');
+  wrapper.className = codeBlockMarkClassName(type);
+  wrapper.dataset.bindId = id;
+  while (codeEl.firstChild) wrapper.appendChild(codeEl.firstChild);
+  codeEl.replaceChildren(wrapper);
+  return wrapper;
+}
diff --git a/packages/ui/utils/codeHighlight.ts b/packages/ui/utils/codeHighlight.ts
index a27d5dee9..a51ea6e88 100644
--- a/packages/ui/utils/codeHighlight.ts
+++ b/packages/ui/utils/codeHighlight.ts
@@ -194,6 +194,39 @@ export function ensureHighlight(lang: string, theme: string): Promise {
 const renderSeq = new WeakMap();
 let seqCounter = 0;
 
+type HighlightSwapListener = (el: HTMLElement) => void;
+const swapListeners = new Set();
+
+/**
+ * Observe every write `applyHighlight` makes to a `` element.
+ *
+ * Each write REPLACES the element's children, which destroys anything the
+ * annotation layer wrapped inside it — a whole-fence `` is
+ * gone the moment the palette changes or the first async grammar attach lands.
+ * Listeners run SYNCHRONOUSLY, immediately after the write, so re-applying a
+ * mark from a listener is ordered by construction rather than by a timer: a
+ * restore that ran before the swap is re-established in the same task the swap
+ * happened in, and a restore that runs after it finds the mark already there.
+ *
+ * Returns an unsubscribe function.
+ */
+export function onCodeHighlightSwap(listener: HighlightSwapListener): () => void {
+  swapListeners.add(listener);
+  return () => {
+    swapListeners.delete(listener);
+  };
+}
+
+function notifyHighlightSwap(el: HTMLElement): void {
+  if (swapListeners.size === 0) return;
+  for (const listener of Array.from(swapListeners)) {
+    // A misbehaving observer must never take syntax highlighting down with it.
+    try {
+      listener(el);
+    } catch {}
+  }
+}
+
 /**
  * Drop-in replacement for `hljs.highlightElement(el)`.
  *
@@ -215,20 +248,25 @@ export function applyHighlight(
   // #1212: a fence with no language stays plain. Never guess.
   if (!lang) {
     el.textContent = code;
+    notifyHighlightSwap(el);
     return;
   }
 
   const immediate = highlightToHtml(code, lang, theme);
   if (immediate !== null) {
     el.innerHTML = immediate;
+    notifyHighlightSwap(el);
     return;
   }
 
   el.textContent = code;
+  notifyHighlightSwap(el);
   void ensureHighlight(lang, theme).then((ok) => {
     if (!ok || renderSeq.get(el) !== seq || !el.isConnected) return;
     const html = highlightToHtml(code, lang, theme);
-    if (html !== null) el.innerHTML = html;
+    if (html === null) return;
+    el.innerHTML = html;
+    notifyHighlightSwap(el);
   });
 }
 
@@ -240,3 +278,16 @@ export function __resetCodeHighlightCacheForTests(): void {
   pierre = undefined;
   pierreLoad = undefined;
 }
+
+/**
+ * Test seam: stand in for `@pierre/diffs` so a test can drive real swaps
+ * (including WHEN the async one lands) without loading Shiki's full bundle.
+ * Pass `undefined` to go back to the real dynamic import.
+ */
+export function __setCodeHighlightModuleForTests(mod: PierreModule | undefined): void {
+  ready.clear();
+  rejected.clear();
+  inflight.clear();
+  pierre = mod;
+  pierreLoad = mod ? Promise.resolve(mod) : undefined;
+}
diff --git a/tests/entry-assets.test.ts b/tests/entry-assets.test.ts
index 168b35380..4eb76f3b3 100644
--- a/tests/entry-assets.test.ts
+++ b/tests/entry-assets.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, test } from 'bun:test';
-import { readFileSync } from 'node:fs';
+import { existsSync, readFileSync } from 'node:fs';
 import { resolve } from 'node:path';
 
 const root = resolve(import.meta.dir, '..');
@@ -51,6 +51,25 @@ describe('review entry assets', () => {
       expect(read(config)).toContain("'shiki/wasm': path.resolve(");
     }
   });
+
+  // The alias assertions above only read SOURCE. A future @pierre/diffs bump
+  // could reach the same inlined blob through a different import specifier and
+  // every source check would still pass, so this reads the ARTIFACT: a base64
+  // WASM module always starts `\0asm\x01\0\0\0`, which encodes with the
+  // `AGFzbQ` prefix regardless of how it got inlined.
+  //
+  // dist/ is gitignored, so this skips cleanly on an unbuilt checkout. The CI
+  // job that builds the bundles runs this file right after the build so the
+  // assertion is not silently optional there.
+  const bundles = ['apps/review/dist/index.html', 'apps/hook/dist/index.html'];
+  test.each(bundles)('%s ships no inlined WebAssembly (skipped if unbuilt)', (path) => {
+    const full = resolve(root, path);
+    if (!existsSync(full)) return;
+    // Asserted on a boolean, not the string: these bundles are ~20MB and a
+    // `toContain` failure would print all of it.
+    const inlinedWasm = readFileSync(full, 'utf8').includes('AGFzbQ');
+    expect({ path, inlinedWasm }).toEqual({ path, inlinedWasm: false });
+  });
 });
 
 describe('marketing embeds', () => {