Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ jobs:
packages/ui/components/MarkdownDiff.frozen.test.tsx
packages/ui/components/MarkdownEditor.extensions.test.tsx
packages/ui/components/ThemeProvider.favicon.test.tsx
packages/ui/components/MermaidBlock.theme.test.tsx
packages/ui/components/CommentPopover.skillReferences.test.tsx
packages/ui/components/SkillReferenceMenu.placement.test.tsx
packages/ui/components/sidebar/FileBrowser.test.ts
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,8 @@ There is **one** highlighter in the app: the Shiki instance `@pierre/diffs` alre

**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.

**Diagram theming (Mermaid):** diagrams follow the palette and mode the same way fences do, through ONE dynamic mapping rather than per-palette themes. `packages/ui/utils/mermaidTheme.ts` reads the live CSS tokens off the document element (`readThemeTokens`: `--background`, `--foreground`, `--card`, `--border`, `--muted`, `--muted-foreground`, `--primary`, the accent tokens, `--font-sans`), derives a complete Mermaid `themeVariables` set for every diagram family from them (`buildMermaidThemeVariables(tokens, mode)`, pure; base theme `dark` under a dark resolved mode, `default` under light; node fill from `card`, borders from `border`, edges and arrowheads from `muted-foreground`, text from `foreground`/`card-foreground`, clusters from `muted`, twelve categorical fills for pie/git/mindmap/journey seeded from `primary`, `accent`, `success`, `warning`, `destructive` and normalized to one lightness per page polarity), and `MermaidBlock` runs the global `mermaid.initialize` through `applyMermaidTheme` once per `(palette, mode)` key from `useTheme()` before each render, re-rendering mounted diagrams when the key changes. Every colour handed to Mermaid is opaque hex (its colour library does not read `oklch()`), and a contrast guard (`ensureContrast`) repairs any text-on-fill pair under WCAG 4.5:1 or line-on-canvas pair under 3:1 (plus 0.1 headroom) by the smallest OKLab step toward `foreground`, then `background`, then pure black/white — guarding page-level text and lines against every surface they can cross (the `bg-muted/30` canvas over the document card and over the bare page, `card`, `muted`, `popover`, ER rows), not the page background alone; `mermaidTheme.test.ts` sweeps every palette in `packages/ui/themes` in both modes against that rule, so a new palette cannot regress it. The pure toolkit behind it is `packages/ui/utils/cssColor.ts`. **Host fallback:** with no theme tokens on the document `readThemeTokens` returns `undefined` and the runtime keeps the static `MERMAID_CONFIG` (still `securityLevel: 'strict'`, pinned) with no extra `initialize`, so a host without `ThemeProvider`/`theme.css` renders exactly as before. Do not add per-palette Mermaid themes or per-theme `.mermaid` CSS — extend the token mapping instead. `GraphvizBlock` already rewrites its SVG to `var(--foreground)` / `var(--muted-foreground)` / `var(--muted)` and needs no equivalent.

**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
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/HANDOFF.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Building your own tooltip and removing the built-in double-click reset are host-
The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz needs nothing from you (the block imports the engine inside its render effect and shows the source fence until the SVG lands, as it always did). Mermaid, KaTeX and the dictionary sit behind synchronous slots:

