Skip to content

feat(ui): Mermaid diagrams follow the active color theme and mode - #1556

Merged
backnotprop merged 4 commits into
mainfrom
feat/mermaid-theme-aware-diagrams
Sep 17, 2026
Merged

backnotprop merged 4 commits into
mainfrom
feat/mermaid-theme-aware-diagrams

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Summary

Mermaid diagrams ignored the app theme: MERMAID_CONFIG pinned Mermaid's dark base theme plus a slate palette, so a diagram was blue-on-slate under GitHub Light and Catppuccin alike (dark node fills on a light page, unthemed pie/git colours everywhere). 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.

  • packages/ui/utils/mermaidTheme.ts: readThemeTokens(el) reads the live theme custom properties off the document (getComputedStyle, with a probe element for color-mix() / var() chains); buildMermaidThemeVariables(tokens, mode) is pure and returns the Mermaid base theme (dark under a dark resolved mode, default under light) plus a complete themeVariables override for every documented family; applyMermaidTheme(mermaid, key) runs the global mermaid.initialize once per (palette, mode) key.
  • packages/ui/utils/cssColor.ts: dependency-free parser (hex, rgb(), hsl(), oklch(), oklab(), lab(), lch(), color(), alpha compositing) plus OKLab mixing and WCAG contrast. Every colour handed to Mermaid is opaque hex, because Mermaid's colour library does not read oklch(), which is what most Plannotator palettes are written in.
  • MermaidBlock derives the key from useTheme() (the same source useFenceTheme uses), applies the theme before every render, and re-renders mounted diagrams when the key changes (the fence re-highlight pattern).
  • MERMAID_CONFIG, loadMermaidRuntime, mermaid-eager and securityLevel: 'strict' are unchanged; flowchart.htmlLabels / curve are carried into the dynamic config. GraphvizBlock already maps its SVG to var(--foreground) / var(--muted-foreground) / var(--muted) and needs nothing.

Token → variable mapping

