@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.
- package/HANDOFF.md +140 -10
- package/README.md +9 -5
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +17 -630
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +67 -3
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +121 -9
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +133 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +469 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +6 -4
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +5 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +36 -7
- package/utils/terminalToolsAnnouncement.ts +76 -0
- 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
|
+
}
|