feat(ui): Mermaid diagrams follow the active color theme and mode - #1556
Merged
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Mermaid diagrams ignored the app theme:
MERMAID_CONFIGpinned Mermaid'sdarkbase 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 forcolor-mix()/var()chains);buildMermaidThemeVariables(tokens, mode)is pure and returns the Mermaid base theme (darkunder a dark resolved mode,defaultunder light) plus a completethemeVariablesoverride for every documented family;applyMermaidTheme(mermaid, key)runs the globalmermaid.initializeonce 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 readoklch(), which is what most Plannotator palettes are written in.MermaidBlockderives the key fromuseTheme()(the same sourceuseFenceThemeuses), applies the theme before every render, and re-renders mounted diagrams when the key changes (the fence re-highlight pattern).MERMAID_CONFIG,loadMermaidRuntime,mermaid-eagerandsecurityLevel: 'strict'are unchanged;flowchart.htmlLabels/curveare carried into the dynamic config.GraphvizBlockalready maps its SVG tovar(--foreground)/var(--muted-foreground)/var(--muted)and needs nothing.Token → variable mapping
Canvas =
mutedat 30% overcard(the block'sbg-muted/30container on the document card).background,labelBackground,edgeLabelBackground,commitLabelBackground,relationLabelBackground,altSectionBkgColor,pieStrokeColor,emSwimlaneBackgroundOdd,xyChart.backgroundColor,wardley.backgroundColorcardprimaryColor,mainBkg,nodeBkg,actorBkg,stateBkg,labelBackgroundColor,altBackground,requirementBackground,tagLabelBackground,personBkg,attributeBackgroundColorOdd,rowOdd,sectionBkgColor2,quadrant1/4Fill,packet.blockFillColor,wardley.componentFillcard-foregroundprimaryTextColor,nodeTextColor,actorTextColor,stateLabelColor,classText,requirementTextColor,tagLabelColor,packet.labelColorforegroundtextColor,titleColor,labelColor,mainContrastColor,signalTextColor,labelTextColor,loopTextColor,transitionLabelColor,relationLabelColor,commitLabelColor,taskTextLightColor,taskTextOutsideColor,pieTitleTextColor,pieLegendTextColor,vennTitleTextColor,vennSetTextColor, quadrant / xyChart / wardley / cynefin textborderprimaryBorderColor,secondaryBorderColor,tertiaryBorderColor,nodeBorder,border1,border2,clusterBorder,actorBorder,activationBorderColor,compositeBorder,taskBorderColor,doneTaskBorderColor,gridColor,pieOuterStrokeColor,tagLabelBorder,archGroupBorderColor,personBorder,requirementBorderColor,emUiStroke,quadrant*BorderStrokeFill,radar.graticuleColormuted-foregroundlineColor,arrowheadColor,defaultLinkColor,signalColor,actorLineColor,labelBoxBorderColor(also strokes the loop frame),transitionColor,relationColor,innerEndBackground,specialStateColor,archEdgeColor,archEdgeArrowColor,em*Stroke,emArrowhead,xyChartaxis lines,radar.axisColor,wardleystrokes,cynefinboundary / arrowmutedsecondaryColor,secondBkg,clusterBkg,compositeBackground,compositeTitleBackground,labelBoxBkgColor,activationBkgColor,sectionBkgColor,excludeBkgColor,doneTaskBkgColor,quadrant2/3Fill;attributeBackgroundColorEven/rowEvenarecardmixed 60% towardmutedpopovertertiaryColorcardtinted 18% towardwarning,warningnoteBkgColor,noteBorderColor(note text guarded on the tint)cardtinted 25% towarddestructiveerrorBkgColorprimaryactiveTaskBorderColor,vertLineColor,quadrantPointFill,taskTextClickableColordestructivetodayLineColor,critBorderColor,critBkgColor,wardley.evolutionStroke,cynefin.cliffColorcScale0..11,cScaleInv*,cScalePeer*,cScaleLabel*,scaleLabelColor,pie1..12,git0..7,gitInv*,gitBranchLabel*,fillType0..7,venn1..8,taskBkgColor,activeTaskBkgColor,em*Fill,xyChart.plotColorPalette,cynefindomain fillsbackgroundcScaleLabel*,gitBranchLabel*,pieSectionTextColor,taskTextColor),sequenceNumberColor(on the line-coloured disc),darkTextColor--font-sansfontFamily(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, thebackgroundtoken, reads on all of them.darkModefollows 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'sforegroundtoken (hue kept where possible); whenforegroundcannot reach the ratio on that fill, thebackgroundtoken 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 thebackgroundink reaches 4.5:1 on each. Node / cluster / actor borders are guaranteed 1.5:1 against the canvas, nudged towardmuted-foreground, so a palette with a near-invisibleborderstill draws outlines. Page polarity is the measured luminance ofbackground, 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.tssweeps 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:
readThemeTokensreturnsundefined,buildMermaidThemeVariablesreturnsnull,buildMermaidConfig(null)isMERMAID_CONFIGitself, andapplyMermaidThemerecords the key without callinginitialize, so the runtime keeps the static config the loader or eager entry initialized it with and renders byte-identically to@plannotator/ui0.39.0 (pinned bymermaidTheme.test.tsandMermaidBlock.theme.test.tsx). A host that mountsThemeProviderwiththeme.cssgets themed diagrams with no configuration. New exports are additive;packages/ui/package.jsonis 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).
/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 bundleMermaidBlock),tests/entry-assets.test.ts(26 pass),scripts/dom-test-allowlist.test.ts(the new DOM test is registered intest.yml).Known limits / follow-ups
#000000stroke on the sequencecrossheadmarker (-xlost messages); no theme variable reaches it, same as every Mermaid theme.