diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed5f0fa5e..45d8f052a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 3b2d1654f..ebc51b9a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index 780dc94a6..cc46b3abd 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -219,6 +219,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r | `utils/mermaid-math-slot` | Alias target only: what a host redirects Mermaid's own `katex` import to, so `$$` labels in diagrams typeset through the math slot and the host build carries one KaTeX chunk. Never import it yourself. See "Lazy renderers and eager entries", item 2. | | `utils/identity-tater` | Side-effect entry that registers the full username dictionary into the identity generator slot. Import it only if you rely on the default tater names and want the full dictionary; a host with `identityProvider` should not. | | `utils/mermaid` (`loadMermaidRuntime`, `getMermaidRuntime`, `getMermaidRuntimeSource`, `setMermaidRuntime`, `MERMAID_CONFIG`) and `utils/mermaid-eager` | The Mermaid runtime slot and its eager registration. Import `utils/mermaid-eager` to keep Mermaid in your entry chunk as Plannotator does; omit it for the lazy path with retry. See "Lazy renderers and eager entries". | +| `utils/mermaidTheme` (`buildMermaidThemeVariables`, `readThemeTokens`, `applyMermaidTheme`, `mermaidThemeKey`, `buildMermaidConfig`, `ensureContrast`) and `utils/cssColor` | Theme-aware diagram configuration: the pure token-to-`themeVariables` mapping with its contrast guard, the document token reader, and the cached per-`(palette, mode)` `initialize` step `MermaidBlock` runs before each render. Additive; with no theme tokens on the document the static `MERMAID_CONFIG` stays in force. See "Theme-aware Mermaid diagrams (next publish)". | **AI is fully avoidable** — with one precision worth knowing. No AI *UI* is reachable from the supported components: `useAIChat` is imported only by `components/ai/DocumentAIChatPanel` and `useAIProviderConfig`, neither of which any supported component imports, and `CommentPopover`'s Ask-AI affordance exists only behind the optional `onAskAI` prop. `configure.ts` does statically import the `useAIChat` module (it needs `setAITransport`), but if you never use AI the hook is dead code and bundlers eliminate it — verified empirically: a standalone consumer's production bundle importing the full supported surface contains zero `/api/ai` strings. Don't import `components/ai/*` and don't pass `aiTransport`, and you ship no AI code. @@ -676,8 +677,23 @@ Additive only, but required: `@plannotator/ui` 0.32.0 imports the new `@plannota --- +## Theme-aware Mermaid diagrams (next publish) + +Mermaid diagrams used to render from one static config in every palette and both modes: `MERMAID_CONFIG` pinned Mermaid's `dark` base theme plus a slate `themeVariables` palette, so a diagram was blue-on-slate under GitHub Light and Catppuccin alike. Diagrams now follow the active colour theme and mode the way code fences already do (`resolveFenceTheme` / `useFenceTheme`), through ONE dynamic mapping rather than per-palette themes. + +**How it works.** `utils/mermaidTheme` has three layers. `readThemeTokens(el?)` reads the theme custom properties off the document element (`--background`, `--foreground`, `--card`, `--card-foreground`, `--popover`, `--border`, `--muted`, `--muted-foreground`, `--primary`, `--primary-foreground`, `--secondary`, `--accent`, `--destructive`, `--success`, `--warning`, `--font-sans`) via `getComputedStyle`, resolving anything the pure parser cannot read as written (`color-mix()`, a `var()` chain) through a throwaway probe element, and returns `undefined` when neither `--background` nor `--foreground` resolves. `buildMermaidThemeVariables(tokens, mode)` is pure: it parses the tokens (hex, `rgb()`, `hsl()`, `oklch()`, `oklab()`, `lab()`, `lch()`, `color()`; alpha composited over the background, output always opaque hex because Mermaid's colour library does not read `oklch()`), fills any missing optional token from the two required ones, and returns `{ theme, themeVariables }` — base theme `dark` when the resolved mode is dark, `default` when light, with a complete override for every documented family: general, flowchart, sequence (`actor*`, `signal*`, `note*`, `activation*`, `labelBox*`, `sequenceNumberColor`), state, class, ER (`attributeBackgroundColor*`, `rowOdd/Even`), requirement, gitGraph (`git0..7`, `gitInv*`, `gitBranchLabel*`, `commitLabel*`, `tagLabel*`), gantt, pie (`pie1..12`, `pieOpacity: 1`), the `cScale*` scale behind mindmap/timeline, journey (`fillType0..7`), quadrant, venn, architecture, C4, plus the nested `xyChart`, `packet`, `radar`, `wardley`, `cynefin` objects. `applyMermaidTheme(mermaid, key, root?)` is the runtime step `MermaidBlock` runs before every render: `mermaid.initialize` is global, so it runs only when the `(palette, mode)` key (`mermaidThemeKey(colorTheme, mode)`, from `useTheme()`) or the runtime object changed since the last apply; a key change also re-runs the block's render effect, which is what re-themes an already rendered diagram (the fence re-highlight pattern). + +**Token → variable mapping** (the canvas is `muted` at 30% over `card`, matching the block container's `bg-muted/30`; the same mix over `background` is also checked as a surface, since the container can sit on either): `background`/`labelBackground`/`edgeLabelBackground`/`commitLabelBackground`/`relationLabelBackground`/`altSectionBkgColor` ← canvas; node/actor/state/entity/requirement/person fills (`primaryColor`, `mainBkg`, `nodeBkg`, `actorBkg`, `stateBkg`, `requirementBackground`, `tagLabelBackground`, `attributeBackgroundColorOdd`, `rowOdd`) ← `card`; their text (`primaryTextColor`, `nodeTextColor`, `actorTextColor`, `stateLabelColor`, `classText`, `requirementTextColor`, `tagLabelColor`) ← `card-foreground`; borders (`primaryBorderColor`, `nodeBorder`, `border1`, `actorBorder`, `activationBorderColor`, `compositeBorder`, `taskBorderColor`, `gridColor`, `pieOuterStrokeColor`, `archGroupBorderColor`, `quadrant*BorderStrokeFill`) ← `border`; edges and arrowheads (`lineColor`, `arrowheadColor`, `defaultLinkColor`, `signalColor`, `actorLineColor`, `labelBoxBorderColor`, `transitionColor`, `relationColor`, `archEdge*`, `innerEndBackground`, `specialStateColor`) ← `muted-foreground`; page text (`textColor`, `titleColor`, `labelColor`, `signalTextColor`, `transitionLabelColor`, `commitLabelColor`, `pieTitleTextColor`, `pieLegendTextColor`, `taskTextOutsideColor`, quadrant/xyChart/wardley text) ← `foreground`; cluster/subgraph, composite state, label box, activation and gantt section fills (`clusterBkg`, `secondaryColor`, `secondBkg`, `compositeBackground`, `labelBoxBkgColor`, `activationBkgColor`, `sectionBkgColor`, `doneTaskBkgColor`) ← `muted`; `tertiaryColor` ← `popover`; notes ← `card` tinted 18% toward `warning` with a `warning` border; error fills ← `card` tinted toward `destructive`; accents (`activeTaskBorderColor`, `vertLineColor`, `quadrantPointFill`, `taskTextClickableColor`) ← `primary`; `todayLineColor`/`critBorderColor` ← `destructive`. The twelve categorical fills (`cScale0..11`, `pie1..12`, `git0..7`, `fillType0..7`, `venn1..8`, `taskBkgColor`, `activeTaskBkgColor`, `xyChart.plotColorPalette`) are seeded from the palette's own accent tokens in the order `primary`, `accent`, `success`, `warning`, `destructive`, `secondary` (greys skipped, hues closer than 18° merged) and completed with hue rotations of the first seed, all normalized to one OKLCH lightness per page polarity (0.74 on a dark page, 0.50 on a light one; chroma clamped to 0.06..0.15) so one ink — the `background` token — reads on all of them (`cScaleLabel*`, `gitBranchLabel*`, `gitInv*`, `pieSectionTextColor`). `fontFamily` ← `--font-sans` when present. `darkMode` follows the mode. + +**Contrast rule.** Every text-on-fill pair the mapping produces must reach WCAG 4.5:1 and every line-on-canvas pair 3:1 (`ensureContrast`; the mapping adds 0.1 of headroom because a browser composites the container tint in its own space). Page-level text and lines are guarded against every surface they can cross rather than one: the canvas (the block's `bg-muted/30` tint over the document card, which is where Plannotator's article puts it, and over the bare page for a host that mounts the block there), node fills (`card`), cluster and composite-state fills (`muted`), popovers and ER rows — the first sweep found edges at 2.4–2.9:1 and cluster titles at 4.0:1 in 20 palettes precisely because they had been guarded against the page background alone. A pair that falls short is repaired by moving the text or line colour toward the mode's `foreground` token by the smallest OKLab step that satisfies the ratio (hue is kept where possible); when `foreground` cannot reach the ratio on that fill, the `background` token is used as the ink; when neither token can, pure black or white is the last resort (a mid-luminance fill such as the line colour under a sequence number); ratios are measured on the 8-bit colour Mermaid receives. Categorical fills are additionally pushed in lightness until the `background` ink reaches 4.5:1 on each. Structural strokes (node, cluster, actor borders) are guaranteed 1.5:1 against the canvas, nudged toward `muted-foreground`, so a palette with a near-invisible `border` still draws outlines. Page polarity (which lightness the fills are normalized to) is decided from the measured luminance of the `background` token, not from the mode label, so a dark-only palette rendered under a light label still gets fills its ink can carry; the label only picks the Mermaid base theme. `packages/ui/utils/mermaidTheme.test.ts` sweeps every palette in `packages/ui/themes` in both modes against these pairs, so a new palette cannot regress the guard. + +**Fallback contract for hosts.** Nothing changes for a host that does not use the tokens: with no `--background`/`--foreground` on the document (no `ThemeProvider`, no `theme.css`), `readThemeTokens` returns `undefined`, `buildMermaidThemeVariables` returns `null`, `buildMermaidConfig(null)` is `MERMAID_CONFIG` itself, and `applyMermaidTheme` records the key without calling `initialize` at all, so the runtime keeps the static config the loader or the eager entry initialized it with and renders byte-identically to 0.39.0 (pinned by `utils/mermaidTheme.test.ts` and `components/MermaidBlock.theme.test.tsx`). Outside a `ThemeProvider`, `useTheme()` yields the default context (Plannotator dark), which only names the key. `MERMAID_CONFIG` keeps its value and meaning (`securityLevel: 'strict'` still pinned by `components/MermaidBlock.test.ts`; `flowchart.htmlLabels` and `curve` are carried into the dynamic config unchanged) and `loadMermaidRuntime` / the eager entry are untouched: the runtime is still initialized once at registration, and the theme apply is a second, cached `initialize` on top. A host that ships its own tokens under the same names gets themed diagrams for free; a host that wants the old slate look in a themed document can keep the tokens off the diagram's ancestors, since `readThemeTokens` reads the document element by default. New exports are additive; the only behaviour change is for documents that carry the tokens, where diagrams now follow them. + +**Known limits.** `GraphvizBlock` already maps its output to `var(--foreground)` / `var(--muted-foreground)` / `var(--muted)` and needs nothing. Mermaid hardcodes a `#000000` stroke on the sequence `crosshead` marker (lost messages, `-x`), which no theme variable reaches; it stays as in every Mermaid theme. A Mermaid 12 upgrade is a separate follow-up that reuses this mapping unchanged. + ## Publishing & versioning +- **Unreleased (next `@plannotator/ui` publish, minor bump):** theme-aware Mermaid diagrams. New additive exports `utils/mermaidTheme` (`buildMermaidThemeVariables`, `readThemeTokens`, `applyMermaidTheme`, `mermaidThemeKey`, `buildMermaidConfig`, `ensureContrast`, `isDarkBackground`, `MERMAID_THEME_TOKEN_NAMES`) and `utils/cssColor` (parser + OKLab/contrast toolkit). `MermaidBlock` now calls `useTheme()` and `applyMermaidTheme` before each render; `MERMAID_CONFIG`, `loadMermaidRuntime`, `mermaid-eager` and `securityLevel: 'strict'` are unchanged. A host whose document carries no theme tokens renders diagrams byte-identically to 0.39.0; a host that mounts `ThemeProvider` with `theme.css` gets diagrams in its palette and mode with no configuration. No new peer dependencies; core unchanged. See "Theme-aware Mermaid diagrams (next publish)". - The current pair is `@plannotator/ui` `0.39.0` on `@plannotator/core` `0.25.2` (core unchanged; nothing under `packages/core` moved). UI 0.39.0 adds **element context** to raw-HTML and live-app pinpoint annotations (#1517, #1520): a new optional `Annotation.elementContext` (`HtmlElementContext` in `@plannotator/ui/types`) and `HtmlAnnotationTarget.context`, captured by the bridge at click time (tag, id, author classes, ancestor `path`, `role`, accessible `name`, an allowlisted `attrs` set with href/src scrubbed of query and fragment, rendered `text`, an adaptive collapsed HTML `outline`, child count, viewport `rect`, nearest `landmark` and `heading`, a `component` hint, and in live-app sessions `page`), hard-capped at 2 KiB serialized per primary and 1 KiB per extra target, and re-validated at the parent trust boundary by the new `parseHtmlElementContext` export of `@plannotator/ui/components/html-viewer`. New helpers on `@plannotator/ui/utils/parser`: `elementContextExportBlock(ann, { includeRoute })` (the fenced skeleton plus selector/path/role/name/attrs/text/box/near lines the full export now prints under a context-bearing comment) and `exportAnnotationEntry(ann, { includeRoute })` (one annotation as a standalone feedback entry, a pure helper for hosts; `AnnotationPanel`'s card chrome is unchanged from 0.38.2). The field is purely descriptive: `HtmlElementAnchor` and restore are untouched, no `BRIDGE_PROTOCOL_VERSION` bump, share links drop it like anchors, annotations without it export byte-identically, and the repaint path posts only anchors to the bridge. **Host persistence gap, tracked as #1521**: `@plannotator/core/html-anchor` (`buildPersistedHtmlAnchor`, `projectHostThreads`) does not carry `elementContext` yet, so a host persisting through those helpers drops it on save; until #1521 lands, hosts that want it must persist and project the field themselves. Peer ranges are unchanged from 0.38.2: `react` / `react-dom` `^19.2.3`, `tailwindcss` as before, and `@codemirror/state ^6.7.2` beside `@codemirror/view ^6.43.10`. Decision-control change in the same window (#1516): the header primary reads `Send Feedback` / `Post Comments` with no inline count (`DecisionPrimary.count` removed; internal, not host-supported surface). - The previous pair was `@plannotator/ui` `0.38.2` on `@plannotator/core` `0.25.2`. UI 0.38.2 keeps the type word in a titled alert's accessible name through a visually hidden `sr-only` span before the title instead of an `aria-label` on the title row (naming a generic `div` is prohibited by ARIA and WebKit drops it, so VoiceOver on Safari read only the bold title in 0.38.1), and loosens the React peer back to `^19.2.3` (0.38.1 declared `^19.2.8` only because the dependency batch moved it; nothing in the package needs a newer API). **Do not consume ui 0.38.0**: it imports `@plannotator/core/token-hover` (the hover-card trigger settings, #1462) but pins core 0.25.1, which never exported that subpath, so it fails to compile in any consumer; 0.38.1 is the same UI pinning core 0.25.2, which publishes `./token-hover`, the rotated `guide-viewer-manifest` pin, and the `config-types` hover fields (core 0.25.2 is the first core publish since 0.25.1 even though those changes landed over several releases; the package smoke now diffs the UI's core imports against the registry so an unpublished core subpath fails preflight instead of the consumer). UI 0.38.1 also aligns `@codemirror/state` to `^6.7.2` beside `@codemirror/view ^6.43.10`, so a consumer can no longer resolve two state copies. UI 0.38.0 also renders a GitHub alert's bold-only first body line as its title on the icon row (an emoji on that line becomes the icon; `` is stripped and resolved through the new `alertIconRenderer` seam, null by default; grammar in `utils/alertTitle`, importable by a host editor so it writes the bytes the reader parses; a fenced code block inside an alert body still renders as text, deferred because nesting a `CodeBlock` inside a block interacts with the positional annotation anchors and needs its own design). UI 0.38.0 carries the whole unified decision-control stack: the internal primitives (`DecisionControl`, `utils/decisionSpec`, `hooks/useDismissablePopover` — not host-supported surface, see the unsupported list; `useDismissablePopover` also replaced the hand-rolled dismissal inside `ActionMenu`/`ApproveDropdown`, both likewise unsupported) plus one blessed-barrel addition: `decisionControlShortcuts` on `@plannotator/ui/shortcuts` (pure scope data, fetch-free, same contract as the other scopes). The removal of `ToolbarButtons`' platform-mode `muted` prop is internal — `ToolbarButtons` is not host-supported surface. UI 0.37.0 added the Viewer-owned document-header seam (a new public API, hence the minor bump; 0.36.1 was reserved for it but never published) while retaining the `hideQuickLabel` and `StickyHeaderLane` seams from the 0.35.x and 0.36.0 releases; core 0.25.1 publishes the `annotation-threads` subpath already used by `AnnotationPanel` and `utils/parser`, and UI pins that corrected core exactly. - Recent pairs, for the consumer's install matrix: ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only), and ui 0.35.2, ui 0.36.0, and ui 0.37.0 on core 0.25.1 (0.36.1 was never published), and ui 0.38.1, ui 0.38.2, and ui 0.39.0 on core 0.25.2. Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export. Do not consume ui 0.38.0 because its exact core 0.25.1 dependency lacks the `token-hover` export. diff --git a/packages/ui/README.md b/packages/ui/README.md index c3a676765..100b421dd 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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". diff --git a/packages/ui/components/MermaidBlock.theme.test.tsx b/packages/ui/components/MermaidBlock.theme.test.tsx new file mode 100644 index 000000000..fa898ca05 --- /dev/null +++ b/packages/ui/components/MermaidBlock.theme.test.tsx @@ -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 = ''; + +/** `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(); +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 { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root!.render( + withProvider ? ( + + + {children} + + ) : ( + <>{children} + ), + ); + }); +} + +async function settle(): Promise { + // 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( + <> + + + , + ); + 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; 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 }; + 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(, false); + await settle(); + + expect(svgCount()).toBe(1); + expect(recorded.renders).toBe(1); + expect(recorded.initialize).toEqual([]); + }); +}); diff --git a/packages/ui/components/MermaidBlock.tsx b/packages/ui/components/MermaidBlock.tsx index 835c43dd9..baa862b80 100644 --- a/packages/ui/components/MermaidBlock.tsx +++ b/packages/ui/components/MermaidBlock.tsx @@ -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(); @@ -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. @@ -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) { @@ -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(() => { diff --git a/packages/ui/utils/cssColor.test.ts b/packages/ui/utils/cssColor.test.ts new file mode 100644 index 000000000..74e80a434 --- /dev/null +++ b/packages/ui/utils/cssColor.test.ts @@ -0,0 +1,97 @@ +/** + * The colour toolkit `mermaidTheme.ts` is built on. + * + * What regresses if these fail: a theme token written in a syntax the parser + * mis-reads (an `oklch()` palette, an alpha hex like `#ffffff99`, a browser's + * computed `color(srgb ...)`) would silently produce a wrong diagram colour or + * a wrong contrast verdict, and the guard would then pass unreadable pairs. + * No DOM required. + */ +import { describe, expect, test } from 'bun:test'; +import { + compositeOver, + contrastRatio, + mixOklab, + parseCssColor, + parseOpaqueColor, + rgbToOklch, + toHex, + withOklchLightness, +} from './cssColor'; + +const hex = (value: string): string => toHex(parseCssColor(value)!); + +describe('parseCssColor', () => { + test('reads every syntax the palettes and browsers emit', () => { + expect(hex('#abc')).toBe('#aabbcc'); + expect(hex('#AABBCC')).toBe('#aabbcc'); + expect(hex('rgb(255, 0, 0)')).toBe('#ff0000'); + expect(hex('rgb(255 0 0 / 50%)')).toBe('#ff0000'); + expect(parseCssColor('rgb(255 0 0 / 50%)')!.a).toBeCloseTo(0.5, 5); + expect(hex('rgba(0, 0, 255, 0.5)')).toBe('#0000ff'); + expect(hex('hsl(120 100% 50%)')).toBe('#00ff00'); + expect(hex('white')).toBe('#ffffff'); + expect(hex('color(srgb 1 0 0)')).toBe('#ff0000'); + expect(hex('color(srgb-linear 1 1 1)')).toBe('#ffffff'); + // oklch white / black are the anchors of the transform. + expect(hex('oklch(1 0 0)')).toBe('#ffffff'); + expect(hex('oklch(0 0 0)')).toBe('#000000'); + // Plannotator's own dark background; the round trip through OKLab must + // land within one 8-bit step of what Chrome computes for it. + const plannotatorBg = parseCssColor('oklch(0.15 0.02 260)')!; + const backAgain = rgbToOklch(plannotatorBg); + expect(backAgain.L).toBeCloseTo(0.15, 2); + expect(backAgain.H).toBeCloseTo(260, 0); + expect(hex('oklab(0.5 0 0)')).toBe(hex('oklch(0.5 0 0)')); + }); + + test('alpha hex keeps its alpha and composites over a backdrop', () => { + const parsed = parseCssColor('#ffffff99')!; + expect(parsed.a).toBeCloseTo(0x99 / 255, 5); + const over = parseOpaqueColor('#ffffff99', parseCssColor('#000000')!)!; + expect(over.a).toBe(1); + expect(toHex(over)).toBe('#999999'); + expect(toHex(compositeOver({ r: 1, g: 0, b: 0, a: 0 }, parseCssColor('#123456')!))).toBe('#123456'); + }); + + test('rejects what only the engine can resolve', () => { + expect(parseCssColor('var(--background)')).toBeUndefined(); + expect(parseCssColor('color-mix(in oklch, #fff 40%, #000)')).toBeUndefined(); + expect(parseCssColor('')).toBeUndefined(); + expect(parseCssColor(undefined)).toBeUndefined(); + expect(parseCssColor('#12')).toBeUndefined(); + expect(parseCssColor('rgb(1, 2)')).toBeUndefined(); + }); +}); + +describe('contrast and mixing', () => { + test('WCAG anchors', () => { + const white = parseCssColor('#ffffff')!; + const black = parseCssColor('#000000')!; + expect(contrastRatio(white, black)).toBeCloseTo(21, 5); + expect(contrastRatio(black, white)).toBeCloseTo(21, 5); + expect(contrastRatio(white, white)).toBeCloseTo(1, 5); + // #767676 on white is the canonical 4.54:1 AA boundary. + expect(contrastRatio(parseCssColor('#767676')!, white)).toBeCloseTo(4.54, 1); + }); + + test('mixOklab is anchored at its endpoints and monotone in lightness', () => { + const a = parseCssColor('#202020')!; + const b = parseCssColor('#e0e0e0')!; + expect(toHex(mixOklab(a, b, 0))).toBe('#202020'); + expect(toHex(mixOklab(a, b, 1))).toBe('#e0e0e0'); + const quarter = rgbToOklch(mixOklab(a, b, 0.25)).L; + const half = rgbToOklch(mixOklab(a, b, 0.5)).L; + expect(quarter).toBeGreaterThan(rgbToOklch(a).L); + expect(half).toBeGreaterThan(quarter); + }); + + test('withOklchLightness keeps hue and lands on the requested lightness', () => { + const blue = parseCssColor('#3b82f6')!; + const lifted = withOklchLightness(blue, 0.8); + expect(rgbToOklch(lifted).L).toBeCloseTo(0.8, 1); + // Lifting a saturated blue clips against the sRGB gamut, which bends hue a + // little; the family must still read as the same blue. + expect(Math.abs(rgbToOklch(lifted).H - rgbToOklch(blue).H)).toBeLessThan(15); + }); +}); diff --git a/packages/ui/utils/cssColor.ts b/packages/ui/utils/cssColor.ts new file mode 100644 index 000000000..cfaad3531 --- /dev/null +++ b/packages/ui/utils/cssColor.ts @@ -0,0 +1,463 @@ +/** + * Small, dependency-free CSS colour toolkit for theme-derived rendering. + * + * Plannotator's palettes write their tokens as hex, `rgb()` and `oklch()` + * (see `packages/ui/themes/*.css`), and a browser's computed value for a + * colour can also come back as `oklab()`, `lab()`, `lch()`, `hsl()` or + * `color(srgb ...)`. Mermaid's own colour library (khroma) understands only + * the legacy syntaxes, so anything handed to it as a theme variable must first + * be reduced to an opaque hex string. That reduction, plus the perceptual + * mixing and the WCAG contrast arithmetic the diagram theme is built on, live + * here so `mermaidTheme.ts` reads as a mapping rather than as colour math. + * + * Browser-free on purpose: every function is pure and runs under plain `bun + * test`, which is what lets the contrast guard be unit-tested per palette. + */ + +/** sRGB colour, channels 0..1, straight (non-premultiplied) alpha. */ +export interface RgbColor { + r: number; + g: number; + b: number; + a: number; +} + +const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v); + +const NAMED_COLORS: Record = { + white: '#ffffff', + black: '#000000', + red: '#ff0000', + green: '#008000', + blue: '#0000ff', + yellow: '#ffff00', + navy: '#000080', + grey: '#808080', + gray: '#808080', + lightgrey: '#d3d3d3', + lightgray: '#d3d3d3', + darkgrey: '#a9a9a9', + darkgray: '#a9a9a9', + silver: '#c0c0c0', + orange: '#ffa500', + purple: '#800080', + teal: '#008080', + transparent: '#00000000', +}; + +/** + * Parse one CSS colour value into sRGB. Returns `undefined` for anything it + * does not understand (a `var()` reference, `color-mix()`, an empty string), + * which callers treat as "token absent". + * + * Supported: `#rgb[a]`, `#rrggbb[aa]`, `rgb()`/`rgba()` (comma or space + * syntax, percentages, `/ alpha`), `hsl()`/`hsla()`, `oklch()`, `oklab()`, + * `lab()`, `lch()`, `color(srgb|srgb-linear|display-p3 ...)`, and a handful of + * named colours. Wide-gamut input is clipped to sRGB per channel. + */ +export function parseCssColor(input: string | null | undefined): RgbColor | undefined { + if (typeof input !== 'string') return undefined; + const value = input.trim().toLowerCase(); + if (!value) return undefined; + + if (value.startsWith('#')) return parseHex(value); + if (value in NAMED_COLORS) return parseHex(NAMED_COLORS[value]); + + const fn = value.match(/^([a-z-]+)\((.*)\)$/s); + if (!fn) return undefined; + const name = fn[1]; + const body = fn[2].trim(); + + switch (name) { + case 'rgb': + case 'rgba': + return parseRgbFunction(body); + case 'hsl': + case 'hsla': + return parseHslFunction(body); + case 'oklch': + return parseOklchFunction(body); + case 'oklab': + return parseOklabFunction(body); + case 'lab': + return parseLabFunction(body); + case 'lch': + return parseLchFunction(body); + case 'color': + return parseColorFunction(body); + default: + return undefined; + } +} + +function parseHex(value: string): RgbColor | undefined { + const hex = value.slice(1); + if (!/^[0-9a-f]+$/.test(hex)) return undefined; + let r: number; + let g: number; + let b: number; + let a = 255; + if (hex.length === 3 || hex.length === 4) { + r = parseInt(hex[0] + hex[0], 16); + g = parseInt(hex[1] + hex[1], 16); + b = parseInt(hex[2] + hex[2], 16); + if (hex.length === 4) a = parseInt(hex[3] + hex[3], 16); + } else if (hex.length === 6 || hex.length === 8) { + r = parseInt(hex.slice(0, 2), 16); + g = parseInt(hex.slice(2, 4), 16); + b = parseInt(hex.slice(4, 6), 16); + if (hex.length === 8) a = parseInt(hex.slice(6, 8), 16); + } else { + return undefined; + } + return { r: r / 255, g: g / 255, b: b / 255, a: a / 255 }; +} + +/** Split a function body into channel tokens and an optional `/ alpha`. */ +function splitChannels(body: string): { parts: string[]; alpha: string | undefined } | undefined { + let main = body; + let alpha: string | undefined; + const slash = body.indexOf('/'); + if (slash >= 0) { + main = body.slice(0, slash); + alpha = body.slice(slash + 1).trim(); + } + const parts = main + .split(/[\s,]+/) + .map((p) => p.trim()) + .filter(Boolean); + if (alpha === undefined && parts.length === 4) { + // legacy `rgba(r, g, b, a)` / `hsla(h, s, l, a)` + alpha = parts.pop(); + } + if (parts.length !== 3) return undefined; + return { parts, alpha }; +} + +function parseNumber(token: string, scale = 1): number | undefined { + if (token === 'none') return 0; + const m = token.match(/^(-?\d*\.?\d+(?:e[-+]?\d+)?)(%|deg|rad|grad|turn)?$/); + if (!m) return undefined; + const n = Number.parseFloat(m[1]); + if (!Number.isFinite(n)) return undefined; + switch (m[2]) { + case '%': + return (n / 100) * scale; + case 'rad': + return (n * 180) / Math.PI; + case 'grad': + return n * 0.9; + case 'turn': + return n * 360; + default: + return n; + } +} + +function parseAlpha(token: string | undefined): number | undefined { + if (token === undefined) return 1; + const a = parseNumber(token, 1); + return a === undefined ? undefined : clamp01(a); +} + +function parseRgbFunction(body: string): RgbColor | undefined { + const split = splitChannels(body); + if (!split) return undefined; + const ch = split.parts.map((p) => (p.endsWith('%') ? parseNumber(p, 1) : (parseNumber(p) ?? NaN) / 255)); + const a = parseAlpha(split.alpha); + if (ch.some((v) => v === undefined || Number.isNaN(v)) || a === undefined) return undefined; + return { r: clamp01(ch[0] as number), g: clamp01(ch[1] as number), b: clamp01(ch[2] as number), a }; +} + +function parseHslFunction(body: string): RgbColor | undefined { + const split = splitChannels(body); + if (!split) return undefined; + const h = parseNumber(split.parts[0]); + const s = parseNumber(split.parts[1], 1); + const l = parseNumber(split.parts[2], 1); + const a = parseAlpha(split.alpha); + if (h === undefined || s === undefined || l === undefined || a === undefined) return undefined; + const { r, g, b } = hslToRgb(((h % 360) + 360) % 360, clamp01(s), clamp01(l)); + return { r, g, b, a }; +} + +function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let rgb: [number, number, number]; + if (h < 60) rgb = [c, x, 0]; + else if (h < 120) rgb = [x, c, 0]; + else if (h < 180) rgb = [0, c, x]; + else if (h < 240) rgb = [0, x, c]; + else if (h < 300) rgb = [x, 0, c]; + else rgb = [c, 0, x]; + return { r: rgb[0] + m, g: rgb[1] + m, b: rgb[2] + m }; +} + +function parseOklchFunction(body: string): RgbColor | undefined { + const split = splitChannels(body); + if (!split) return undefined; + const L = parseNumber(split.parts[0], 1); + const C = parseNumber(split.parts[1], 0.4); + const H = parseNumber(split.parts[2]); + const a = parseAlpha(split.alpha); + if (L === undefined || C === undefined || H === undefined || a === undefined) return undefined; + return { ...oklchToRgb({ L, C, H }), a }; +} + +function parseOklabFunction(body: string): RgbColor | undefined { + const split = splitChannels(body); + if (!split) return undefined; + const L = parseNumber(split.parts[0], 1); + const aa = parseNumber(split.parts[1], 0.4); + const bb = parseNumber(split.parts[2], 0.4); + const a = parseAlpha(split.alpha); + if (L === undefined || aa === undefined || bb === undefined || a === undefined) return undefined; + return { ...oklabToRgb({ L, a: aa, b: bb }), a }; +} + +function parseLabFunction(body: string): RgbColor | undefined { + const split = splitChannels(body); + if (!split) return undefined; + const L = parseNumber(split.parts[0], 100); + const aa = parseNumber(split.parts[1], 125); + const bb = parseNumber(split.parts[2], 125); + const a = parseAlpha(split.alpha); + if (L === undefined || aa === undefined || bb === undefined || a === undefined) return undefined; + return { ...xyzToRgb(labToXyz(L, aa, bb)), a }; +} + +function parseLchFunction(body: string): RgbColor | undefined { + const split = splitChannels(body); + if (!split) return undefined; + const L = parseNumber(split.parts[0], 100); + const C = parseNumber(split.parts[1], 150); + const H = parseNumber(split.parts[2]); + const a = parseAlpha(split.alpha); + if (L === undefined || C === undefined || H === undefined || a === undefined) return undefined; + const rad = (H * Math.PI) / 180; + return { ...xyzToRgb(labToXyz(L, C * Math.cos(rad), C * Math.sin(rad))), a }; +} + +function parseColorFunction(body: string): RgbColor | undefined { + const m = body.match(/^([a-z0-9-]+)\s+(.*)$/s); + if (!m) return undefined; + const space = m[1]; + const split = splitChannels(m[2]); + if (!split) return undefined; + const ch = split.parts.map((p) => parseNumber(p, 1)); + const a = parseAlpha(split.alpha); + if (ch.some((v) => v === undefined) || a === undefined) return undefined; + const [x, y, z] = ch as [number, number, number]; + switch (space) { + case 'srgb': + return { r: clamp01(x), g: clamp01(y), b: clamp01(z), a }; + case 'srgb-linear': + return { r: clamp01(linearToSrgb(x)), g: clamp01(linearToSrgb(y)), b: clamp01(linearToSrgb(z)), a }; + case 'display-p3': { + const lin = [srgbToLinear(x), srgbToLinear(y), srgbToLinear(z)]; + // display-p3 (linear) -> XYZ D65 + const X = 0.4865709 * lin[0] + 0.2656677 * lin[1] + 0.1982173 * lin[2]; + const Y = 0.2289746 * lin[0] + 0.6917385 * lin[1] + 0.0792869 * lin[2]; + const Z = 0.0 * lin[0] + 0.0451134 * lin[1] + 1.0439444 * lin[2]; + return { ...xyzToRgb({ X, Y, Z }), a }; + } + default: + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Colour spaces +// --------------------------------------------------------------------------- + +export function srgbToLinear(c: number): number { + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +export function linearToSrgb(c: number): number { + return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; +} + +interface Xyz { + X: number; + Y: number; + Z: number; +} + +function xyzToRgb({ X, Y, Z }: Xyz): { r: number; g: number; b: number } { + const rl = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z; + const gl = -0.969266 * X + 1.8760108 * Y + 0.041556 * Z; + const bl = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z; + return { r: clamp01(linearToSrgb(rl)), g: clamp01(linearToSrgb(gl)), b: clamp01(linearToSrgb(bl)) }; +} + +/** CIE Lab (D50 white as CSS specifies) -> XYZ D65 via Bradford. */ +function labToXyz(L: number, a: number, b: number): Xyz { + const fy = (L + 16) / 116; + const fx = fy + a / 500; + const fz = fy - b / 200; + const e = 216 / 24389; + const k = 24389 / 27; + const xr = Math.pow(fx, 3) > e ? Math.pow(fx, 3) : (116 * fx - 16) / k; + const yr = L > k * e ? Math.pow((L + 16) / 116, 3) : L / k; + const zr = Math.pow(fz, 3) > e ? Math.pow(fz, 3) : (116 * fz - 16) / k; + // D50 reference white + const X50 = xr * 0.3457 / 0.3585; + const Y50 = yr; + const Z50 = zr * (1 - 0.3457 - 0.3585) / 0.3585; + // Bradford D50 -> D65 + return { + X: 0.9554734 * X50 - 0.0230985 * Y50 + 0.0632593 * Z50, + Y: -0.0283697 * X50 + 1.0099954 * Y50 + 0.0210413 * Z50, + Z: 0.0123141 * X50 - 0.0205050 * Y50 + 1.3299098 * Z50, + }; +} + +/** OKLab, the perceptual space every mix and lightness edit here is done in. */ +export interface Oklab { + L: number; + a: number; + b: number; +} + +export interface Oklch { + L: number; + C: number; + H: number; +} + +export function rgbToOklab({ r, g, b }: { r: number; g: number; b: number }): Oklab { + const lr = srgbToLinear(r); + const lg = srgbToLinear(g); + const lb = srgbToLinear(b); + const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); + const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); + const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); + return { + L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + }; +} + +export function oklabToRgb({ L, a, b }: Oklab): { r: number; g: number; b: number } { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.291485548 * b; + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; + return { r: clamp01(linearToSrgb(lr)), g: clamp01(linearToSrgb(lg)), b: clamp01(linearToSrgb(lb)) }; +} + +export function oklabToOklch({ L, a, b }: Oklab): Oklch { + const C = Math.sqrt(a * a + b * b); + let H = (Math.atan2(b, a) * 180) / Math.PI; + if (H < 0) H += 360; + return { L, C, H }; +} + +export function oklchToOklab({ L, C, H }: Oklch): Oklab { + const rad = (H * Math.PI) / 180; + return { L, a: C * Math.cos(rad), b: C * Math.sin(rad) }; +} + +export function oklchToRgb(c: Oklch): { r: number; g: number; b: number } { + return oklabToRgb(oklchToOklab(c)); +} + +export function rgbToOklch(c: { r: number; g: number; b: number }): Oklch { + return oklabToOklch(rgbToOklab(c)); +} + +// --------------------------------------------------------------------------- +// Compositing, mixing, contrast +// --------------------------------------------------------------------------- + +/** Alpha-composite `top` over an opaque `under` (source-over). */ +export function compositeOver(top: RgbColor, under: RgbColor): RgbColor { + const a = clamp01(top.a); + if (a >= 1) return { r: top.r, g: top.g, b: top.b, a: 1 }; + return { + r: top.r * a + under.r * (1 - a), + g: top.g * a + under.g * (1 - a), + b: top.b * a + under.b * (1 - a), + a: 1, + }; +} + +/** Perceptual mix in OKLab: `t = 0` is `from`, `t = 1` is `to`. Alpha ignored. */ +export function mixOklab(from: RgbColor, to: RgbColor, t: number): RgbColor { + const k = clamp01(t); + const a = rgbToOklab(from); + const b = rgbToOklab(to); + const rgb = oklabToRgb({ L: a.L + (b.L - a.L) * k, a: a.a + (b.a - a.a) * k, b: a.b + (b.b - a.b) * k }); + return { ...rgb, a: 1 }; +} + +/** WCAG relative luminance of an opaque sRGB colour. */ +export function relativeLuminance({ r, g, b }: { r: number; g: number; b: number }): number { + return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b); +} + +/** WCAG 2.x contrast ratio between two opaque colours (1..21). */ +export function contrastRatio(a: { r: number; g: number; b: number }, b: { r: number; g: number; b: number }): number { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + const light = Math.max(la, lb); + const dark = Math.min(la, lb); + return (light + 0.05) / (dark + 0.05); +} + +/** Round to the 8-bit sRGB grid a hex string carries, so a contrast measured + * here equals the contrast of the colour Mermaid actually receives. */ +export function quantize(color: RgbColor): RgbColor { + const q = (v: number) => Math.round(clamp01(v) * 255) / 255; + return { r: q(color.r), g: q(color.g), b: q(color.b), a: 1 }; +} + +/** `#rrggbb` (alpha dropped; callers composite first when it matters). */ +export function toHex({ r, g, b }: { r: number; g: number; b: number }): string { + const h = (v: number) => + Math.round(clamp01(v) * 255) + .toString(16) + .padStart(2, '0'); + return `#${h(r)}${h(g)}${h(b)}`; +} + +/** Parse, then composite any alpha over `backdrop` so the result is opaque. */ +export function parseOpaqueColor(value: string | null | undefined, backdrop: RgbColor): RgbColor | undefined { + const parsed = parseCssColor(value); + if (!parsed) return undefined; + return compositeOver(parsed, backdrop); +} + +/** Return a copy of `color` with its OKLCH lightness set to `L` (0..1). */ +export function withOklchLightness(color: RgbColor, L: number): RgbColor { + const lch = rgbToOklch(color); + return { ...oklchToRgb({ ...lch, L: clamp01(L) }), a: 1 }; +} + +/** Return a copy of `color` with its OKLCH chroma capped at `maxC`. */ +export function withMaxChroma(color: RgbColor, maxC: number): RgbColor { + const lch = rgbToOklch(color); + if (lch.C <= maxC) return { ...color, a: 1 }; + return { ...oklchToRgb({ ...lch, C: maxC }), a: 1 }; +} + +/** Rotate hue by `deg` in OKLCH. */ +export function rotateHue(color: RgbColor, deg: number): RgbColor { + const lch = rgbToOklch(color); + return { ...oklchToRgb({ ...lch, H: (((lch.H + deg) % 360) + 360) % 360 }), a: 1 }; +} + +/** Smallest angular distance between two hues (degrees, 0..180). */ +export function hueDistance(a: number, b: number): number { + const d = Math.abs(((a - b) % 360) + 360) % 360; + return d > 180 ? 360 - d : d; +} diff --git a/packages/ui/utils/mermaidTheme.test.ts b/packages/ui/utils/mermaidTheme.test.ts new file mode 100644 index 000000000..bf7474b9d --- /dev/null +++ b/packages/ui/utils/mermaidTheme.test.ts @@ -0,0 +1,387 @@ +/** + * Theme-aware Mermaid mapping (utils/mermaidTheme.ts). + * + * What regresses if these fail: + * - a diagram family (sequence, gitGraph, pie, ...) drops out of the mapping + * and silently falls back to Mermaid's built-in palette in one theme; + * - the base theme stops following the resolved mode, so light palettes get + * Mermaid's dark derivations for everything the mapping leaves alone; + * - the fallback contract breaks: a host without theme tokens would get a + * dynamic config (or a second `initialize`) instead of today's static one; + * - the contrast guard stops holding, so a palette whose tokens are close in + * luminance ships unreadable labels or invisible edges. The sweep below runs + * the guard over every palette shipped in `packages/ui/themes`, in both + * modes, so a new palette cannot regress it either. + * + * No DOM required: the tokens are read from the theme CSS files as text. + */ +import { describe, expect, test } from 'bun:test'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { MERMAID_CONFIG } from './mermaid'; +import { contrastRatio, parseCssColor, toHex } from './cssColor'; +import { + MERMAID_LINE_CONTRAST_MIN, + MERMAID_TEXT_CONTRAST_MIN, + __resetMermaidThemeForTests, + applyMermaidTheme, + buildMermaidConfig, + buildMermaidThemeVariables, + ensureContrast, + mermaidThemeKey, + type MermaidThemeMode, + type MermaidThemeTokens, +} from './mermaidTheme'; + +const HEX = /^#[0-9a-f]{6}$/; + +/** The Plannotator base palette, as `themes/plannotator.css` writes it. */ +const PLANNOTATOR_DARK: MermaidThemeTokens = { + background: 'oklch(0.15 0.02 260)', + foreground: 'oklch(0.90 0.01 260)', + card: 'oklch(0.22 0.02 260)', + 'card-foreground': 'oklch(0.90 0.01 260)', + popover: 'oklch(0.28 0.025 260)', + primary: 'oklch(0.75 0.18 280)', + 'primary-foreground': 'oklch(0.15 0.02 260)', + secondary: 'oklch(0.65 0.15 180)', + muted: 'oklch(0.26 0.02 260)', + 'muted-foreground': 'oklch(0.72 0.02 260)', + accent: 'oklch(0.70 0.20 60)', + destructive: 'oklch(0.65 0.20 25)', + border: 'oklch(0.35 0.02 260)', + success: 'oklch(0.72 0.17 150)', + warning: 'oklch(0.75 0.15 85)', + 'font-sans': "'Inter Variable', 'Inter', system-ui, sans-serif", +}; + +const PLANNOTATOR_LIGHT: MermaidThemeTokens = { + background: 'oklch(0.97 0.005 260)', + foreground: 'oklch(0.18 0.02 260)', + card: 'oklch(1 0 0)', + 'card-foreground': 'oklch(0.18 0.02 260)', + popover: 'oklch(1 0 0)', + primary: 'oklch(0.50 0.25 280)', + 'primary-foreground': 'oklch(1 0 0)', + secondary: 'oklch(0.50 0.18 180)', + muted: 'oklch(0.92 0.01 260)', + 'muted-foreground': 'oklch(0.40 0.02 260)', + accent: 'oklch(0.60 0.22 50)', + destructive: 'oklch(0.50 0.25 25)', + border: 'oklch(0.88 0.01 260)', + success: 'oklch(0.45 0.20 150)', + warning: 'oklch(0.55 0.18 85)', +}; + +/** + * Every theme variable Mermaid documents per family (docs: "Theme Variables", + * `theme-dark.js` / `theme-default.js` in 11.x). The mapping must set each one + * explicitly so no family inherits a base-theme derivation from a colour the + * palette never chose. + */ +const DOCUMENTED_VARIABLES = { + general: [ + 'background', 'primaryColor', 'primaryTextColor', 'primaryBorderColor', 'secondaryColor', + 'secondaryTextColor', 'secondaryBorderColor', 'tertiaryColor', 'tertiaryTextColor', + 'tertiaryBorderColor', 'textColor', 'titleColor', 'lineColor', 'arrowheadColor', 'mainBkg', + 'secondBkg', 'border1', 'border2', 'labelBackground', 'errorBkgColor', 'errorTextColor', + ], + flowchart: ['nodeBkg', 'nodeBorder', 'nodeTextColor', 'clusterBkg', 'clusterBorder', 'defaultLinkColor', 'edgeLabelBackground'], + sequence: [ + 'actorBkg', 'actorBorder', 'actorTextColor', 'actorLineColor', 'signalColor', 'signalTextColor', + 'labelBoxBkgColor', 'labelBoxBorderColor', 'labelTextColor', 'loopTextColor', 'noteBkgColor', + 'noteBorderColor', 'noteTextColor', 'activationBkgColor', 'activationBorderColor', 'sequenceNumberColor', + ], + state: [ + 'stateBkg', 'stateLabelColor', 'transitionColor', 'transitionLabelColor', 'labelBackgroundColor', + 'compositeBackground', 'compositeTitleBackground', 'compositeBorder', 'altBackground', + 'innerEndBackground', 'specialStateColor', 'labelColor', + ], + class: ['classText'], + er: ['attributeBackgroundColorOdd', 'attributeBackgroundColorEven', 'rowOdd', 'rowEven'], + requirement: ['requirementBackground', 'requirementBorderColor', 'requirementTextColor', 'relationColor', 'relationLabelBackground', 'relationLabelColor'], + git: [ + ...Array.from({ length: 8 }, (_, i) => `git${i}`), + ...Array.from({ length: 8 }, (_, i) => `gitInv${i}`), + ...Array.from({ length: 8 }, (_, i) => `gitBranchLabel${i}`), + 'commitLabelColor', 'commitLabelBackground', 'tagLabelColor', 'tagLabelBackground', 'tagLabelBorder', + ], + gantt: [ + 'sectionBkgColor', 'altSectionBkgColor', 'sectionBkgColor2', 'excludeBkgColor', 'taskBorderColor', + 'taskBkgColor', 'taskTextColor', 'taskTextLightColor', 'taskTextDarkColor', 'taskTextOutsideColor', + 'taskTextClickableColor', 'activeTaskBorderColor', 'activeTaskBkgColor', 'gridColor', 'doneTaskBkgColor', + 'doneTaskBorderColor', 'critBorderColor', 'critBkgColor', 'todayLineColor', 'vertLineColor', + ], + pie: [ + ...Array.from({ length: 12 }, (_, i) => `pie${i + 1}`), + 'pieTitleTextColor', 'pieSectionTextColor', 'pieLegendTextColor', 'pieStrokeColor', 'pieOuterStrokeColor', + ], + scale: [ + ...Array.from({ length: 12 }, (_, i) => `cScale${i}`), + ...Array.from({ length: 12 }, (_, i) => `cScaleInv${i}`), + ...Array.from({ length: 12 }, (_, i) => `cScalePeer${i}`), + ...Array.from({ length: 12 }, (_, i) => `cScaleLabel${i}`), + 'scaleLabelColor', + ], + journey: Array.from({ length: 8 }, (_, i) => `fillType${i}`), + quadrant: [ + 'quadrant1Fill', 'quadrant2Fill', 'quadrant3Fill', 'quadrant4Fill', 'quadrant1TextFill', 'quadrant2TextFill', + 'quadrant3TextFill', 'quadrant4TextFill', 'quadrantPointFill', 'quadrantPointTextFill', 'quadrantXAxisTextFill', + 'quadrantYAxisTextFill', 'quadrantInternalBorderStrokeFill', 'quadrantExternalBorderStrokeFill', 'quadrantTitleFill', + ], + architecture: ['archEdgeColor', 'archEdgeArrowColor', 'archGroupBorderColor'], + c4: ['personBkg', 'personBorder'], + venn: [...Array.from({ length: 8 }, (_, i) => `venn${i + 1}`), 'vennTitleTextColor', 'vennSetTextColor'], +} as const; + +const NESTED_VARIABLES = { + xyChart: [ + 'backgroundColor', 'titleColor', 'dataLabelColor', 'legendTextColor', 'xAxisTitleColor', 'xAxisLabelColor', + 'xAxisTickColor', 'xAxisLineColor', 'yAxisTitleColor', 'yAxisLabelColor', 'yAxisTickColor', 'yAxisLineColor', + ], + packet: ['startByteColor', 'endByteColor', 'labelColor', 'titleColor', 'blockStrokeColor', 'blockFillColor'], + radar: ['axisColor', 'graticuleColor'], + wardley: ['backgroundColor', 'axisColor', 'axisTextColor', 'gridColor', 'componentFill', 'componentStroke', 'componentLabelColor', 'linkStroke', 'evolutionStroke', 'annotationStroke', 'annotationTextColor', 'annotationFill'], +} as const; + +function rgb(value: unknown) { + expect(typeof value).toBe('string'); + expect(value as string).toMatch(HEX); + return parseCssColor(value as string)!; +} + +function ratio(vars: Record, a: string, b: string): number { + return contrastRatio(rgb(vars[a]), rgb(vars[b])); +} + +describe('buildMermaidThemeVariables', () => { + test('sets every documented variable of every family as an opaque hex colour', () => { + const spec = buildMermaidThemeVariables(PLANNOTATOR_DARK, 'dark'); + expect(spec).not.toBeNull(); + const vars = spec!.themeVariables; + for (const [family, names] of Object.entries(DOCUMENTED_VARIABLES)) { + for (const name of names) { + expect(vars[name], `${family}.${name}`).toMatch(HEX); + } + } + for (const [family, names] of Object.entries(NESTED_VARIABLES)) { + const nested = vars[family] as Record; + expect(nested, family).toBeObject(); + for (const name of names) { + expect(nested[name], `${family}.${name}`).toMatch(HEX); + } + } + expect((vars.xyChart as { plotColorPalette: string }).plotColorPalette.split(',')).toHaveLength(12); + expect(vars.darkMode).toBe(true); + // Nothing handed to Mermaid may still be in a syntax its colour library cannot read. + const flat = JSON.stringify(vars); + expect(flat).not.toContain('oklch('); + expect(flat).not.toContain('var('); + }); + + test('base theme follows the resolved mode; the font follows the sans token', () => { + const dark = buildMermaidThemeVariables(PLANNOTATOR_DARK, 'dark')!; + const light = buildMermaidThemeVariables(PLANNOTATOR_LIGHT, 'light')!; + expect(dark.theme).toBe('dark'); + expect(light.theme).toBe('default'); + expect(light.themeVariables.darkMode).toBe(false); + expect(dark.themeVariables.fontFamily).toBe(PLANNOTATOR_DARK['font-sans']); + expect('fontFamily' in light.themeVariables).toBe(false); + // Node fill is the card token, not a fixed slate, in both modes. + expect(dark.themeVariables.nodeBkg).toBe(toHex(parseCssColor(PLANNOTATOR_DARK.card)!)); + expect(dark.themeVariables.nodeBkg).not.toBe(MERMAID_CONFIG.themeVariables!.mainBkg); + expect(light.themeVariables.nodeBkg).toBe('#ffffff'); + }); + + test('falls back to the static config when the tokens are absent or unusable', () => { + expect(buildMermaidThemeVariables(undefined, 'dark')).toBeNull(); + expect(buildMermaidThemeVariables({}, 'dark')).toBeNull(); + expect(buildMermaidThemeVariables({ foreground: '#fff' }, 'dark')).toBeNull(); + expect(buildMermaidThemeVariables({ background: 'var(--x)', foreground: '#fff' }, 'dark')).toBeNull(); + // Identity, not a copy: the fallback IS the pinned static config. + expect(buildMermaidConfig(null)).toBe(MERMAID_CONFIG); + const dynamic = buildMermaidConfig(buildMermaidThemeVariables(PLANNOTATOR_DARK, 'dark')); + expect(dynamic.securityLevel).toBe('strict'); + expect(dynamic.startOnLoad).toBe(false); + expect(dynamic.flowchart).toEqual(MERMAID_CONFIG.flowchart); + expect(MERMAID_CONFIG.theme).toBe('dark'); + }); + + test('the two required tokens are enough; the rest are derived', () => { + const spec = buildMermaidThemeVariables({ background: '#ffffff', foreground: '#111111' }, 'light')!; + expect(spec.themeVariables.nodeBkg).toBe('#ffffff'); + expect(ratio(spec.themeVariables, 'primaryTextColor', 'nodeBkg')).toBeGreaterThanOrEqual(MERMAID_TEXT_CONTRAST_MIN); + expect(ratio(spec.themeVariables, 'lineColor', 'background')).toBeGreaterThanOrEqual(MERMAID_LINE_CONTRAST_MIN); + }); + + test('an alpha muted-foreground is composited, never passed through with alpha', () => { + const spec = buildMermaidThemeVariables({ background: '#000000', foreground: '#ffffff', 'muted-foreground': '#ffffff99' }, 'dark')!; + expect(spec.themeVariables.lineColor).toMatch(HEX); + // #ffffff99 over black is #999999, a neutral grey; the guard may lift it + // toward the foreground but never re-introduces alpha or a hue. + const c = parseCssColor(spec.themeVariables.lineColor as string)!; + expect(c.r).toBeCloseTo(c.g, 2); + expect(c.g).toBeCloseTo(c.b, 2); + expect(c.r).toBeGreaterThanOrEqual(0x99 / 255 - 0.01); + }); +}); + +describe('contrast guard', () => { + test('repairs a failing pair by the smallest step toward the ink, and leaves a passing pair alone', () => { + const white = parseCssColor('#ffffff')!; + const black = parseCssColor('#000000')!; + const grey = parseCssColor('#cccccc')!; + const untouched = ensureContrast(black, white, 4.5, [black, white]); + expect(untouched).toEqual({ ...black, a: 1 }); + const repaired = ensureContrast(grey, white, 4.5, [black, white]); + const r = contrastRatio(repaired, white); + expect(r).toBeGreaterThanOrEqual(4.5); + // Smallest step: not slammed all the way to the ink. + expect(r).toBeLessThan(6); + // First ink cannot reach the ratio (white on white): falls through to the second. + const flipped = ensureContrast(grey, white, 4.5, [white, black]); + expect(contrastRatio(flipped, white)).toBeGreaterThanOrEqual(4.5); + }); + + test('a palette whose muted-foreground hugs the background still draws readable edges and labels', () => { + const tokens: MermaidThemeTokens = { + background: '#202020', + foreground: '#e0e0e0', + card: '#242424', + 'card-foreground': '#2a2a2a', // deliberately unreadable on the card + 'muted-foreground': '#303030', // deliberately invisible on the page + border: '#212121', + }; + const vars = buildMermaidThemeVariables(tokens, 'dark')!.themeVariables; + expect(ratio(vars, 'lineColor', 'background')).toBeGreaterThanOrEqual(MERMAID_LINE_CONTRAST_MIN); + expect(ratio(vars, 'primaryTextColor', 'nodeBkg')).toBeGreaterThanOrEqual(MERMAID_TEXT_CONTRAST_MIN); + expect(ratio(vars, 'nodeBorder', 'background')).toBeGreaterThanOrEqual(1.5); + }); + + /** + * Every shipped palette, both modes. Reads the tokens straight out of the + * CSS so a palette added later is swept automatically. + */ + const themesDir = join(import.meta.dir, '..', 'themes'); + const themeFiles = readdirSync(themesDir).filter((f) => f.endsWith('.css')).sort(); + expect(themeFiles.length).toBeGreaterThan(30); + + /** First rule whose selector list names `selector` (some files write `.theme-x,\n.theme-x.light {`). */ + function tokensFromCss(css: string, selector: string): MermaidThemeTokens | undefined { + const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ''); + for (const rule of stripped.matchAll(/([^{}]+)\{([^}]*)\}/g)) { + const selectors = rule[1].split(',').map((sel) => sel.trim()); + if (!selectors.includes(selector)) continue; + const tokens: Record = {}; + for (const m of rule[2].matchAll(/--([a-z-]+)\s*:\s*([^;]+);/g)) tokens[m[1]] = m[2].trim(); + return tokens as MermaidThemeTokens; + } + return undefined; + } + + const TEXT_PAIRS: Array<[string, string]> = [ + ['primaryTextColor', 'nodeBkg'], + ['nodeTextColor', 'nodeBkg'], + ['textColor', 'background'], + ['titleColor', 'background'], + ['actorTextColor', 'actorBkg'], + ['signalTextColor', 'background'], + ['labelTextColor', 'labelBoxBkgColor'], + ['loopTextColor', 'labelBoxBkgColor'], + ['noteTextColor', 'noteBkgColor'], + ['sequenceNumberColor', 'lineColor'], + ['stateLabelColor', 'stateBkg'], + ['transitionLabelColor', 'background'], + ['classText', 'nodeBkg'], + ['requirementTextColor', 'requirementBackground'], + ['relationLabelColor', 'relationLabelBackground'], + ['commitLabelColor', 'commitLabelBackground'], + ['tagLabelColor', 'tagLabelBackground'], + ['taskTextColor', 'taskBkgColor'], + ['taskTextOutsideColor', 'background'], + ['pieLegendTextColor', 'background'], + ['pieTitleTextColor', 'background'], + ['pieSectionTextColor', 'pie1'], + ['errorTextColor', 'errorBkgColor'], + ['quadrant1TextFill', 'quadrant1Fill'], + ['quadrant2TextFill', 'quadrant2Fill'], + ...Array.from({ length: 12 }, (_, i) => [`cScaleLabel${i}`, `cScale${i}`] as [string, string]), + ...Array.from({ length: 8 }, (_, i) => [`gitBranchLabel${i}`, `git${i}`] as [string, string]), + ]; + const LINE_PAIRS: Array<[string, string]> = [ + ['lineColor', 'background'], + ['arrowheadColor', 'background'], + ['defaultLinkColor', 'background'], + ['signalColor', 'background'], + ['actorLineColor', 'background'], + ['transitionColor', 'background'], + ['relationColor', 'background'], + ['activeTaskBorderColor', 'background'], + ['todayLineColor', 'background'], + ['critBorderColor', 'background'], + ...Array.from({ length: 8 }, (_, i) => [`git${i}`, 'background'] as [string, string]), + ]; + + for (const file of themeFiles) { + const css = readFileSync(join(themesDir, file), 'utf8'); + const id = file.replace(/\.css$/, ''); + for (const mode of ['dark', 'light'] as MermaidThemeMode[]) { + const selector = mode === 'dark' ? `.theme-${id}` : `.theme-${id}.light`; + const tokens = tokensFromCss(css, selector); + test(`${id} / ${mode}: every text pair >= ${MERMAID_TEXT_CONTRAST_MIN}:1, every line pair >= ${MERMAID_LINE_CONTRAST_MIN}:1`, () => { + expect(tokens, `${selector} block in ${file}`).toBeDefined(); + const spec = buildMermaidThemeVariables(tokens, mode); + expect(spec).not.toBeNull(); + const vars = spec!.themeVariables; + const failures: string[] = []; + for (const [text, fill] of TEXT_PAIRS) { + const r = ratio(vars, text, fill); + if (r < MERMAID_TEXT_CONTRAST_MIN) failures.push(`${text} on ${fill}: ${r.toFixed(2)}`); + } + for (const [stroke, canvas] of LINE_PAIRS) { + const r = ratio(vars, stroke, canvas); + if (r < MERMAID_LINE_CONTRAST_MIN) failures.push(`${stroke} vs ${canvas}: ${r.toFixed(2)}`); + } + expect(failures).toEqual([]); + // The categorical scale is twelve distinct fills. + const scale = new Set(Array.from({ length: 12 }, (_, i) => vars[`cScale${i}`])); + expect(scale.size).toBe(12); + }); + } + } +}); + +describe('applyMermaidTheme', () => { + function fakeRuntime() { + const calls: unknown[] = []; + return { calls, initialize: (config: unknown) => { calls.push(config); } }; + } + + test('without theme tokens it never re-initializes a fresh runtime (host fallback contract)', () => { + __resetMermaidThemeForTests(); + try { + const runtime = fakeRuntime(); + // No tokens are defined in this process (no DOM, or a DOM without theme.css). + expect(applyMermaidTheme(runtime, mermaidThemeKey('plannotator', 'dark'))).toBe('static'); + expect(applyMermaidTheme(runtime, mermaidThemeKey('plannotator', 'dark'))).toBe('unchanged'); + expect(applyMermaidTheme(runtime, mermaidThemeKey('github', 'light'))).toBe('static'); + expect(runtime.calls).toEqual([]); + } finally { + __resetMermaidThemeForTests(); + } + }); + + test('a new runtime object is themed afresh even under the same key', () => { + __resetMermaidThemeForTests(); + try { + const first = fakeRuntime(); + const second = fakeRuntime(); + const key = mermaidThemeKey('plannotator', 'dark'); + expect(applyMermaidTheme(first, key)).toBe('static'); + expect(applyMermaidTheme(second, key)).toBe('static'); + expect(applyMermaidTheme(second, key)).toBe('unchanged'); + } finally { + __resetMermaidThemeForTests(); + } + }); +}); diff --git a/packages/ui/utils/mermaidTheme.ts b/packages/ui/utils/mermaidTheme.ts new file mode 100644 index 000000000..c158a0931 --- /dev/null +++ b/packages/ui/utils/mermaidTheme.ts @@ -0,0 +1,732 @@ +/** + * Theme-aware Mermaid configuration. + * + * Mermaid diagrams used to render from one static config (`MERMAID_CONFIG` in + * `./mermaid`: the `dark` base theme plus a slate palette) in every one of + * Plannotator's palettes and both modes. This module derives the diagram + * theme from the live CSS tokens instead, the same tokens `ThemeProvider` + * applies through `theme.css`, so a diagram follows the palette the way a + * code fence already does (see `syntaxTheme.ts` / `useFenceTheme`). + * + * Three layers, each pure below the top one: + * + * 1. `readThemeTokens(el)` reads the handful of custom properties the mapping + * needs off the document (`getComputedStyle`), returning a plain record of + * raw CSS values, or `undefined` when the tokens are not there at all (a + * host that never mounted `ThemeProvider` and ships no `theme.css`). + * 2. `buildMermaidThemeVariables(tokens, mode)` turns that record into the + * Mermaid base theme (`dark` when the resolved mode is dark, `default` + * when light) plus a complete `themeVariables` override: general, flowchart, + * sequence, state, class, ER, requirement, gitGraph, gantt, pie, mindmap / + * timeline (`cScale*`), journey (`fillType*`), quadrant, xyChart, packet, + * radar, wardley, venn, architecture and C4 all derive from the same + * tokens. Every colour handed to Mermaid is an opaque hex string, because + * Mermaid's colour library does not parse `oklch()`. + * 3. `applyMermaidTheme(mermaid, key)` is the runtime step `MermaidBlock` + * calls before every render: `mermaid.initialize` is global state, so it + * runs only when the `(palette, mode)` key changed since the last apply. + * + * Fallback contract (hosts): when no tokens resolve, the runtime keeps the + * static `MERMAID_CONFIG` it was initialized with, and nothing is + * re-initialized, so a host that does not use Plannotator's theme tokens + * renders exactly as before this module existed. + * + * Contrast rule (the "guard"): every text-on-fill pair the mapping produces + * must reach WCAG 4.5:1 and every line-on-canvas pair 3:1 (plus a 0.1 + * headroom, `GUARD_HEADROOM`). Page-level text and lines are guarded against + * EVERY surface they can cross, not one: the diagram canvas (the block's + * `bg-muted/30` tint over the document card, and over the bare page for a + * host that mounts the block there), node fills (`card`), cluster and + * composite-state fills (`muted`), popovers and ER rows. A pair that falls + * short is repaired by moving the text (or line) colour toward the mode's + * `foreground` token, the smallest step that satisfies the ratio so hue is + * kept where possible; when `foreground` itself cannot reach the ratio on that + * fill (a light fill in dark mode), the `background` token is used as the ink + * instead, and when neither token reaches it pure black or white is the last + * resort (a mid-luminance fill such as the line colour under a sequence + * number). Ratios are measured on the 8-bit colour Mermaid receives, never on + * the unrounded mix. Categorical fills (pie slices, branch lines, mindmap + * sections, journey tasks) are normalized to one lightness per page polarity + * (0.74 on a dark page, 0.50 on a light one, chroma clamped to 0.06..0.15) so + * a single ink, the `background` token, reads on all of them; each fill is + * additionally pushed in lightness until that ink reaches 4.5:1. Polarity is + * the measured luminance of the `background` token, not the mode label, so a + * dark-only palette rendered under a light label still gets fills its ink can + * carry; the mode label only picks the Mermaid base theme. Structural strokes (node, cluster, actor + * borders) are guaranteed 1.5:1 against the canvas, nudged toward + * `muted-foreground`, so a palette with a near-invisible `border` still draws + * node outlines. + */ +import type { Mermaid, MermaidConfig } from 'mermaid'; +import { MERMAID_CONFIG } from './mermaid'; +import { + compositeOver, + contrastRatio, + hueDistance, + mixOklab, + oklchToRgb, + parseCssColor, + parseOpaqueColor, + quantize, + relativeLuminance, + rgbToOklch, + rotateHue, + toHex, + withOklchLightness, + type RgbColor, +} from './cssColor'; + +export type MermaidThemeMode = 'light' | 'dark'; + +/** The custom properties the mapping reads (without the `--` prefix). */ +export const MERMAID_THEME_TOKEN_NAMES = [ + 'background', + 'foreground', + 'card', + 'card-foreground', + 'popover', + 'border', + 'muted', + 'muted-foreground', + 'primary', + 'primary-foreground', + 'secondary', + 'accent', + 'destructive', + 'success', + 'warning', + 'font-sans', +] as const; + +export type MermaidThemeTokenName = (typeof MERMAID_THEME_TOKEN_NAMES)[number]; + +/** Raw CSS values keyed by token name, as read off the document. */ +export type MermaidThemeTokens = Partial>; + +export interface MermaidThemeSpec { + theme: 'dark' | 'default'; + themeVariables: Record; +} + +/** WCAG minimums the guard enforces. */ +export const MERMAID_TEXT_CONTRAST_MIN = 4.5; +export const MERMAID_LINE_CONTRAST_MIN = 3; +export const MERMAID_BORDER_CONTRAST_MIN = 1.5; +/** + * Headroom the mapping adds over the text and line minimums. This arithmetic + * composites in float and rounds once; a browser composites the container's + * `bg-muted/30` tint in its own space, so a pair repaired to exactly 4.5 or + * 3.0 here can measure a hair under in the rendered SVG. + */ +const GUARD_HEADROOM = 0.1; + +/** Target OKLCH lightness of categorical fills per mode (see module doc). */ +const CATEGORICAL_LIGHTNESS: Record = { dark: 0.74, light: 0.5 }; +const CATEGORICAL_MAX_CHROMA = 0.15; +const CATEGORICAL_MIN_CHROMA = 0.06; +const CATEGORICAL_COUNT = 12; +/** Hues closer than this (degrees) are treated as the same family. */ +const HUE_SEPARATION = 18; +/** Opacity of the diagram container's `bg-muted/30` tint over the page. */ +const CANVAS_MUTED_ALPHA = 0.3; + +// --------------------------------------------------------------------------- +// Token reading (browser) +// --------------------------------------------------------------------------- + +/** + * Read the theme tokens off `el` (default: the document element, where + * `ThemeProvider` puts the `theme-*` / `light` classes). A value the parser + * does not understand (`color-mix()`, a `var()` chain) is resolved through a + * throwaway probe element so the engine does the substitution. Returns + * `undefined` when neither `--background` nor `--foreground` resolves, which + * is the signal to keep the static config. + */ +export function readThemeTokens(el?: Element | null): MermaidThemeTokens | undefined { + if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return undefined; + const target = el ?? document.documentElement; + if (!target) return undefined; + let computed: CSSStyleDeclaration; + try { + computed = getComputedStyle(target); + } catch { + return undefined; + } + const tokens: MermaidThemeTokens = {}; + let probe: HTMLElement | null = null; + try { + for (const name of MERMAID_THEME_TOKEN_NAMES) { + let raw = ''; + try { + raw = computed.getPropertyValue(`--${name}`).trim(); + } catch { + raw = ''; + } + if (!raw) continue; + if (name === 'font-sans') { + tokens[name] = raw; + continue; + } + if (parseCssColor(raw)) { + tokens[name] = raw; + continue; + } + // Unparsable as written: let the engine resolve it to a colour. + try { + if (!probe) { + probe = document.createElement('span'); + probe.setAttribute('aria-hidden', 'true'); + probe.style.position = 'absolute'; + probe.style.width = '0'; + probe.style.height = '0'; + probe.style.overflow = 'hidden'; + probe.style.pointerEvents = 'none'; + target.appendChild(probe); + } + probe.style.color = `var(--${name})`; + const resolved = getComputedStyle(probe).color.trim(); + if (resolved && parseCssColor(resolved)) tokens[name] = resolved; + } catch { + // leave the token absent + } + } + } finally { + probe?.remove(); + } + if (!tokens.background || !tokens.foreground) return undefined; + return tokens; +} + +// --------------------------------------------------------------------------- +// Mapping (pure) +// --------------------------------------------------------------------------- + +interface Palette { + canvas: RgbColor; + canvasOnBackground: RgbColor; + background: RgbColor; + foreground: RgbColor; + card: RgbColor; + cardForeground: RgbColor; + popover: RgbColor; + border: RgbColor; + muted: RgbColor; + mutedForeground: RgbColor; + primary: RgbColor; + primaryForeground: RgbColor; + secondary: RgbColor; + accent: RgbColor; + destructive: RgbColor; + success: RgbColor; + warning: RgbColor; + fontFamily: string | undefined; +} + +/** + * Parse the tokens into opaque colours, filling gaps from the two required + * ones so the mapping below is total. Alpha (e.g. a `#ffffff99` + * muted-foreground) is composited over the page background. + */ +function resolvePalette(tokens: MermaidThemeTokens): Palette | null { + const background = parseCssColor(tokens.background); + if (!background) return null; + const opaqueBackground = compositeOver(background, { r: 1, g: 1, b: 1, a: 1 }); + const foreground = parseOpaqueColor(tokens.foreground, opaqueBackground); + if (!foreground) return null; + const over = (value: string | undefined, fallback: RgbColor): RgbColor => + parseOpaqueColor(value, opaqueBackground) ?? fallback; + const towardFg = (t: number): RgbColor => mixOklab(opaqueBackground, foreground, t); + + const card = over(tokens.card, opaqueBackground); + const muted = over(tokens.muted, towardFg(0.08)); + const primary = over(tokens.primary, foreground); + // The block's `bg-muted/30` container sits on the document card in + // Plannotator (`bg-card` article); a host may place it straight on the page. + const canvas = compositeOver({ ...muted, a: CANVAS_MUTED_ALPHA }, card); + const canvasOnBackground = compositeOver({ ...muted, a: CANVAS_MUTED_ALPHA }, opaqueBackground); + const font = tokens['font-sans']?.trim(); + + return { + canvas, + canvasOnBackground, + background: opaqueBackground, + foreground, + card, + cardForeground: over(tokens['card-foreground'], foreground), + popover: over(tokens.popover, card), + border: over(tokens.border, towardFg(0.2)), + muted, + mutedForeground: over(tokens['muted-foreground'], towardFg(0.7)), + primary, + primaryForeground: over(tokens['primary-foreground'], opaqueBackground), + secondary: over(tokens.secondary, muted), + accent: over(tokens.accent, primary), + destructive: over(tokens.destructive, parseCssColor('#e5484d') as RgbColor), + success: over(tokens.success, parseCssColor('#3fb950') as RgbColor), + warning: over(tokens.warning, parseCssColor('#d29922') as RgbColor), + fontFamily: font || undefined, + }; +} + +/** + * Repair `color` against `against` until the pair reaches `min`, by the + * smallest OKLab step toward the first ink that can reach it (see the module + * doc for the rule). Returns `color` unchanged when it already passes. + */ +export function ensureContrast(color: RgbColor, against: RgbColor, min: number, inks: readonly RgbColor[]): RgbColor { + const start = quantize(color); + const fill = quantize(against); + if (contrastRatio(start, fill) >= min) return start; + let best: RgbColor = start; + let bestRatio = contrastRatio(start, fill); + // The tokens first; pure black and white are the last resort for a + // mid-luminance fill that neither token can carry text on. + for (const ink of [...inks, BLACK, WHITE]) { + const inkRatio = contrastRatio(ink, fill); + if (inkRatio < min) { + if (inkRatio > bestRatio) { + best = ink; + bestRatio = inkRatio; + } + continue; + } + // Binary search the smallest mix toward this ink that passes, measured + // on the 8-bit colour Mermaid will receive. + let lo = 0; + let hi = 1; + for (let i = 0; i < 12; i++) { + const mid = (lo + hi) / 2; + if (contrastRatio(quantize(mixOklab(start, ink, mid)), fill) >= min) hi = mid; + else lo = mid; + } + return quantize(mixOklab(start, ink, hi)); + } + return quantize(best); +} + +const BLACK: RgbColor = { r: 0, g: 0, b: 0, a: 1 }; +const WHITE: RgbColor = { r: 1, g: 1, b: 1, a: 1 }; + +/** + * Whether the page reads as dark: the luminance at which black and white + * text contrast equally (about 0.179). Decided from the background token's + * measured luminance rather than the mode label, so a dark-only palette that + * a host renders under a light label still gets fills its ink can carry. + */ +export function isDarkBackground(background: RgbColor): boolean { + return relativeLuminance(background) < 0.179; +} + +/** + * Push a fill's lightness away from `ink` until `ink` reads on it at 4.5:1. + * Used for the categorical scale, whose single ink is fixed per mode. + */ +function fitFillForInk(fill: RgbColor, ink: RgbColor, polarity: MermaidThemeMode): RgbColor { + let current = quantize(fill); + const step = polarity === 'dark' ? 0.02 : -0.02; + for (let i = 0; i < 24 && contrastRatio(ink, current) < MERMAID_TEXT_CONTRAST_MIN; i++) { + const L = rgbToOklch(current).L + step; + if (L < 0.05 || L > 0.98) break; + current = quantize(withOklchLightness(current, L)); + } + return current; +} + +/** + * Twelve categorical fills for pie / git / mindmap / journey / timeline, + * seeded from the palette's own accent tokens (in order: primary, accent, + * success, warning, destructive, secondary; greys skipped) and filled out with + * hue rotations of the first seed, all normalized to one lightness and a + * chroma band so they read as one family and share one ink. + */ +export function buildCategoricalScale(p: Palette, polarity: MermaidThemeMode): RgbColor[] { + const targetL = CATEGORICAL_LIGHTNESS[polarity]; + const normalize = (c: RgbColor): RgbColor => { + const lch = rgbToOklch(c); + const C = Math.min(CATEGORICAL_MAX_CHROMA, Math.max(CATEGORICAL_MIN_CHROMA, lch.C)); + return { ...oklchToRgb({ L: targetL, C, H: lch.H }), a: 1 }; + }; + const hues: number[] = []; + const out: RgbColor[] = []; + const push = (c: RgbColor): void => { + const h = rgbToOklch(c).H; + if (hues.some((existing) => hueDistance(existing, h) < HUE_SEPARATION)) return; + hues.push(h); + out.push(normalize(c)); + }; + const seeds = [p.primary, p.accent, p.success, p.warning, p.destructive, p.secondary]; + for (const seed of seeds) { + if (rgbToOklch(seed).C >= 0.05) push(seed); + if (out.length >= CATEGORICAL_COUNT) break; + } + // A grey palette (no chromatic seed) still gets a scale: start from a + // mid-chroma blue-violet so slices stay tellable apart. + const base: RgbColor = out.length ? out[0] : { ...withOklchLightness(parseCssColor('#7c7cff') as RgbColor, targetL), a: 1 }; + if (!out.length) push(base); + for (let k = 1; out.length < CATEGORICAL_COUNT && k < 40; k++) { + // Golden-angle-ish stepping spreads the rotations before wrapping. + push(rotateHue(base, (k * 137.5) % 360)); + } + // Every fill must carry the mode ink at 4.5:1. + return out.slice(0, CATEGORICAL_COUNT).map((c) => fitFillForInk(c, p.background, polarity)); +} + +/** + * Derive the Mermaid base theme and a total `themeVariables` override from + * the tokens. Returns `null` when the required tokens are missing or + * unparsable, which callers treat as "use the static config". + */ +export function buildMermaidThemeVariables(tokens: MermaidThemeTokens | undefined, mode: MermaidThemeMode): MermaidThemeSpec | null { + if (!tokens) return null; + const p = resolvePalette(tokens); + if (!p) return null; + + // Base theme follows the mode; fill lightness and ink follow the measured page. + const polarity: MermaidThemeMode = isDarkBackground(p.background) ? 'dark' : 'light'; + const inks = [p.foreground, p.background] as const; + const rowEven = mixOklab(p.card, p.muted, 0.6); + // Every surface page-level text and lines can land on: the canvas (over the + // card, and over the bare page for hosts), node fills, cluster fills, ER + // rows, popovers. A colour guarded against all of them reads everywhere. + const surfaces = [p.canvas, p.canvasOnBackground, p.card, p.muted, p.popover, rowEven] as const; + // A little headroom over the published minimums absorbs the compositing + // and rounding differences between this arithmetic and a browser's. + const textMin = MERMAID_TEXT_CONTRAST_MIN + GUARD_HEADROOM; + const lineMin = MERMAID_LINE_CONTRAST_MIN + GUARD_HEADROOM; + const text = (color: RgbColor, fill: RgbColor): RgbColor => ensureContrast(color, fill, textMin, inks); + const textOnSurfaces = (color: RgbColor): RgbColor => + surfaces.reduce((c, surface) => ensureContrast(c, surface, textMin, inks), quantize(color)); + const line = (color: RgbColor): RgbColor => + [p.canvas, p.canvasOnBackground, p.muted, p.card].reduce((c, surface) => ensureContrast(c, surface, lineMin, inks), quantize(color)); + const stroke = (color: RgbColor): RgbColor => + ensureContrast(color, p.canvas, MERMAID_BORDER_CONTRAST_MIN, [p.mutedForeground, p.foreground]); + + const canvas = p.canvas; + const fg = textOnSurfaces(p.foreground); + const cardText = textOnSurfaces(p.cardForeground); + const mutedText = cardText; + const popoverText = cardText; + const lineColor = line(p.mutedForeground); + const nodeBorder = stroke(p.border); + const clusterBorder = stroke(p.border); + const primaryLine = line(p.primary); + const destructiveLine = line(p.destructive); + const warningLine = line(p.warning); + // A note is a card tinted toward the warning token, so it stays a surface + // its own text reads on rather than a fixed yellow. + const noteBg = mixOklab(p.card, p.warning, 0.18); + const noteText = text(p.cardForeground, noteBg); + const errorBg = mixOklab(p.card, p.destructive, 0.25); + const errorText = text(p.foreground, errorBg); + // Ink on a line-coloured shape (sequence numbers sit on `lineColor` discs). + const onLine = text(p.background, lineColor); + + const scale = buildCategoricalScale(p, polarity); + const scaleInk = p.background; + const scaleHex = scale.map(toHex); + const scalePeer = scale.map((c) => toHex(mixOklab(c, scaleInk, 0.25))); + const scaleLabel = scale.map((c) => toHex(text(scaleInk, c))); + + const h = toHex; + const vars: Record = { + darkMode: mode === 'dark', + ...(p.fontFamily ? { fontFamily: p.fontFamily } : {}), + + // General + background: h(canvas), + primaryColor: h(p.card), + primaryTextColor: h(cardText), + primaryBorderColor: h(nodeBorder), + secondaryColor: h(p.muted), + secondaryTextColor: h(mutedText), + secondaryBorderColor: h(nodeBorder), + tertiaryColor: h(p.popover), + tertiaryTextColor: h(popoverText), + tertiaryBorderColor: h(nodeBorder), + textColor: h(fg), + titleColor: h(fg), + labelColor: h(fg), + mainContrastColor: h(fg), + darkTextColor: h(text(p.background, p.foreground)), + lineColor: h(lineColor), + arrowheadColor: h(lineColor), + defaultLinkColor: h(lineColor), + mainBkg: h(p.card), + secondBkg: h(p.muted), + border1: h(nodeBorder), + border2: h(clusterBorder), + labelBackground: h(canvas), + errorBkgColor: h(errorBg), + errorTextColor: h(errorText), + useGradient: false, + + // Flowchart + nodeBkg: h(p.card), + nodeBorder: h(nodeBorder), + nodeTextColor: h(cardText), + clusterBkg: h(p.muted), + clusterBorder: h(clusterBorder), + edgeLabelBackground: h(canvas), + + // Sequence + actorBkg: h(p.card), + actorBorder: h(nodeBorder), + actorTextColor: h(cardText), + actorLineColor: h(lineColor), + signalColor: h(lineColor), + signalTextColor: h(fg), + labelBoxBkgColor: h(p.muted), + // Also strokes the dashed loop/alt frame (`.loopLine`), a real line. + labelBoxBorderColor: h(lineColor), + labelTextColor: h(mutedText), + loopTextColor: h(mutedText), + noteBkgColor: h(noteBg), + noteBorderColor: h(warningLine), + noteTextColor: h(noteText), + activationBkgColor: h(p.muted), + activationBorderColor: h(nodeBorder), + sequenceNumberColor: h(onLine), + + // State + stateBkg: h(p.card), + stateLabelColor: h(cardText), + transitionColor: h(lineColor), + transitionLabelColor: h(fg), + labelBackgroundColor: h(p.card), + compositeBackground: h(p.muted), + compositeTitleBackground: h(p.muted), + compositeBorder: h(nodeBorder), + altBackground: h(p.card), + innerEndBackground: h(lineColor), + specialStateColor: h(lineColor), + + // Class + classText: h(cardText), + + // ER + attributeBackgroundColorOdd: h(p.card), + attributeBackgroundColorEven: h(rowEven), + rowOdd: h(p.card), + rowEven: h(rowEven), + + // Requirement + requirementBackground: h(p.card), + requirementBorderColor: h(nodeBorder), + requirementTextColor: h(cardText), + relationColor: h(lineColor), + relationLabelBackground: h(canvas), + relationLabelColor: h(fg), + + // Git + commitLabelColor: h(fg), + commitLabelBackground: h(canvas), + tagLabelColor: h(cardText), + tagLabelBackground: h(p.card), + tagLabelBorder: h(nodeBorder), + + // Gantt + sectionBkgColor: h(p.muted), + altSectionBkgColor: h(canvas), + sectionBkgColor2: h(p.card), + excludeBkgColor: h(p.muted), + taskBorderColor: h(nodeBorder), + taskBkgColor: scaleHex[0], + taskTextColor: scaleLabel[0], + taskTextLightColor: h(fg), + taskTextDarkColor: scaleLabel[0], + taskTextOutsideColor: h(fg), + taskTextClickableColor: h(text(p.primary, canvas)), + activeTaskBorderColor: h(primaryLine), + activeTaskBkgColor: scaleHex[1] ?? scaleHex[0], + gridColor: h(nodeBorder), + doneTaskBkgColor: h(p.muted), + doneTaskBorderColor: h(nodeBorder), + critBorderColor: h(destructiveLine), + critBkgColor: h(fitFillForInk(withOklchLightness(p.destructive, CATEGORICAL_LIGHTNESS[polarity]), scaleInk, polarity)), + todayLineColor: h(destructiveLine), + vertLineColor: h(primaryLine), + + // Pie + pieTitleTextColor: h(fg), + pieSectionTextColor: h(text(scaleInk, scale[0])), + pieLegendTextColor: h(fg), + pieStrokeColor: h(canvas), + pieOuterStrokeColor: h(nodeBorder), + pieOpacity: '1', + + // Quadrant + quadrant1Fill: h(p.card), + quadrant2Fill: h(p.muted), + quadrant3Fill: h(p.muted), + quadrant4Fill: h(p.card), + quadrant1TextFill: h(cardText), + quadrant2TextFill: h(mutedText), + quadrant3TextFill: h(mutedText), + quadrant4TextFill: h(cardText), + quadrantPointFill: h(primaryLine), + quadrantPointTextFill: h(fg), + quadrantXAxisTextFill: h(fg), + quadrantYAxisTextFill: h(fg), + quadrantInternalBorderStrokeFill: h(nodeBorder), + quadrantExternalBorderStrokeFill: h(nodeBorder), + quadrantTitleFill: h(fg), + + // Architecture / C4 + archEdgeColor: h(lineColor), + archEdgeArrowColor: h(lineColor), + archGroupBorderColor: h(nodeBorder), + personBkg: h(p.card), + personBorder: h(nodeBorder), + + // Venn + vennTitleTextColor: h(fg), + vennSetTextColor: h(fg), + + // Event modeling + emUiFill: h(p.card), + emUiStroke: h(nodeBorder), + emProcessorFill: scaleHex[3] ?? scaleHex[0], + emProcessorStroke: h(lineColor), + emReadModelFill: scaleHex[2] ?? scaleHex[0], + emReadModelStroke: h(lineColor), + emCommandFill: scaleHex[0], + emCommandStroke: h(lineColor), + emEventFill: scaleHex[1] ?? scaleHex[0], + emEventStroke: h(lineColor), + emSwimlaneBackgroundOdd: h(canvas), + emSwimlaneBackgroundStroke: h(nodeBorder), + emArrowhead: h(lineColor), + emRelationStroke: h(lineColor), + + // Scale-driven families + scaleLabelColor: scaleLabel[0], + xyChart: { + backgroundColor: h(canvas), + titleColor: h(fg), + dataLabelColor: h(fg), + legendTextColor: h(fg), + xAxisTitleColor: h(fg), + xAxisLabelColor: h(fg), + xAxisTickColor: h(lineColor), + xAxisLineColor: h(lineColor), + yAxisTitleColor: h(fg), + yAxisLabelColor: h(fg), + yAxisTickColor: h(lineColor), + yAxisLineColor: h(lineColor), + plotColorPalette: scaleHex.join(','), + }, + packet: { + startByteColor: h(fg), + endByteColor: h(fg), + labelColor: h(cardText), + titleColor: h(fg), + blockStrokeColor: h(nodeBorder), + blockFillColor: h(p.card), + }, + radar: { + axisColor: h(lineColor), + graticuleColor: h(nodeBorder), + }, + wardley: { + backgroundColor: h(canvas), + axisColor: h(lineColor), + axisTextColor: h(fg), + gridColor: h(nodeBorder), + componentFill: h(p.card), + componentStroke: h(lineColor), + componentLabelColor: h(fg), + linkStroke: h(lineColor), + evolutionStroke: h(destructiveLine), + annotationStroke: h(lineColor), + annotationTextColor: h(fg), + annotationFill: h(p.card), + }, + cynefin: { + boundaryColor: h(lineColor), + cliffColor: h(destructiveLine), + arrowColor: h(lineColor), + textColor: h(fg), + labelColor: h(fg), + complexBg: scaleHex[0], + complicatedBg: scaleHex[1] ?? scaleHex[0], + chaoticBg: h(fitFillForInk(withOklchLightness(p.destructive, CATEGORICAL_LIGHTNESS[polarity]), scaleInk, polarity)), + clearBg: h(fitFillForInk(withOklchLightness(p.success, CATEGORICAL_LIGHTNESS[polarity]), scaleInk, polarity)), + confusionBg: scaleHex[2] ?? scaleHex[0], + }, + }; + + for (let i = 0; i < CATEGORICAL_COUNT; i++) { + vars[`cScale${i}`] = scaleHex[i]; + vars[`cScaleInv${i}`] = scaleLabel[i]; + vars[`cScalePeer${i}`] = scalePeer[i]; + vars[`cScaleLabel${i}`] = scaleLabel[i]; + vars[`pie${i + 1}`] = scaleHex[i]; + } + for (let i = 0; i < 8; i++) { + vars[`git${i}`] = scaleHex[i]; + vars[`gitInv${i}`] = scaleLabel[i]; + vars[`gitBranchLabel${i}`] = scaleLabel[i]; + vars[`fillType${i}`] = scaleHex[i]; + vars[`venn${i + 1}`] = scaleHex[i]; + } + return { theme: mode === 'dark' ? 'dark' : 'default', themeVariables: vars }; +} + +/** The full Mermaid config for a spec: `MERMAID_CONFIG` with the theme swapped. */ +export function buildMermaidConfig(spec: MermaidThemeSpec | null): MermaidConfig { + if (!spec) return MERMAID_CONFIG; + return { ...MERMAID_CONFIG, theme: spec.theme, themeVariables: spec.themeVariables }; +} + +// --------------------------------------------------------------------------- +// Runtime application (cached by key) +// --------------------------------------------------------------------------- + +/** `mode:palette`, the cache key `applyMermaidTheme` compares. */ +export function mermaidThemeKey(colorTheme: string, mode: MermaidThemeMode): string { + return `${mode}:${colorTheme}`; +} + +function modeFromKey(key: string): MermaidThemeMode { + return key.startsWith('light:') ? 'light' : 'dark'; +} + +export type MermaidThemeApplyResult = 'unchanged' | 'dynamic' | 'static'; + +let appliedRuntime: Pick | null = null; +let appliedKey: string | null = null; +let appliedKind: 'dynamic' | 'static' | null = null; + +/** + * Initialize `mermaid` for the `(palette, mode)` named by `key`, once per key + * change. Reads the tokens from `root` (default: the document element). With + * no tokens the runtime is left on the static config it was initialized with + * (no `initialize` call at all unless a dynamic theme was applied earlier), + * which is the fallback contract for hosts. + */ +export function applyMermaidTheme( + mermaid: Pick, + key: string, + root?: Element | null, +): MermaidThemeApplyResult { + if (appliedRuntime === mermaid && appliedKey === key) return 'unchanged'; + const spec = buildMermaidThemeVariables(readThemeTokens(root), modeFromKey(key)); + const sameRuntime = appliedRuntime === mermaid; + appliedRuntime = mermaid; + appliedKey = key; + if (!spec) { + if (sameRuntime && appliedKind === 'dynamic') mermaid.initialize(MERMAID_CONFIG); + appliedKind = 'static'; + return 'static'; + } + mermaid.initialize(buildMermaidConfig(spec)); + appliedKind = 'dynamic'; + return 'dynamic'; +} + +/** Test hook: forget the last applied key so the next apply re-initializes. */ +export function __resetMermaidThemeForTests(): void { + appliedRuntime = null; + appliedKey = null; + appliedKind = null; +}