- **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3). The default `import('katex')` is the only runtime mention of `katex` in the package and lives in `utils/math-default-loader` (0.33.0), called only while no loader is registered; a registered loader is never backfilled by it, though a default load already in flight at registration still fills the slot (pre-existing), so register the loader before the first math render. Chunk emission is static, so a bundler still emits that chunk (never requested) unless you alias the module away; see HANDOFF.md "Lazy renderers and eager entries" for the two-line alias. The Mermaid runtime has its own `import("katex")` for `$$` labels, which leaves a second, shared KaTeX chunk in a host build even with the alias; since 0.34.0 a host redirects that one import (for importers inside the `mermaid` package only) to `@plannotator/ui/utils/mermaid-math-slot`, which typesets the labels through your registered renderer, so one KaTeX chunk remains and it is yours. Recipe and measurement in HANDOFF.md, same section. `resetMathRenderer()` empties the slot only and keeps a registered loader (0.34.0); `setMathRendererLoader(null)` is the explicit way back to the package default.
- **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')`; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. Plannotator keeps Mermaid eager by policy so it can never fail separately from the app: `import "@plannotator/ui/utils/mermaid-eager";` in your entry does the same for your bundle. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
- **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')`; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. Plannotator keeps Mermaid eager by policy so it can never fail separately from the app: `import "@plannotator/ui/utils/mermaid-eager";` in your entry does the same for your bundle. **Diagrams follow the colour theme.** Before every render `MermaidBlock` calls `applyMermaidTheme` (`utils/mermaidTheme`), which reads the theme tokens off the document (`--background`, `--foreground`, `--card`, `--border`, `--muted`, `--muted-foreground`, `--primary`, the accent tokens and `--font-sans`; `readThemeTokens`), derives a complete `themeVariables` set for every diagram family from them (`buildMermaidThemeVariables(tokens, mode)`, pure; base theme `dark` under a dark resolved mode, `default` under light; every text-on-fill pair guarded to WCAG 4.5:1 and every line 3:1, rule in the module doc) and runs the global `mermaid.initialize` once per `(palette, mode)` key, re-rendering mounted diagrams when the key changes. The key comes from `useTheme()`, so a host that mounts `ThemeProvider` and ships `theme.css` gets diagrams in its palette with nothing to configure. **Fallback contract:** with no tokens on the document (no `ThemeProvider`, no `theme.css`) `readThemeTokens` returns `undefined`, nothing is re-initialized, and the runtime keeps the static `MERMAID_CONFIG` it was initialized with, so such a host renders byte-identically to 0.39.0. `MERMAID_CONFIG` keeps its value and meaning (`securityLevel: 'strict'` pinned); the new exports are additive. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
- **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`.

Plannotator's own entries import the eager modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`; `mermaid-eager` in the plan editor only, since the review editor never renders a Mermaid block), which is what keeps its single-file builds byte-identical and its portal entry chunk shaped as before; `tests/entry-assets.test.ts` fails if any of them is dropped. See HANDOFF.md "Lazy renderers and eager entries".
Expand Down
192 changes: 192 additions & 0 deletions packages/ui/components/MermaidBlock.theme.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* MermaidBlock follows the active colour theme and mode.
*
* What regresses if these fail:
* - the block renders without first initializing the runtime for the palette
* on screen, so a diagram comes up in the static slate palette;
* - a palette or mode change no longer re-renders an already rendered
* diagram (the code-fence re-highlight pattern), so a user who flips to
* light mode keeps a dark diagram until reload;
* - the `(palette, mode)` cache stops holding, so every block on a page
* re-runs the global `initialize` (or two blocks under one theme run it
* twice);
* - the host fallback breaks: with no theme tokens on the document the block
* must not call `initialize` at all.
*
* The runtime is a stand-in (no real Mermaid); the tokens are injected as a
* stylesheet under the same `theme-*` / `light` classes ThemeProvider sets.
*
* DOM-gated (DOM_TESTS=1).
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Block } from '../types';
import { MermaidBlock, __setMermaidRuntimeLoaderForTests } from './MermaidBlock';
import { ThemeProvider, useTheme } from './ThemeProvider';
import { __resetMermaidThemeForTests } from '../utils/mermaidTheme';
import { resetStorageBackend, setStorageBackend } from '../utils/storage';

const hasDom = typeof document !== 'undefined';

const block: Block = { id: 'themeSweep', type: 'code', language: 'mermaid', content: 'flowchart LR\n A --> B', order: 0, startLine: 1 };
const SVG = '<svg viewBox="0 0 10 10" data-sentinel="diagram"><rect width="10" height="10"/></svg>';

/** `github` is a shipped palette id, so ThemeProvider accepts it; the tokens are ours. */
const TOKENS_CSS = `
.theme-github { --background: #24292e; --foreground: #e1e4e8; --card: #1f2428; --card-foreground: #e1e4e8; --border: #1b1f23; --muted: #2f363d; --muted-foreground: #6a737d; --primary: #58a6ff; }
.theme-github.light { --background: #ffffff; --foreground: #24292e; --card: #f6f8fa; --card-foreground: #24292e; --border: #e1e4e8; --muted: #f6f8fa; --muted-foreground: #6a737d; --primary: #0366d6; }
`;

interface Recorded {
initialize: unknown[];
renders: number;
}

function fakeRuntime(): { runtime: any; recorded: Recorded } {
const recorded: Recorded = { initialize: [], renders: 0 };
const runtime = {
initialize(config: unknown) {
recorded.initialize.push(config);
},
async render() {
recorded.renders += 1;
return { svg: SVG };
},
};
return { runtime, recorded };
}

let root: Root | null = null;
let host: HTMLElement | null = null;
let styleEl: HTMLStyleElement | null = null;
const stored = new Map<string, string>();
let setModeFromTest: ((mode: 'dark' | 'light') => void) | null = null;

function ModeHandle(): null {
const { setMode } = useTheme();
setModeFromTest = setMode;
return null;
}

async function mount(children: React.ReactNode, withProvider = true): Promise<void> {
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
await act(async () => {
root!.render(
withProvider ? (
<ThemeProvider defaultTheme="dark" defaultColorTheme="github">
<ModeHandle />
{children}
</ThemeProvider>
) : (
<>{children}</>
),
);
});
}

async function settle(): Promise<void> {
// The render effect awaits the (already resolved) runtime, then the render.
for (let i = 0; i < 4; i++) {
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
}
}

function svgCount(): number {
return host?.querySelectorAll('svg[data-sentinel="diagram"]').length ?? 0;
}