Canvas = muted at 30% over card (the block's bg-muted/30 container on the document card).

Tokens Mermaid variables
canvas background, labelBackground, edgeLabelBackground, commitLabelBackground, relationLabelBackground, altSectionBkgColor, pieStrokeColor, emSwimlaneBackgroundOdd, xyChart.backgroundColor, wardley.backgroundColor
card primaryColor, mainBkg, nodeBkg, actorBkg, stateBkg, labelBackgroundColor, altBackground, requirementBackground, tagLabelBackground, personBkg, attributeBackgroundColorOdd, rowOdd, sectionBkgColor2, quadrant1/4Fill, packet.blockFillColor, wardley.componentFill
card-foreground primaryTextColor, nodeTextColor, actorTextColor, stateLabelColor, classText, requirementTextColor, tagLabelColor, packet.labelColor
foreground textColor, titleColor, labelColor, mainContrastColor, signalTextColor, labelTextColor, loopTextColor, transitionLabelColor, relationLabelColor, commitLabelColor, taskTextLightColor, taskTextOutsideColor, pieTitleTextColor, pieLegendTextColor, vennTitleTextColor, vennSetTextColor, quadrant / xyChart / wardley / cynefin text
border primaryBorderColor, secondaryBorderColor, tertiaryBorderColor, nodeBorder, border1, border2, clusterBorder, actorBorder, activationBorderColor, compositeBorder, taskBorderColor, doneTaskBorderColor, gridColor, pieOuterStrokeColor, tagLabelBorder, archGroupBorderColor, personBorder, requirementBorderColor, emUiStroke, quadrant*BorderStrokeFill, radar.graticuleColor
muted-foreground lineColor, arrowheadColor, defaultLinkColor, signalColor, actorLineColor, labelBoxBorderColor (also strokes the loop frame), transitionColor, relationColor, innerEndBackground, specialStateColor, archEdgeColor, archEdgeArrowColor, em*Stroke, emArrowhead, xyChart axis lines, radar.axisColor, wardley strokes, cynefin boundary / arrow
muted secondaryColor, secondBkg, clusterBkg, compositeBackground, compositeTitleBackground, labelBoxBkgColor, activationBkgColor, sectionBkgColor, excludeBkgColor, doneTaskBkgColor, quadrant2/3Fill; attributeBackgroundColorEven / rowEven are card mixed 60% toward muted
popover tertiaryColor
card tinted 18% toward warning, warning noteBkgColor, noteBorderColor (note text guarded on the tint)
card tinted 25% toward destructive errorBkgColor
primary activeTaskBorderColor, vertLineColor, quadrantPointFill, taskTextClickableColor
destructive todayLineColor, critBorderColor, critBkgColor, wardley.evolutionStroke, cynefin.cliffColor
categorical scale cScale0..11, cScaleInv*, cScalePeer*, cScaleLabel*, scaleLabelColor, pie1..12, git0..7, gitInv*, gitBranchLabel*, fillType0..7, venn1..8, taskBkgColor, activeTaskBkgColor, em*Fill, xyChart.plotColorPalette, cynefin domain fills
background the ink on categorical fills (cScaleLabel*, gitBranchLabel*, pieSectionTextColor, taskTextColor), sequenceNumberColor (on the line-coloured disc), darkTextColor
--font-sans fontFamily (when present)

The twelve categorical fills are seeded from the palette's own accents 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. darkMode follows the mode.

Contrast rule

Every text-on-fill pair must reach WCAG 4.5:1 and every line-on-canvas pair 3:1, plus 0.1 headroom (a browser composites the container tint in its own space). Page-level text and lines are guarded against every surface they can cross, not one: the canvas over the card and over the bare page (hosts), node fills (card), cluster / composite-state fills (muted), popovers, ER rows. A pair that falls short is repaired by the smallest OKLab step toward the mode's foreground token (hue kept where possible); when foreground cannot reach the ratio on that fill, the background token is the ink; when neither token can, pure black or white (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. 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 is the measured luminance of background, not 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. mermaidTheme.test.ts sweeps all 52 shipped palettes in both modes against these pairs, so a new palette cannot regress the guard.

Fallback contract for hosts

Nothing changes for a host whose document carries no theme tokens: readThemeTokens returns undefined, buildMermaidThemeVariables returns null, buildMermaidConfig(null) is MERMAID_CONFIG itself, and applyMermaidTheme records the key without calling initialize, so the runtime keeps the static config the loader or eager entry initialized it with and renders byte-identically to @plannotator/ui 0.39.0 (pinned by mermaidTheme.test.ts and MermaidBlock.theme.test.tsx). A host that mounts ThemeProvider with theme.css gets themed diagrams with no configuration. New exports are additive; packages/ui/package.json is not bumped (owner bumps at publish); HANDOFF.md "Publishing & versioning" carries the consumer note, README.md's lazy-renderers section and AGENTS.md "Syntax Highlighting" describe the mechanism.

Verification

Playwright (Chromium) against one annotate server on a 15-diagram sample (flowcharts with subgraphs, state, class, ER, sequence, gitGraph, pie, ELK, requirement), every diagram screenshotted at 2x and MEASURED from the rendered SVG: each text node vs the fill behind it (≥ 4.5:1), each edge / lifeline / transition / relation / branch stroke and arrowhead vs the diagram canvas (≥ 3:1).

  • Combinations: 104 (all 52 palettes × dark and light; one-mode palettes render the default palette in the other mode, which is the app's existing behaviour), 17,160 text pairs and 9,880 line pairs.
  • First pass: 1863 failures in 20 combinations (edges 2.4–2.9:1, cluster titles 4.0:1) — the guard had measured against the page background while the canvas sits on the document card and edges cross cluster fills. Fixed by guarding against every surface (commit 3).
  • Second pass: 0 failing pairs remaining (no residual list).
  • Before (released 0.27.15, static config), same document, github / catppuccin / everforest / dracula in both modes: 537 failing pairs in 8 combinations (class-diagram cardinalities at 1.0:1, light-mode edges at 1.2–1.5:1, dark node fills on light pages).
  • Sign-off page (standalone, relative image paths; per palette, both modes, a strip of the 15 diagrams with the failure count per cell and a "before" row for the 4 representative palettes): /private/tmp/claude-501/-Users-ramos-plannotator-plannotator/34d56d5a-33a8-4878-92b2-3f4318214ae2/scratchpad/mermaid-theme/index.html.

Checks: DOM_TESTS=1 bun test packages/ui packages/editor (1854 pass; only the 4 pre-existing order-dependent DocBadges failures), bun test packages/ui/utils (697 pass), bun run typecheck, bun run --cwd packages/ui smoke:package, bun run --cwd apps/guides-show build:viewer && check:manifest (manifest in sync, no repin — the viewer does not bundle MermaidBlock), tests/entry-assets.test.ts (26 pass), scripts/dom-test-allowlist.test.ts (the new DOM test is registered in test.yml).

Known limits / follow-ups

  • Mermaid hardcodes a #000000 stroke on the sequence crosshead marker (-x lost messages); no theme variable reaches it, same as every Mermaid theme.
  • The Mermaid 12 bump is a separate follow-up that reuses this mapping unchanged.

Mermaid diagrams rendered from one static config (dark base theme plus a slate
palette) in every palette and both modes. MermaidBlock now reads the live theme
tokens off the document, maps them to a complete themeVariables set for every
diagram family (utils/mermaidTheme), and re-initializes the runtime once per
(palette, mode) key before rendering, re-rendering mounted diagrams when the key
changes. A contrast guard keeps text-on-fill pairs at 4.5:1 and lines at 3:1.

Hosts without theme tokens keep the static MERMAID_CONFIG with no extra
initialize call; securityLevel stays strict.
The first palette sweep (104 combinations) found edges at 2.4-2.9:1 and cluster
titles at 4.0:1 in 20 palettes: the guard measured against the page
background, but the block's bg-muted/30 canvas sits on the document card, and
edges and titles also cross cluster (muted) fills. Guard page-level text and
lines against the canvas over the card and over the page, node, cluster,
popover and ER-row fills, with 0.1 headroom for the browser's own compositing.
The sequence loop frame (labelBoxBorderColor) is a line and now takes the
line colour.
@backnotprop
backnotprop merged commit 2b264a2 into main Sep 17, 2026
28 checks passed
@backnotprop
backnotprop deleted the feat/mermaid-theme-aware-diagrams branch September 17, 2026 06:28
backnotprop added a commit that referenced this pull request Sep 17, 2026
…heme-aware diagrams; core 0.25.3 (#1557)

* chore(ui): mermaid 12.0.0 (exact), ELK layout by default

Bump @plannotator/ui's mermaid dependency from ^11.17.2 to an exact 12.0.0
and regenerate bun.lock. Mermaid 12 lays flowchart, state, class, ER and
requirement diagrams out with ELK by default (elkjs is now bundled in
mermaid itself), targets Safari 17.4+ / ES2024, and drops the legacy
flowchart/class/state diagram ids. We take 12's defaults rather than
pinning the 11 ones. No API or type changes were needed: MERMAID_CONFIG
(securityLevel 'strict') and the theme mapping apply unchanged, every
generated SVG id keeps its 11.x shape, and the katex import Mermaid makes
for $$ labels is the same call (the math-slot bridge is untouched).

The visual-explainer skill's render gate now expects Mermaid 12.

* feat(editor): load the Mermaid runtime lazily on the first diagram

Drop the eager registration import (@plannotator/ui/utils/mermaid-eager)
from the plan editor entry. Since Mermaid 12 the runtime plus ELK is about
1.8 MB larger, so a plan with no diagram must not pay for it: the block now
resolves the runtime through utils/mermaid's own import('mermaid') on the
first MermaidBlock render. In the chunked share-portal build that moves
mermaid.core (~640 KB) out of the entry chunk; the single-file builds still
inline it through inlineDynamicImports, so nothing changes there except
Mermaid 12's own size.

MermaidBlock shows the source fence under a role=status "Rendering
diagram" line until the first render lands, never the error panel, and
applyMermaidTheme is keyed on the runtime object so the lazily loaded
runtime is themed on its first render like an eagerly registered one.
mermaid-eager stays exported for hosts that want startup registration.

tests/entry-assets.test.ts now asserts the eager Mermaid marker is absent
from both app bundles; DiagramBlock.lazyRetry.test.tsx pins the pending
state.

* docs(ui): HANDOFF 0.40.0 section for Mermaid 12 and the lazy runtime

Add the "Mermaid 12 (0.40.0)" section hosts need to adopt the bump: ELK
layout by default and the other 12 changes we take, the concrete SVG id
patterns (unchanged 11 -> 12, per family), the g.edgePaths declaration-order
change and the select-by-id rule, the Safari 17.4+ / ES2024 floor, the
lazy-load contract (the plan editor no longer imports mermaid-eager; how a
host keeps startup registration; the pending state; measured chunk and
single-file sizes), the theming contract with and without tokens, the 0.40
contrast re-sweep (104 combinations, 0 failures), and the publish order
core 0.25.3 -> ui 0.40.0. Retitle the theme and element-context sections
to 0.40.0, fold the two unreleased publishing bullets into the current
pair, and update the 0.32.0 lazy-renderer text, README and AGENTS for the
new policy.

* chore(core): bump @plannotator/core to 0.25.3

Carries the #1549 html-anchor element-context exports (parseHtmlElementContext,
MAX_ELEMENT_CONTEXT_BYTES, MAX_PAGE_URL_LENGTH) that @plannotator/ui 0.40.0
imports. Publish this before ui 0.40.0.

* chore(ui): bump @plannotator/ui to 0.40.0 on core 0.25.3

Mermaid 12.0.0 (exact), lazy runtime loading in the plan editor, the
theme-aware diagram mapping from #1556, and the #1549 element-context host
seam. Pins @plannotator/core 0.25.3 exactly; bun.lock records both
workspace versions. Publish core 0.25.3 first, then this.

* chore: pin lodash-es 4.18.1 over chevrotain's vulnerable exact pin

Mermaid 12 pulls chevrotain 11.1.2 (via langium), which pins lodash-es
exactly 4.17.23 (CVE-2026-4800 high, CVE-2026-2950 medium), so the
workspace resolved a second, vulnerable copy beside the 4.18.1 that
dagre-d3-es already resolves. A root "overrides" entry forces every
lodash-es range to 4.18.1 (published 2026-04-01, past the 7-day gate);
bun.lock now records a single lodash-es@4.18.1. chevrotain still parses
every diagram grammar under it: all 15 eval diagrams render with zero
errors in the built plan editor, typecheck, the ui utils and MermaidBlock
tests, and the package smoke pass, and the built bundles are byte-for-byte
the same size.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant