@plannotator/ui 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/HANDOFF.md +140 -10
  2. package/README.md +9 -5
  3. package/components/AnnotationPanel.tsx +244 -13
  4. package/components/CommentPopover.tsx +91 -2
  5. package/components/DiagramBlock.tsx +376 -0
  6. package/components/GraphvizBlock.tsx +22 -597
  7. package/components/HtmlSurfaceControls.tsx +188 -23
  8. package/components/ListMarker.tsx +10 -1
  9. package/components/MermaidBlock.tsx +17 -630
  10. package/components/TableOfContents.tsx +5 -1
  11. package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
  12. package/components/Viewer.tsx +67 -3
  13. package/components/blocks/AlertBlock.tsx +7 -2
  14. package/components/diagram/DiagramCanvas.tsx +431 -0
  15. package/components/diagram/DiagramComposer.tsx +135 -0
  16. package/components/diagram/DiagramOverlay.tsx +215 -0
  17. package/components/diagram/DiagramPopout.tsx +70 -0
  18. package/components/diagram/DiagramSourcePane.tsx +244 -0
  19. package/components/diagram/DiagramViewer.tsx +276 -0
  20. package/components/diagram/anchorClaims.ts +71 -0
  21. package/components/diagram/index.ts +36 -0
  22. package/components/diagram/useDiagramComments.ts +341 -0
  23. package/components/diagram/useDiagramRender.ts +91 -0
  24. package/components/diagram/useDiagramSourceDraft.ts +143 -0
  25. package/components/diagram/useDiagramViewport.ts +156 -0
  26. package/components/html-viewer/HtmlViewer.tsx +32 -0
  27. package/components/html-viewer/bridge-script.asset.js +121 -9
  28. package/components/html-viewer/bridge-script.lite.ts +1 -1
  29. package/components/html-viewer/bridge-script.ts +133 -9
  30. package/components/html-viewer/useHtmlAnnotation.ts +44 -151
  31. package/hooks/useAnnotationHighlighter.ts +469 -14
  32. package/hooks/useLinkedDoc.ts +100 -9
  33. package/package.json +6 -4
  34. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
  35. package/styles.css +1 -1
  36. package/theme.css +62 -0
  37. package/types.ts +5 -0
  38. package/utils/annotationScope.ts +159 -0
  39. package/utils/cssColor.ts +463 -0
  40. package/utils/diagram-anchor-graphviz.ts +143 -0
  41. package/utils/diagram-anchor.ts +401 -0
  42. package/utils/diagram-projection.ts +66 -0
  43. package/utils/diagram-render.ts +668 -0
  44. package/utils/graphviz.ts +93 -0
  45. package/utils/htmlChrome.ts +70 -5
  46. package/utils/htmlLinkNavigation.ts +196 -0
  47. package/utils/mermaid-eager.ts +13 -11
  48. package/utils/mermaid.ts +19 -10
  49. package/utils/mermaidTheme.ts +732 -0
  50. package/utils/parser.ts +36 -7
  51. package/utils/terminalToolsAnnouncement.ts +76 -0
  52. package/components/mermaidSvg.ts +0 -33