describe('MermaidBlock theming', () => {
beforeEach(() => {
if (!hasDom) return;
stored.clear();
setStorageBackend({
getItem: (key) => stored.get(key) ?? null,
setItem: (key, value) => {
stored.set(key, value);
},
removeItem: (key) => {
stored.delete(key);
},
});
__resetMermaidThemeForTests();
styleEl = document.createElement('style');
styleEl.textContent = TOKENS_CSS;
document.head.appendChild(styleEl);
});

afterEach(async () => {
if (!hasDom) return;
if (root) {
await act(async () => {
root!.unmount();
});
}
root = null;
host?.remove();
host = null;
styleEl?.remove();
styleEl = null;
setModeFromTest = null;
for (const cls of Array.from(document.documentElement.classList)) {
if (cls.startsWith('theme-') || cls === 'light') document.documentElement.classList.remove(cls);
}
__setMermaidRuntimeLoaderForTests(undefined);
__resetMermaidThemeForTests();
resetStorageBackend();
});

test.skipIf(!hasDom)('initializes for the palette on screen before rendering, and re-renders on a mode change without a second initialize per block', async () => {
const { runtime, recorded } = fakeRuntime();
__setMermaidRuntimeLoaderForTests(async () => runtime, { retryDelayMs: 5 });

await mount(
<>
<MermaidBlock block={block} />
<MermaidBlock block={{ ...block, id: 'themeSweepTwo' }} />
</>,
);
await settle();

expect(svgCount()).toBe(2);
expect(recorded.renders).toBe(2);
// One initialize for two blocks under one (palette, mode).
expect(recorded.initialize).toHaveLength(1);
const dark = recorded.initialize[0] as { theme: string; themeVariables: Record<string, string>; securityLevel: string };
expect(dark.theme).toBe('dark');
expect(dark.securityLevel).toBe('strict');
expect(dark.themeVariables.nodeBkg).toBe('#1f2428');

await act(async () => {
setModeFromTest!('light');
});
await settle();

expect(document.documentElement.classList.contains('light')).toBe(true);
expect(recorded.initialize).toHaveLength(2);
const light = recorded.initialize[1] as { theme: string; themeVariables: Record<string, string> };
expect(light.theme).toBe('default');
expect(light.themeVariables.nodeBkg).toBe('#f6f8fa');
// Both diagrams were re-rendered under the new theme.
expect(recorded.renders).toBe(4);
expect(svgCount()).toBe(2);
});

test.skipIf(!hasDom)('without theme tokens on the document the runtime is never re-initialized (host fallback)', async () => {
styleEl?.remove();
styleEl = null;
const { runtime, recorded } = fakeRuntime();
__setMermaidRuntimeLoaderForTests(async () => runtime, { retryDelayMs: 5 });

await mount(<MermaidBlock block={block} />, false);
await settle();

expect(svgCount()).toBe(1);
expect(recorded.renders).toBe(1);
expect(recorded.initialize).toEqual([]);
});
});
14 changes: 13 additions & 1 deletion packages/ui/components/MermaidBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
} from '../utils/mermaid';
import { loadMathRenderer } from '../utils/math';
import { hasMermaidMath } from '../utils/mermaid-math-slot';
import { applyMermaidTheme, mermaidThemeKey } from '../utils/mermaidTheme';
import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
import { useTheme } from './ThemeProvider';

/** One Retry re-attempts every block whose runtime import failed (see utils/runtimeRetry). */
const mermaidRetryEpoch = createRuntimeRetryEpoch();
Expand Down Expand Up @@ -139,6 +141,14 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
const [retryToken, setRetryToken] = useState(0);
const [showSource, setShowSource] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// The (palette, mode) the diagram must follow: the same resolution the
// code fences use (see useFenceTheme). Outside a ThemeProvider the default
// context yields the Plannotator dark pair, and with no theme tokens on the
// document `applyMermaidTheme` keeps the static config, so a host without
// the provider renders exactly as before. A key change re-runs the render
// effect below, which is what re-themes an already rendered diagram.
const { colorTheme, resolvedMode } = useTheme();
const themeKey = mermaidThemeKey(colorTheme, resolvedMode === 'light' ? 'light' : 'dark');
// A sibling's Retry re-attempts this block too, but only while its own
// failure was the shared runtime import; a healthy block or a diagram
// syntax error is left alone.
Expand Down Expand Up @@ -238,6 +248,8 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
}
if (cancelled) return;
}
// Global initialize, once per (palette, mode) change, before render.
applyMermaidTheme(mermaid, themeKey);
const id = `mermaid-${block.id}`;
const { svg: renderedSvg } = await mermaid.render(id, block.content);
if (!cancelled) {
Expand All @@ -261,7 +273,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
return () => {
cancelled = true;
};
}, [block.content, block.id, retryToken]);
}, [block.content, block.id, retryToken, themeKey]);

// Reset zoom and pan when content changes
useEffect(() => {
Expand Down
Loading