@@ -0,0 +1,732 @@
1
+ /**
2
+ * Theme-aware Mermaid configuration.
3
+ *
4
+ * Mermaid diagrams used to render from one static config (`MERMAID_CONFIG` in
5
+ * `./mermaid`: the `dark` base theme plus a slate palette) in every one of
6
+ * Plannotator's palettes and both modes. This module derives the diagram
7
+ * theme from the live CSS tokens instead, the same tokens `ThemeProvider`
8
+ * applies through `theme.css`, so a diagram follows the palette the way a
9
+ * code fence already does (see `syntaxTheme.ts` / `useFenceTheme`).
10
+ *
11
+ * Three layers, each pure below the top one:
12
+ *
13
+ * 1. `readThemeTokens(el)` reads the handful of custom properties the mapping
14
+ * needs off the document (`getComputedStyle`), returning a plain record of
15
+ * raw CSS values, or `undefined` when the tokens are not there at all (a
16
+ * host that never mounted `ThemeProvider` and ships no `theme.css`).
17
+ * 2. `buildMermaidThemeVariables(tokens, mode)` turns that record into the
18
+ * Mermaid base theme (`dark` when the resolved mode is dark, `default`
19
+ * when light) plus a complete `themeVariables` override: general, flowchart,
20
+ * sequence, state, class, ER, requirement, gitGraph, gantt, pie, mindmap /
21
+ * timeline (`cScale*`), journey (`fillType*`), quadrant, xyChart, packet,
22
+ * radar, wardley, venn, architecture and C4 all derive from the same
23
+ * tokens. Every colour handed to Mermaid is an opaque hex string, because
24
+ * Mermaid's colour library does not parse `oklch()`.
25
+ * 3. `applyMermaidTheme(mermaid, key)` is the runtime step `MermaidBlock`
26
+ * calls before every render: `mermaid.initialize` is global state, so it
27
+ * runs only when the `(palette, mode)` key changed since the last apply.
28
+ *
29
+ * Fallback contract (hosts): when no tokens resolve, the runtime keeps the
30
+ * static `MERMAID_CONFIG` it was initialized with, and nothing is
31
+ * re-initialized, so a host that does not use Plannotator's theme tokens
32
+ * renders exactly as before this module existed.
33
+ *
34
+ * Contrast rule (the "guard"): every text-on-fill pair the mapping produces
35
+ * must reach WCAG 4.5:1 and every line-on-canvas pair 3:1 (plus a 0.1
36
+ * headroom, `GUARD_HEADROOM`). Page-level text and lines are guarded against
37
+ * EVERY surface they can cross, not one: the diagram canvas (the block's
38
+ * `bg-muted/30` tint over the document card, and over the bare page for a
39
+ * host that mounts the block there), node fills (`card`), cluster and
40
+ * composite-state fills (`muted`), popovers and ER rows. A pair that falls
41
+ * short is repaired by moving the text (or line) colour toward the mode's
42
+ * `foreground` token, the smallest step that satisfies the ratio so hue is
43
+ * kept where possible; when `foreground` itself cannot reach the ratio on that
44
+ * fill (a light fill in dark mode), the `background` token is used as the ink
45
+ * instead, and when neither token reaches it pure black or white is the last
46
+ * resort (a mid-luminance fill such as the line colour under a sequence
47
+ * number). Ratios are measured on the 8-bit colour Mermaid receives, never on
48
+ * the unrounded mix. Categorical fills (pie slices, branch lines, mindmap
49
+ * sections, journey tasks) are normalized to one lightness per page polarity
50
+ * (0.74 on a dark page, 0.50 on a light one, chroma clamped to 0.06..0.15) so
51
+ * a single ink, the `background` token, reads on all of them; each fill is
52
+ * additionally pushed in lightness until that ink reaches 4.5:1. Polarity is
53
+ * the measured luminance of the `background` token, not the mode label, so a
54
+ * dark-only palette rendered under a light label still gets fills its ink can
55
+ * carry; the mode label only picks the Mermaid base theme. Structural strokes (node, cluster, actor
56
+ * borders) are guaranteed 1.5:1 against the canvas, nudged toward
57
+ * `muted-foreground`, so a palette with a near-invisible `border` still draws
58
+ * node outlines.
59
+ */
60
+ import type { Mermaid, MermaidConfig } from 'mermaid';
61
+ import { MERMAID_CONFIG } from './mermaid';
62
+ import {
63
+ compositeOver,
64
+ contrastRatio,
65
+ hueDistance,
66
+ mixOklab,
67
+ oklchToRgb,
68
+ parseCssColor,
69
+ parseOpaqueColor,
70
+ quantize,
71
+ relativeLuminance,
72
+ rgbToOklch,
73
+ rotateHue,
74
+ toHex,
75
+ withOklchLightness,
76
+ type RgbColor,
77
+ } from './cssColor';
78
+
79
+ export type MermaidThemeMode = 'light' | 'dark';
80
+
81
+ /** The custom properties the mapping reads (without the `--` prefix). */
82
+ export const MERMAID_THEME_TOKEN_NAMES = [
83
+ 'background',
84
+ 'foreground',
85
+ 'card',
86
+ 'card-foreground',
87
+ 'popover',
88
+ 'border',
89
+ 'muted',
90
+ 'muted-foreground',
91
+ 'primary',
92
+ 'primary-foreground',
93
+ 'secondary',
94
+ 'accent',
95
+ 'destructive',
96
+ 'success',
97
+ 'warning',
98
+ 'font-sans',
99
+ ] as const;
100
+
101
+ export type MermaidThemeTokenName = (typeof MERMAID_THEME_TOKEN_NAMES)[number];
102
+
103
+ /** Raw CSS values keyed by token name, as read off the document. */
104
+ export type MermaidThemeTokens = Partial<Record<MermaidThemeTokenName, string>>;
105
+
106
+ export interface MermaidThemeSpec {
107
+ theme: 'dark' | 'default';
108
+ themeVariables: Record<string, unknown>;
109
+ }
110
+
111
+ /** WCAG minimums the guard enforces. */
112
+ export const MERMAID_TEXT_CONTRAST_MIN = 4.5;
113
+ export const MERMAID_LINE_CONTRAST_MIN = 3;
114
+ export const MERMAID_BORDER_CONTRAST_MIN = 1.5;
115
+ /**
116
+ * Headroom the mapping adds over the text and line minimums. This arithmetic
117
+ * composites in float and rounds once; a browser composites the container's
118
+ * `bg-muted/30` tint in its own space, so a pair repaired to exactly 4.5 or
119
+ * 3.0 here can measure a hair under in the rendered SVG.
120
+ */
121
+ const GUARD_HEADROOM = 0.1;
122
+
123
+ /** Target OKLCH lightness of categorical fills per mode (see module doc). */
124
+ const CATEGORICAL_LIGHTNESS: Record<MermaidThemeMode, number> = { dark: 0.74, light: 0.5 };
125
+ const CATEGORICAL_MAX_CHROMA = 0.15;
126
+ const CATEGORICAL_MIN_CHROMA = 0.06;
127
+ const CATEGORICAL_COUNT = 12;
128
+ /** Hues closer than this (degrees) are treated as the same family. */
129
+ const HUE_SEPARATION = 18;
130
+ /** Opacity of the diagram container's `bg-muted/30` tint over the page. */
131
+ const CANVAS_MUTED_ALPHA = 0.3;
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Token reading (browser)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Read the theme tokens off `el` (default: the document element, where
139
+ * `ThemeProvider` puts the `theme-*` / `light` classes). A value the parser
140
+ * does not understand (`color-mix()`, a `var()` chain) is resolved through a
141
+ * throwaway probe element so the engine does the substitution. Returns
142
+ * `undefined` when neither `--background` nor `--foreground` resolves, which
143
+ * is the signal to keep the static config.
144
+ */
145
+ export function readThemeTokens(el?: Element | null): MermaidThemeTokens | undefined {
146
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return undefined;
147
+ const target = el ?? document.documentElement;
148
+ if (!target) return undefined;
149
+ let computed: CSSStyleDeclaration;
150
+ try {
151
+ computed = getComputedStyle(target);
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ const tokens: MermaidThemeTokens = {};
156
+ let probe: HTMLElement | null = null;
157
+ try {
158
+ for (const name of MERMAID_THEME_TOKEN_NAMES) {
159
+ let raw = '';
160
+ try {
161
+ raw = computed.getPropertyValue(`--${name}`).trim();
162
+ } catch {
163
+ raw = '';
164
+ }
165
+ if (!raw) continue;
166
+ if (name === 'font-sans') {
167
+ tokens[name] = raw;
168
+ continue;
169
+ }
170
+ if (parseCssColor(raw)) {
171
+ tokens[name] = raw;
172
+ continue;
173
+ }
174
+ // Unparsable as written: let the engine resolve it to a colour.
175
+ try {
176
+ if (!probe) {
177
+ probe = document.createElement('span');
178
+ probe.setAttribute('aria-hidden', 'true');
179
+ probe.style.position = 'absolute';
180
+ probe.style.width = '0';
181
+ probe.style.height = '0';
182
+ probe.style.overflow = 'hidden';
183
+ probe.style.pointerEvents = 'none';
184
+ target.appendChild(probe);
185
+ }
186
+ probe.style.color = `var(--${name})`;
187
+ const resolved = getComputedStyle(probe).color.trim();
188
+ if (resolved && parseCssColor(resolved)) tokens[name] = resolved;
189
+ } catch {
190
+ // leave the token absent
191
+ }
192
+ }
193
+ } finally {
194
+ probe?.remove();
195
+ }
196
+ if (!tokens.background || !tokens.foreground) return undefined;
197
+ return tokens;
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Mapping (pure)
202
+ // ---------------------------------------------------------------------------
203
+
204
+ interface Palette {
205
+ canvas: RgbColor;
206
+ canvasOnBackground: RgbColor;
207
+ background: RgbColor;
208
+ foreground: RgbColor;
209
+ card: RgbColor;
210
+ cardForeground: RgbColor;
211
+ popover: RgbColor;
212
+ border: RgbColor;
213
+ muted: RgbColor;
214
+ mutedForeground: RgbColor;
215
+ primary: RgbColor;
216
+ primaryForeground: RgbColor;
217
+ secondary: RgbColor;
218
+ accent: RgbColor;
219
+ destructive: RgbColor;
220
+ success: RgbColor;
221
+ warning: RgbColor;
222
+ fontFamily: string | undefined;
223
+ }
224
+
225
+ /**
226
+ * Parse the tokens into opaque colours, filling gaps from the two required
227
+ * ones so the mapping below is total. Alpha (e.g. a `#ffffff99`
228
+ * muted-foreground) is composited over the page background.
229
+ */
230
+ function resolvePalette(tokens: MermaidThemeTokens): Palette | null {
231
+ const background = parseCssColor(tokens.background);
232
+ if (!background) return null;
233
+ const opaqueBackground = compositeOver(background, { r: 1, g: 1, b: 1, a: 1 });
234
+ const foreground = parseOpaqueColor(tokens.foreground, opaqueBackground);
235
+ if (!foreground) return null;
236
+ const over = (value: string | undefined, fallback: RgbColor): RgbColor =>
237
+ parseOpaqueColor(value, opaqueBackground) ?? fallback;
238
+ const towardFg = (t: number): RgbColor => mixOklab(opaqueBackground, foreground, t);
239
+
240
+ const card = over(tokens.card, opaqueBackground);
241
+ const muted = over(tokens.muted, towardFg(0.08));
242
+ const primary = over(tokens.primary, foreground);
243
+ // The block's `bg-muted/30` container sits on the document card in
244
+ // Plannotator (`bg-card` article); a host may place it straight on the page.
245
+ const canvas = compositeOver({ ...muted, a: CANVAS_MUTED_ALPHA }, card);
246
+ const canvasOnBackground = compositeOver({ ...muted, a: CANVAS_MUTED_ALPHA }, opaqueBackground);
247
+ const font = tokens['font-sans']?.trim();
248
+
249
+ return {
250
+ canvas,
251
+ canvasOnBackground,
252
+ background: opaqueBackground,
253
+ foreground,
254
+ card,
255
+ cardForeground: over(tokens['card-foreground'], foreground),
256
+ popover: over(tokens.popover, card),
257
+ border: over(tokens.border, towardFg(0.2)),
258
+ muted,
259
+ mutedForeground: over(tokens['muted-foreground'], towardFg(0.7)),
260
+ primary,
261
+ primaryForeground: over(tokens['primary-foreground'], opaqueBackground),
262
+ secondary: over(tokens.secondary, muted),
263
+ accent: over(tokens.accent, primary),
264
+ destructive: over(tokens.destructive, parseCssColor('#e5484d') as RgbColor),
265
+ success: over(tokens.success, parseCssColor('#3fb950') as RgbColor),
266
+ warning: over(tokens.warning, parseCssColor('#d29922') as RgbColor),
267
+ fontFamily: font || undefined,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Repair `color` against `against` until the pair reaches `min`, by the
273
+ * smallest OKLab step toward the first ink that can reach it (see the module
274
+ * doc for the rule). Returns `color` unchanged when it already passes.
275
+ */
276
+ export function ensureContrast(color: RgbColor, against: RgbColor, min: number, inks: readonly RgbColor[]): RgbColor {
277
+ const start = quantize(color);
278
+ const fill = quantize(against);
279
+ if (contrastRatio(start, fill) >= min) return start;
280
+ let best: RgbColor = start;
281
+ let bestRatio = contrastRatio(start, fill);
282
+ // The tokens first; pure black and white are the last resort for a
283
+ // mid-luminance fill that neither token can carry text on.
284
+ for (const ink of [...inks, BLACK, WHITE]) {
285
+ const inkRatio = contrastRatio(ink, fill);
286
+ if (inkRatio < min) {
287
+ if (inkRatio > bestRatio) {
288
+ best = ink;
289
+ bestRatio = inkRatio;
290
+ }
291
+ continue;
292
+ }
293
+ // Binary search the smallest mix toward this ink that passes, measured
294
+ // on the 8-bit colour Mermaid will receive.
295
+ let lo = 0;
296
+ let hi = 1;
297
+ for (let i = 0; i < 12; i++) {
298
+ const mid = (lo + hi) / 2;
299
+ if (contrastRatio(quantize(mixOklab(start, ink, mid)), fill) >= min) hi = mid;
300
+ else lo = mid;
301
+ }
302
+ return quantize(mixOklab(start, ink, hi));
303
+ }
304
+ return quantize(best);
305
+ }
306
+
307
+ const BLACK: RgbColor = { r: 0, g: 0, b: 0, a: 1 };
308
+ const WHITE: RgbColor = { r: 1, g: 1, b: 1, a: 1 };
309
+
310
+ /**
311
+ * Whether the page reads as dark: the luminance at which black and white
312
+ * text contrast equally (about 0.179). Decided from the background token's
313
+ * measured luminance rather than the mode label, so a dark-only palette that
314
+ * a host renders under a light label still gets fills its ink can carry.
315
+ */
316
+ export function isDarkBackground(background: RgbColor): boolean {
317
+ return relativeLuminance(background) < 0.179;
318
+ }
319
+
320
+ /**
321
+ * Push a fill's lightness away from `ink` until `ink` reads on it at 4.5:1.
322
+ * Used for the categorical scale, whose single ink is fixed per mode.
323
+ */
324
+ function fitFillForInk(fill: RgbColor, ink: RgbColor, polarity: MermaidThemeMode): RgbColor {
325
+ let current = quantize(fill);
326
+ const step = polarity === 'dark' ? 0.02 : -0.02;
327
+ for (let i = 0; i < 24 && contrastRatio(ink, current) < MERMAID_TEXT_CONTRAST_MIN; i++) {
328
+ const L = rgbToOklch(current).L + step;
329
+ if (L < 0.05 || L > 0.98) break;
330
+ current = quantize(withOklchLightness(current, L));
331
+ }
332
+ return current;
333
+ }
334
+
335
+ /**
336
+ * Twelve categorical fills for pie / git / mindmap / journey / timeline,
337
+ * seeded from the palette's own accent tokens (in order: primary, accent,
338
+ * success, warning, destructive, secondary; greys skipped) and filled out with
339
+ * hue rotations of the first seed, all normalized to one lightness and a
340
+ * chroma band so they read as one family and share one ink.
341
+ */
342
+ export function buildCategoricalScale(p: Palette, polarity: MermaidThemeMode): RgbColor[] {
343
+ const targetL = CATEGORICAL_LIGHTNESS[polarity];
344
+ const normalize = (c: RgbColor): RgbColor => {
345
+ const lch = rgbToOklch(c);
346
+ const C = Math.min(CATEGORICAL_MAX_CHROMA, Math.max(CATEGORICAL_MIN_CHROMA, lch.C));
347
+ return { ...oklchToRgb({ L: targetL, C, H: lch.H }), a: 1 };
348
+ };
349
+ const hues: number[] = [];
350
+ const out: RgbColor[] = [];
351
+ const push = (c: RgbColor): void => {
352
+ const h = rgbToOklch(c).H;
353
+ if (hues.some((existing) => hueDistance(existing, h) < HUE_SEPARATION)) return;
354
+ hues.push(h);
355
+ out.push(normalize(c));
356
+ };
357
+ const seeds = [p.primary, p.accent, p.success, p.warning, p.destructive, p.secondary];
358
+ for (const seed of seeds) {
359
+ if (rgbToOklch(seed).C >= 0.05) push(seed);
360
+ if (out.length >= CATEGORICAL_COUNT) break;
361
+ }
362
+ // A grey palette (no chromatic seed) still gets a scale: start from a
363
+ // mid-chroma blue-violet so slices stay tellable apart.
364
+ const base: RgbColor = out.length ? out[0] : { ...withOklchLightness(parseCssColor('#7c7cff') as RgbColor, targetL), a: 1 };
365
+ if (!out.length) push(base);
366
+ for (let k = 1; out.length < CATEGORICAL_COUNT && k < 40; k++) {
367
+ // Golden-angle-ish stepping spreads the rotations before wrapping.
368
+ push(rotateHue(base, (k * 137.5) % 360));
369
+ }
370
+ // Every fill must carry the mode ink at 4.5:1.
371
+ return out.slice(0, CATEGORICAL_COUNT).map((c) => fitFillForInk(c, p.background, polarity));
372
+ }
373
+
374
+ /**
375
+ * Derive the Mermaid base theme and a total `themeVariables` override from
376
+ * the tokens. Returns `null` when the required tokens are missing or
377
+ * unparsable, which callers treat as "use the static config".
378
+ */
379
+ export function buildMermaidThemeVariables(tokens: MermaidThemeTokens | undefined, mode: MermaidThemeMode): MermaidThemeSpec | null {
380
+ if (!tokens) return null;
381
+ const p = resolvePalette(tokens);
382
+ if (!p) return null;
383
+
384
+ // Base theme follows the mode; fill lightness and ink follow the measured page.
385
+ const polarity: MermaidThemeMode = isDarkBackground(p.background) ? 'dark' : 'light';
386
+ const inks = [p.foreground, p.background] as const;
387
+ const rowEven = mixOklab(p.card, p.muted, 0.6);
388
+ // Every surface page-level text and lines can land on: the canvas (over the
389
+ // card, and over the bare page for hosts), node fills, cluster fills, ER
390
+ // rows, popovers. A colour guarded against all of them reads everywhere.
391
+ const surfaces = [p.canvas, p.canvasOnBackground, p.card, p.muted, p.popover, rowEven] as const;
392
+ // A little headroom over the published minimums absorbs the compositing
393
+ // and rounding differences between this arithmetic and a browser's.
394
+ const textMin = MERMAID_TEXT_CONTRAST_MIN + GUARD_HEADROOM;
395
+ const lineMin = MERMAID_LINE_CONTRAST_MIN + GUARD_HEADROOM;
396
+ const text = (color: RgbColor, fill: RgbColor): RgbColor => ensureContrast(color, fill, textMin, inks);
397
+ const textOnSurfaces = (color: RgbColor): RgbColor =>
398
+ surfaces.reduce((c, surface) => ensureContrast(c, surface, textMin, inks), quantize(color));
399
+ const line = (color: RgbColor): RgbColor =>
400
+ [p.canvas, p.canvasOnBackground, p.muted, p.card].reduce((c, surface) => ensureContrast(c, surface, lineMin, inks), quantize(color));
401
+ const stroke = (color: RgbColor): RgbColor =>
402
+ ensureContrast(color, p.canvas, MERMAID_BORDER_CONTRAST_MIN, [p.mutedForeground, p.foreground]);
403
+
404
+ const canvas = p.canvas;
405
+ const fg = textOnSurfaces(p.foreground);
406
+ const cardText = textOnSurfaces(p.cardForeground);
407
+ const mutedText = cardText;
408
+ const popoverText = cardText;
409
+ const lineColor = line(p.mutedForeground);
410
+ const nodeBorder = stroke(p.border);
411
+ const clusterBorder = stroke(p.border);
412
+ const primaryLine = line(p.primary);
413
+ const destructiveLine = line(p.destructive);
414
+ const warningLine = line(p.warning);
415
+ // A note is a card tinted toward the warning token, so it stays a surface
416
+ // its own text reads on rather than a fixed yellow.
417
+ const noteBg = mixOklab(p.card, p.warning, 0.18);
418
+ const noteText = text(p.cardForeground, noteBg);
419
+ const errorBg = mixOklab(p.card, p.destructive, 0.25);
420
+ const errorText = text(p.foreground, errorBg);
421
+ // Ink on a line-coloured shape (sequence numbers sit on `lineColor` discs).
422
+ const onLine = text(p.background, lineColor);
423
+
424
+ const scale = buildCategoricalScale(p, polarity);
425
+ const scaleInk = p.background;
426
+ const scaleHex = scale.map(toHex);
427
+ const scalePeer = scale.map((c) => toHex(mixOklab(c, scaleInk, 0.25)));
428
+ const scaleLabel = scale.map((c) => toHex(text(scaleInk, c)));
429
+
430
+ const h = toHex;
431
+ const vars: Record<string, unknown> = {
432
+ darkMode: mode === 'dark',
433
+ ...(p.fontFamily ? { fontFamily: p.fontFamily } : {}),
434
+
435
+ // General
436
+ background: h(canvas),
437
+ primaryColor: h(p.card),
438
+ primaryTextColor: h(cardText),
439
+ primaryBorderColor: h(nodeBorder),
440
+ secondaryColor: h(p.muted),
441
+ secondaryTextColor: h(mutedText),
442
+ secondaryBorderColor: h(nodeBorder),
443
+ tertiaryColor: h(p.popover),
444
+ tertiaryTextColor: h(popoverText),
445
+ tertiaryBorderColor: h(nodeBorder),
446
+ textColor: h(fg),
447
+ titleColor: h(fg),
448
+ labelColor: h(fg),
449
+ mainContrastColor: h(fg),
450
+ darkTextColor: h(text(p.background, p.foreground)),
451
+ lineColor: h(lineColor),
452
+ arrowheadColor: h(lineColor),
453
+ defaultLinkColor: h(lineColor),
454
+ mainBkg: h(p.card),
455
+ secondBkg: h(p.muted),
456
+ border1: h(nodeBorder),
457
+ border2: h(clusterBorder),
458
+ labelBackground: h(canvas),
459
+ errorBkgColor: h(errorBg),
460
+ errorTextColor: h(errorText),
461
+ useGradient: false,
462
+
463
+ // Flowchart
464
+ nodeBkg: h(p.card),
465
+ nodeBorder: h(nodeBorder),
466
+ nodeTextColor: h(cardText),
467
+ clusterBkg: h(p.muted),
468
+ clusterBorder: h(clusterBorder),
469
+ edgeLabelBackground: h(canvas),
470
+
471
+ // Sequence
472
+ actorBkg: h(p.card),
473
+ actorBorder: h(nodeBorder),
474
+ actorTextColor: h(cardText),
475
+ actorLineColor: h(lineColor),
476
+ signalColor: h(lineColor),
477
+ signalTextColor: h(fg),
478
+ labelBoxBkgColor: h(p.muted),
479
+ // Also strokes the dashed loop/alt frame (`.loopLine`), a real line.
480
+ labelBoxBorderColor: h(lineColor),
481
+ labelTextColor: h(mutedText),
482
+ loopTextColor: h(mutedText),
483
+ noteBkgColor: h(noteBg),
484
+ noteBorderColor: h(warningLine),
485
+ noteTextColor: h(noteText),
486
+ activationBkgColor: h(p.muted),
487
+ activationBorderColor: h(nodeBorder),
488
+ sequenceNumberColor: h(onLine),
489
+
490
+ // State
491
+ stateBkg: h(p.card),
492
+ stateLabelColor: h(cardText),
493
+ transitionColor: h(lineColor),
494
+ transitionLabelColor: h(fg),
495
+ labelBackgroundColor: h(p.card),
496
+ compositeBackground: h(p.muted),
497
+ compositeTitleBackground: h(p.muted),
498
+ compositeBorder: h(nodeBorder),
499
+ altBackground: h(p.card),
500
+ innerEndBackground: h(lineColor),
501
+ specialStateColor: h(lineColor),
502
+
503
+ // Class
504
+ classText: h(cardText),
505
+
506
+ // ER
507
+ attributeBackgroundColorOdd: h(p.card),
508
+ attributeBackgroundColorEven: h(rowEven),
509
+ rowOdd: h(p.card),
510
+ rowEven: h(rowEven),
511
+
512
+ // Requirement
513
+ requirementBackground: h(p.card),
514
+ requirementBorderColor: h(nodeBorder),
515
+ requirementTextColor: h(cardText),
516
+ relationColor: h(lineColor),
517
+ relationLabelBackground: h(canvas),
518
+ relationLabelColor: h(fg),
519
+
520
+ // Git
521
+ commitLabelColor: h(fg),
522
+ commitLabelBackground: h(canvas),
523
+ tagLabelColor: h(cardText),
524
+ tagLabelBackground: h(p.card),
525
+ tagLabelBorder: h(nodeBorder),
526
+
527
+ // Gantt
528
+ sectionBkgColor: h(p.muted),
529
+ altSectionBkgColor: h(canvas),
530
+ sectionBkgColor2: h(p.card),
531
+ excludeBkgColor: h(p.muted),
532
+ taskBorderColor: h(nodeBorder),
533
+ taskBkgColor: scaleHex[0],
534
+ taskTextColor: scaleLabel[0],
535
+ taskTextLightColor: h(fg),
536
+ taskTextDarkColor: scaleLabel[0],
537
+ taskTextOutsideColor: h(fg),
538
+ taskTextClickableColor: h(text(p.primary, canvas)),
539
+ activeTaskBorderColor: h(primaryLine),
540
+ activeTaskBkgColor: scaleHex[1] ?? scaleHex[0],
541
+ gridColor: h(nodeBorder),
542
+ doneTaskBkgColor: h(p.muted),
543
+ doneTaskBorderColor: h(nodeBorder),
544
+ critBorderColor: h(destructiveLine),
545
+ critBkgColor: h(fitFillForInk(withOklchLightness(p.destructive, CATEGORICAL_LIGHTNESS[polarity]), scaleInk, polarity)),
546
+ todayLineColor: h(destructiveLine),
547
+ vertLineColor: h(primaryLine),
548
+
549
+ // Pie
550
+ pieTitleTextColor: h(fg),
551
+ pieSectionTextColor: h(text(scaleInk, scale[0])),
552
+ pieLegendTextColor: h(fg),
553
+ pieStrokeColor: h(canvas),
554
+ pieOuterStrokeColor: h(nodeBorder),
555
+ pieOpacity: '1',
556
+
557
+ // Quadrant
558
+ quadrant1Fill: h(p.card),
559
+ quadrant2Fill: h(p.muted),
560
+ quadrant3Fill: h(p.muted),
561
+ quadrant4Fill: h(p.card),
562
+ quadrant1TextFill: h(cardText),
563
+ quadrant2TextFill: h(mutedText),
564
+ quadrant3TextFill: h(mutedText),
565
+ quadrant4TextFill: h(cardText),
566
+ quadrantPointFill: h(primaryLine),
567
+ quadrantPointTextFill: h(fg),
568
+ quadrantXAxisTextFill: h(fg),
569
+ quadrantYAxisTextFill: h(fg),
570
+ quadrantInternalBorderStrokeFill: h(nodeBorder),
571
+ quadrantExternalBorderStrokeFill: h(nodeBorder),
572
+ quadrantTitleFill: h(fg),
573
+
574
+ // Architecture / C4
575
+ archEdgeColor: h(lineColor),
576
+ archEdgeArrowColor: h(lineColor),
577
+ archGroupBorderColor: h(nodeBorder),
578
+ personBkg: h(p.card),
579
+ personBorder: h(nodeBorder),
580
+
581
+ // Venn
582
+ vennTitleTextColor: h(fg),
583
+ vennSetTextColor: h(fg),
584
+
585
+ // Event modeling
586
+ emUiFill: h(p.card),
587
+ emUiStroke: h(nodeBorder),
588
+ emProcessorFill: scaleHex[3] ?? scaleHex[0],
589
+ emProcessorStroke: h(lineColor),
590
+ emReadModelFill: scaleHex[2] ?? scaleHex[0],
591
+ emReadModelStroke: h(lineColor),
592
+ emCommandFill: scaleHex[0],
593
+ emCommandStroke: h(lineColor),
594
+ emEventFill: scaleHex[1] ?? scaleHex[0],
595
+ emEventStroke: h(lineColor),
596
+ emSwimlaneBackgroundOdd: h(canvas),
597
+ emSwimlaneBackgroundStroke: h(nodeBorder),
598
+ emArrowhead: h(lineColor),
599
+ emRelationStroke: h(lineColor),
600
+
601
+ // Scale-driven families
602
+ scaleLabelColor: scaleLabel[0],
603
+ xyChart: {
604
+ backgroundColor: h(canvas),
605
+ titleColor: h(fg),
606
+ dataLabelColor: h(fg),
607
+ legendTextColor: h(fg),
608
+ xAxisTitleColor: h(fg),
609
+ xAxisLabelColor: h(fg),
610
+ xAxisTickColor: h(lineColor),
611
+ xAxisLineColor: h(lineColor),
612
+ yAxisTitleColor: h(fg),
613
+ yAxisLabelColor: h(fg),
614
+ yAxisTickColor: h(lineColor),
615
+ yAxisLineColor: h(lineColor),
616
+ plotColorPalette: scaleHex.join(','),
617
+ },
618
+ packet: {
619
+ startByteColor: h(fg),
620
+ endByteColor: h(fg),
621
+ labelColor: h(cardText),
622
+ titleColor: h(fg),
623
+ blockStrokeColor: h(nodeBorder),
624
+ blockFillColor: h(p.card),
625
+ },
626
+ radar: {
627
+ axisColor: h(lineColor),
628
+ graticuleColor: h(nodeBorder),
629
+ },
630
+ wardley: {
631
+ backgroundColor: h(canvas),
632
+ axisColor: h(lineColor),
633
+ axisTextColor: h(fg),
634
+ gridColor: h(nodeBorder),
635
+ componentFill: h(p.card),
636
+ componentStroke: h(lineColor),
637
+ componentLabelColor: h(fg),
638
+ linkStroke: h(lineColor),
639
+ evolutionStroke: h(destructiveLine),
640
+ annotationStroke: h(lineColor),
641
+ annotationTextColor: h(fg),
642
+ annotationFill: h(p.card),
643
+ },
644
+ cynefin: {
645
+ boundaryColor: h(lineColor),
646
+ cliffColor: h(destructiveLine),
647
+ arrowColor: h(lineColor),
648
+ textColor: h(fg),
649
+ labelColor: h(fg),
650
+ complexBg: scaleHex[0],
651
+ complicatedBg: scaleHex[1] ?? scaleHex[0],
652
+ chaoticBg: h(fitFillForInk(withOklchLightness(p.destructive, CATEGORICAL_LIGHTNESS[polarity]), scaleInk, polarity)),
653
+ clearBg: h(fitFillForInk(withOklchLightness(p.success, CATEGORICAL_LIGHTNESS[polarity]), scaleInk, polarity)),
654
+ confusionBg: scaleHex[2] ?? scaleHex[0],
655
+ },
656
+ };
657
+
658
+ for (let i = 0; i < CATEGORICAL_COUNT; i++) {
659
+ vars[`cScale${i}`] = scaleHex[i];
660
+ vars[`cScaleInv${i}`] = scaleLabel[i];
661
+ vars[`cScalePeer${i}`] = scalePeer[i];
662
+ vars[`cScaleLabel${i}`] = scaleLabel[i];
663
+ vars[`pie${i + 1}`] = scaleHex[i];
664
+ }
665
+ for (let i = 0; i < 8; i++) {
666
+ vars[`git${i}`] = scaleHex[i];
667
+ vars[`gitInv${i}`] = scaleLabel[i];
668
+ vars[`gitBranchLabel${i}`] = scaleLabel[i];
669
+ vars[`fillType${i}`] = scaleHex[i];
670
+ vars[`venn${i + 1}`] = scaleHex[i];
671
+ }
672
+ return { theme: mode === 'dark' ? 'dark' : 'default', themeVariables: vars };
673
+ }
674
+
675
+ /** The full Mermaid config for a spec: `MERMAID_CONFIG` with the theme swapped. */
676
+ export function buildMermaidConfig(spec: MermaidThemeSpec | null): MermaidConfig {
677
+ if (!spec) return MERMAID_CONFIG;
678
+ return { ...MERMAID_CONFIG, theme: spec.theme, themeVariables: spec.themeVariables };
679
+ }
680
+
681
+ // ---------------------------------------------------------------------------
682
+ // Runtime application (cached by key)
683
+ // ---------------------------------------------------------------------------
684
+
685
+ /** `mode:palette`, the cache key `applyMermaidTheme` compares. */
686
+ export function mermaidThemeKey(colorTheme: string, mode: MermaidThemeMode): string {
687
+ return `${mode}:${colorTheme}`;
688
+ }
689
+
690
+ function modeFromKey(key: string): MermaidThemeMode {
691
+ return key.startsWith('light:') ? 'light' : 'dark';
692
+ }
693
+
694
+ export type MermaidThemeApplyResult = 'unchanged' | 'dynamic' | 'static';
695
+
696
+ let appliedRuntime: Pick<Mermaid, 'initialize'> | null = null;
697
+ let appliedKey: string | null = null;
698
+ let appliedKind: 'dynamic' | 'static' | null = null;
699
+
700
+ /**
701
+ * Initialize `mermaid` for the `(palette, mode)` named by `key`, once per key
702
+ * change. Reads the tokens from `root` (default: the document element). With
703
+ * no tokens the runtime is left on the static config it was initialized with
704
+ * (no `initialize` call at all unless a dynamic theme was applied earlier),
705
+ * which is the fallback contract for hosts.
706
+ */
707
+ export function applyMermaidTheme(
708
+ mermaid: Pick<Mermaid, 'initialize'>,
709
+ key: string,
710
+ root?: Element | null,
711
+ ): MermaidThemeApplyResult {
712
+ if (appliedRuntime === mermaid && appliedKey === key) return 'unchanged';
713
+ const spec = buildMermaidThemeVariables(readThemeTokens(root), modeFromKey(key));
714
+ const sameRuntime = appliedRuntime === mermaid;
715
+ appliedRuntime = mermaid;
716
+ appliedKey = key;
717
+ if (!spec) {
718
+ if (sameRuntime && appliedKind === 'dynamic') mermaid.initialize(MERMAID_CONFIG);
719
+ appliedKind = 'static';
720
+ return 'static';
721
+ }
722
+ mermaid.initialize(buildMermaidConfig(spec));
723
+ appliedKind = 'dynamic';
724
+ return 'dynamic';
725
+ }
726
+
727
+ /** Test hook: forget the last applied key so the next apply re-initializes. */
728
+ export function __resetMermaidThemeForTests(): void {
729
+ appliedRuntime = null;
730
+ appliedKey = null;
731
+ appliedKind = null;
732
+ }