Theming Diagrams

You write a diagram in a page; you color it in your repository. Theming Mermaid takes exactly two files, and they change together: src/static/js/mermaid.theme.json is the palette, and tailwind/docs/mermaid.css is for the handful of colors the palette cannot reach. Neither file can read the other, and nothing enforces that they agree. Both absent is the normal case and costs nothing — diagrams then render in Mermaid’s own colors.

Everything below is a method you apply to your own brand. leed.ai’s two files are the worked example, and a customer who follows the method ends up with their palette rather than a copy of Leed’s.

Which file a color belongs in — the test, not the taste

Take the decision first, because it is mechanical and it will save you the rest of this page when you arrive with one wrong color.

A gap belongs in the JSON if any themeVariables key reaches it, and in the CSS only if none does.

The diagnostic that settles it without reading Mermaid’s source: render the diagram with every color variable forced to one sentinel color. If the shape moves, it is themeable and belongs in the JSON. If it does not, it is a hardcoded literal and belongs in the CSS.

That test is worth running. Applied to a 547-line reference stylesheet of 86 rule blocks, it kept nine: sixty-one matched nothing or fought a value the palette already reached, and sixteen had a better fix in the JSON — a theme variable the reference’s author did not know existed. It also surfaced one gap the reference had never covered. A rule that matches, reads correctly and paints nothing is worse than no rule, because the next reader believes it.

Why almost every value can be one of your own tokens

Mermaid renders the SVG inline into the document — the browser script assigns diagram.innerHTML = svg — never as an <img src="data:…">. An inline SVG is part of the page, so the browser resolves every var(--token) inside it against :root in the live cascade. Diagrams therefore follow light and dark on their own, and a palette written almost entirely in your own tokens is a single palette rather than two.

This was measured rather than assumed, because a reader will not take it on trust: var() resolves in all three places Mermaid can put a color — an SVG presentation attribute (<rect fill="var(--card)">), an inline style attribute, and Mermaid’s own <style> block. That is why the ER row bands, which the sketch renderer writes with setAttribute("fill", …), are already your tokens with no rule at all.

The same Mermaid flowchart rendered twice on a Leed documentation page, once with the browser emulating a light system theme and once dark, showing the palette following the page in both

Write the palette against site tokens, never docs-only tokens

--docs-* tokens are declared inside an @utility, so they exist only on an element carrying the color-theme class — the documentation <html> and nothing else. A mermaid fence works on any markdown page type. A palette written against --docs-* renders unstyled in a blog or resource page, with no warning and no error.

Use the site-level names declared on bare :root. leed.ai’s palette names thirteen of them — --fg, --fg-muted, --card, --bg, --bg-secondary, --border, --border-strong, --primary, --primary-soft, --error, --error-soft, --success and --success-soft — and nothing else. The catalog of what is available is the site token contract.

Where var() stops working

Mermaid derives colors from colors, and that arithmetic runs on the string it was handed. There are three distinct failure modes and they look nothing alike.

ModeMechanismWhat you see
A — a throwThe color library raises Unsupported color format: "var(--x)"The diagram renders as raw source text. If it happens while the theme is being computed, every diagram on the page does
B — an invalid attributeThe value lands in an SVG presentation attribute, where a custom property is not legalThe element silently takes the initial value: fill goes black, stroke goes to none — invisible. No error
C — a sanitizer rejectThe railroad renderer runs a regex allowlist over color valuesSilently substitutes Mermaid’s own stock color

The eleven that must be literal hex

This is the entire reason a palette has separate light and dark blocks. Freeze these eleven per scheme:

KeyWhy a var() fails hereModeWhat you see
backgroundEvery venn diagram runs a darkness test over itAVenn renders as raw source
primaryColorUnset venn1/venn4/venn5/venn7 are derived from it by hue rotationAVenn renders as raw source
secondaryColorSame derivation, for venn2/venn6/venn8AVenn renders as raw source
tertiaryColorDerives venn3, and every ER diagram fades itAER and venn render as raw source
mainBkgBlock-diagram styles fade it; ER cardinality markers paint it as an attributeA + BBlock renders as raw source
edgeLabelBackgroundFlowchart styles fade it — the most common diagram on any siteAEvery flowchart renders as raw source
quadrant1FillA lighten branch always executes once quadrantPointFill is setAQuadrant charts render as raw source
clusterBkgBlock styles fade it; swimlane title rects paint it as an attributeA + BBlock diagrams render as raw source
clusterBorderSame two pathsA + BBlock diagrams render as raw source
primaryTextColorThe railroad sanitizer reads it, and it is the fallback for three quadrant text fillsCRailroad text goes black
sequenceNumberColorNot literal-bound — it is a guard; see below—Every diagram loses its colors

The literals still follow the reader’s system scheme, because the browser re-initializes Mermaid and redraws on a prefers-color-scheme change. That is the whole job of the light and dark blocks.

Your file will usually carry more than eleven per-scheme values, and that is expected: anything Mermaid paints as a presentation attribute has to be frozen for the same reason, even though nothing derives from it. leed.ai’s blocks carry twenty-one each — the eleven above, plus the event-model fills and the Wardley evolution color, which are mode-B values.

fontFamily is a literal font stack, never a var()

The reason is layout, not typeface: Mermaid measures rendered text to size its nodes, so an unresolved variable breaks the geometry rather than the color. The railroad renderer additionally rejects any family string containing parentheses, which var( fails on its face.

So the stack is hand-duplicated from documentationConfiguration.font and must be changed whenever that changes. Nothing warns you, which is exactly why it drifts. The stacks for the shipped families, to copy verbatim:

{
  "fontFamily": "\"Noto Sans\", ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\""
}

Swap the leading quoted name for "Open Sans", "Inter" or "Geist" and leave the rest of the stack alone; the four sans families ship with the same fallback chain.

Two variables that look redundant and are load-bearing

noteBorderColor and sequenceNumberColor are not set for their own color. Each exists to short-circuit a fallback before it runs on a var():

  • sequenceNumberColor guards sequenceNumberColor || invert(lineColor), and lineColor is a token.
  • noteBorderColor guards noteBorderColor || mkBorder(noteBkgColor), and noteBkgColor is a token.

The shape of the palette file

The file lives at src/static/js/mermaid.theme.json — static/js/, not static/javascript/. Both directories exist in a Leed site and choosing the wrong one fails silently, because a file that is not there is indistinguishable from a site that never wanted a palette.

Four file shapes are accepted.

The full form, and what you want once any value has to differ between schemes.

{
  "shared": { "theme": "base", "themeVariables": { "lineColor": "var(--fg-muted)" } },
  "light":  { "themeVariables": { "background": "#ffffff" } },
  "dark":   { "themeVariables": { "background": "#171717" } }
}

shared is merged under each scheme, so light mode sees every shared variable plus its own eleven literals.

shared merges under each scheme one level deep, and the three consequences all matter:

  1. themeVariables merges key by key. A name set in both shared and light takes the light value in light mode. That is the mechanism the eleven literals rely on.
  2. Nothing deeper than one level merges. themeVariables.xyChart, .radar, .cynefin and .packet are objects inside themeVariables, so a scheme block setting one would replace the shared one wholesale rather than merging into it. Put nested objects in shared only.
  3. A sibling of themeVariables merges too. railroad, flowchart, sequence and look are top-level Mermaid config keys, so a per-scheme railroad block is legal and merges cleanly.

"theme": "base" is mandatory, and it sits beside themeVariables

Without it, Mermaid ignores the entire palette. It belongs next to themeVariables, not inside it:

{
  "shared": {
    "theme": "base",
    "themeVariables": { "primaryTextColor": "var(--fg)" }
  }
}

It is also load-bearing for the companion stylesheet. Mermaid’s old lavender literals — #ECECFF and #9370DB — still exist in 11.17.2, but only inside its default theme, which base never instantiates. They therefore cannot be emitted, and no CSS needs to hunt for them. Switching to "theme": "default" brings them all back and breaks a stylesheet written on the assumption that they cannot appear.

A scheme block takes any Mermaid config key

The whole scheme object is spread into mermaid.initialize(), so anything Mermaid’s config accepts is legal beside themeVariables.

The one that earns its own paragraph is railroad. It is the only way to theme railroad and ABNF diagrams: their color sanitizer rejects var() outright and falls back to stock black, gray and purple, and it does so silently. leed.ai carries a thirteen-key railroad block in each scheme:

{
  "light": {
    "railroad": {
      "terminalFill": "#fafafa",
      "terminalStroke": "#e5e5e5",
      "terminalTextColor": "#171717",
      "nonTerminalFill": "#ffffff",
      "nonTerminalStroke": "#e5e5e5",
      "commentFill": "#f5f5f5",
      "commentStroke": "#e5e5e5",
      "commentTextColor": "#525252",
      "specialFill": "#f5f5f5",
      "specialStroke": "#e5e5e5",
      "lineColor": "#525252",
      "markerFill": "#525252",
      "ruleNameColor": "#171717"
    }
  }
}

Nested objects inside themeVariables

packet, radar, cynefin and xyChart each take an object of their own, and Mermaid preserves any key you pass whether or not its base theme declares it. This is underused and high value — packet.labelColor is the difference between a packet diagram with black text and one that reads in dark mode.

{
  "packet": {
    "labelColor": "var(--fg)",
    "titleColor": "var(--fg)",
    "blockStrokeColor": "var(--border-strong)",
    "blockFillColor": "var(--bg-secondary)"
  }
}

When the palette is wrong

It never fails the build and never breaks the page. Diagram colors are not worth a red build, so every failure warns and carries on.

MessageWhereCauseFixBuild fails?
static/js/mermaid.theme.json is not valid JSON, so diagrams keep Mermaid's own palette: <error>Build logA trailing comma, a comment, a stray quoteJSON is strict — no comments, no trailing commasNo
static/js/mermaid.theme.json must be a JSON object, so diagrams keep Mermaid's own paletteBuild logThe file’s top level is an array or a scalarWrap it in { }No
static/js/mermaid.theme.json is empty, so diagrams keep Mermaid's own paletteBuild logThe file parses to {}Delete it or fill itNo
static/js/mermaid.theme.json: "shared", "light" and "dark" must each be a Mermaid config objectBuild logOne of the three keys holds a string, an array or nullEach must be an objectNo
Theming Mermaid diagrams from static/js/mermaid.theme.json (shared + light + dark)Build logSuccess — it names which keys it used——
Mermaid rejected this site's palette from static/js/mermaid.theme.json; rendering in Mermaid's own colors instead.Browser consoleA var() in a variable Mermaid derives fromFreeze that variable per schemeNo

The browser half is a probe. Before drawing anything, the page renders a two-node diagram with your palette applied. If that throws, the console carries the message above and everything re-initializes unthemed — deliberately, because reacting to the first real render failure cannot tell a bad palette from one badly written diagram, and guessing wrong strips the colors from every other diagram on the page.

The probe has a blind spot worth knowing. It renders a flowchart. A var() that only kills one diagram family — block diagrams are the usual one — sails straight through it. Each of those diagrams then fails alone, is logged by itself, and is left as raw source while the rest of the page stays correctly themed.

Reading the error

This is the diagnostic that saves the most time on this page. When one whole diagram renders as raw source, that type rejected one of your var() values, and the error names the first token it choked on, not all of them.

Bisect on the message. Convert the named variable to a literal, rebuild, read the next message, repeat. That finds the minimum set that has to become literals. The alternative — converting every variable that holds a token — works, and costs you live scheme switching on all of them. One measured case cost two variables where the first guess had been twenty-five.

How the palette reaches the page

The feature spans two runtimes and an event.

sequenceDiagram
  autonumber
  participant B as leed site build
  participant P as The page
  participant M as Mermaid
  B->>B: read src/static/js/mermaid.theme.json
  B->>P: emit window.leedMermaidTheme = {light, dark}
  Note over P: the module loads only if the page<br/>contains a pre.mermaid element
  P->>M: initialize({theme, ...scheme, startOnLoad: false})
  P->>M: probe render, two nodes
  M-->>P: ok
  P->>M: render each diagram from its cached source
  Note over P,M: later, the reader's system switches to dark
  P->>M: initialize() again with the dark scheme
  P->>M: redraw every diagram, no reload

Three details follow from that sequence. startOnLoad: false is applied last and cannot be overridden — the manager renders the diagrams itself, and a site turning it back on would draw every diagram twice. Your theme key is applied before it and both can and should be set. And because each diagram’s original source is kept, a prefers-color-scheme change re-themes and redraws without a reload.

That last point is why the eleven frozen literals are not a compromise: they are per-scheme, and the scheme is re-applied live.

The companion stylesheet, and how much weight a rule needs

The CSS half is a cascade problem, and teaching it as one is the only way to make it transferable.

Mermaid compiles its per-diagram stylesheet under a #<svgId> namespace and inserts it inside the <svg>. Every Mermaid rule therefore carries at least an id. Four paint sources, ranked:

RankSourceExampleWhat beats it
4An inline style with !importantC4 shapesNothing. No author stylesheet reaches it
3Mermaid’s own id-scoped <style>.face { … }Only your !important
2A plain inline style attribute.actor-manOnly your !important
1A rule in your stylesheet—Ranks 2, 3 and 4
0A presentation attributestroke="#666"Any declaration at all

Rank 3 is the trap. Your selector matches, your value is right, and nothing happens, because an id outranks anything an author stylesheet can say about that property on that element.

The working rule that follows: a plain declaration is correct only where Mermaid’s stylesheet says nothing about that property on that element. And the discipline that follows from that: every !important in the file is there because it was measured to be necessary, and every rule without one was measured to win without it. Do not add or remove one on intuition — add the sentinel, render, and read the computed value.

Import this file unlayered

/* tailwind/site.config.css */
@import "./docs/mermaid.css";        /* no layer(...) — deliberately */

Mermaid’s <style> lives inside the SVG and is unlayered author CSS, and unlayered CSS beats every cascade layer on normal declarations regardless of specificity. Putting your rules in layer(components) would place them a step behind Mermaid’s own the moment one of them names a selector you also use — which at 11.17.2 is the common case rather than the rare one.

This is the exact opposite of the advice for an alert override, which must be layered, and the single rule that explains both is on cascade layers and overriding Leed. Alerts are Leed’s own components in layer(components); Mermaid’s stylesheet is not layered at all, and you match the thing you are trying to beat.

One order note: the SVG’s <style> is inserted at render time, after your stylesheet is parsed, so on a specificity tie Mermaid wins. There is no tie today, because every Mermaid rule carries an id — but do not write a rule that depends on source order.

Scope every rule to pre.mermaid svg

One selector shape, two reasons. It keeps diagram rules off the rest of the page, and it is what makes them survive click-to-zoom: the zoom stage clones the diagram’s <pre> as a carrier, so a rule scoped this way matches in both places and no second selector is needed.

Coverage, type by type

Every diagram type themes. The three exceptions below are about which file reaches a color, not about whether color is possible at all.

Measured at Mermaid 11.17.2 in a real browser: every type below rendered on one page, and the painted fill and stroke of every element in every SVG was read back and compared against the site’s tokens. “Themed by the palette” means the JSON alone was enough. “Needs a CSS rule” means it was not, and tailwind/docs/mermaid.css carries the rule that finishes it.

With both halves in place every type below is brand. The split matters only if you are porting this to another site, or debugging one: the palette cannot reach the second group, and no amount of adding variables to the JSON will change that. Mermaid derives colors from colors — handed primaryColor it computes borders and secondaries by lightening and darkening the string it was given — and that arithmetic cannot consume a var(). The CSS can, because the browser resolves it after Mermaid has finished.

TypeThemed by the paletteNeeds a CSS ruleNotes
flowchart / graphYesNoedgeLabelBackground must be a per-scheme literal
classDiagramYesNoNode fill routes through mainBkg
ganttYesNoCritical and done tasks read your error and success tokens
gitGraphYesNoBranch colors come from the categorical ramp
mindmapYesNoNo non-token color found in the render
block-betaYesNoThrows unless clusterBkg/clusterBorder are literals
packet-betaYesNoSet the nested packet object or the labels are black
kanbanYesNoLooks unthemed and is not — see below
swimlane-betaYesNoNo non-token color found in the render
ishikawa-betaYesNoNo non-token color found in the render
sequenceDiagramMostlyYesg.actor-man carries an inline lavender; the autonumber badge takes the arrowhead’s ink
stateDiagram / -v2MostlyYesNode fill routes through stateBkg, but the [*] markers stay #ececff / #9370db
erDiagramMostlyYesAttribute row bands are Mermaid’s lavender pair, not attributeBackgroundColor*
requirementDiagramMostlyYesRequirement boxes stay #ececff throughout
journeyMostlyYesFace stroke, mouth, section and task bar strokes, actor rings
timelineMostlyYesShares the journey actor-ring rule
architecture-betaMostlyYesThe icon plate is a hardcoded blue inline style
pieNoYesAll twelve slices and the legend keep Mermaid’s ramp
quadrantChartNoYesThe four grounds ignore quadrant1Fill–quadrant4Fill
xychart-betaNoYesIts config carries no color keys at all in 11.17.2
radar-betaNoYesCurves, legend boxes and graticule all paint from constants
treemap-betaNoYesLeaves and the section frame paint from constants
venn-betaNoYesSet fills and labels are inline-styled
cynefin-betaNoYesFive pastel domains, matched on the literal
C4ContextConnectors onlyPartlyShapes are unreachable — see below
sankey-betaNoLeft alonePaints from d3’s Tableau10 — deliberately kept; see below
railroad / ABNFVia the railroad config block onlyNoIts sanitizer rejects every var()

Three exceptions, documented so nobody re-debugs them:

  • C4Context — its shapes and their labels carry inline fill:… !important, the top of the cascade. Neither file reaches them, and its c4 config block is accepted and then ignored. Style C4 from the diagram source with UpdateElementStyle. Its relationship connectors and the relationship label are plain-reachable and leed.ai’s stylesheet does reach them, so do not conclude from a partly-brand C4 diagram that the problem is fixed.
  • sankey-beta — the one type deliberately left on Mermaid’s own colors. Its d3 Tableau10 scheme is a ten-hue qualitative scale, and a sankey is read by following a flow through nodes that have to stay told apart; a warm brand ramp separates well at three series and progressively worse as a real diagram grows. Color is carrying information here in a way it is not in the types above. tailwind/docs/mermaid.css records the full reasoning, including the selector trap that makes a naive fix repaint unrelated flowcharts.
  • kanban — looks unthemed and is not. Its column tints are Mermaid’s own color maths run over your accent, so they are brand colors that are not literal tokens. An audit that diffs rendered colors against your token list will keep flagging it. Leave it.

Where the rules have to live, and why both files exist

The lavender #ececff and its #9370db partner are real and still present at 11.17.2, in stateDiagram-v2’s [*] markers, erDiagram’s attribute row bands and requirementDiagram’s boxes. What is not true — and was asserted here until it was measured — is that they are unreachable, or that a diagram has to be rewritten as a flowchart to be themeable. They are matched on the literal in tailwind/docs/mermaid.css and they go brand.

Two rules of thumb when you add to either file:

  • If Mermaid passes your value straight into the SVG, it belongs in the palette JSON, where light and dark stay one definition.
  • If Mermaid computes with it, or writes the color into its own #mermaid-N block or an inline style, it belongs in the CSS — with !important only in that second case, which no plain declaration outranks.

Selectors in that file name two containers, pre.mermaid and .zoomable-fullscreen-svg. Click-to-zoom clones the live SVG into the second one, outside pre.mermaid, so a rule scoped only to the first is absent from the one view large enough to study the diagram.

Every type, rendered

The table above claims every type comes out brand once both files are in place. This is the proof, and it is deliberately not a screenshot: these render in your scheme, on the page making the claim. It is also the page to open after a Mermaid upgrade or a palette change — one screen, every type, both schemes.

Each example is minimal on purpose and is copy-pasteable into a Leed page as-is.

Flowchart

flowchart LR
  A([Edit]) --> B{Validate}
  B -->|ok| C[Commit]
  B -->|fails| D[Fix]
  subgraph repo [Your repository]
    C --> E[(Push)]
  end

Sequence

sequenceDiagram
  autonumber
  actor A as Author
  participant L as Leed
  A->>L: publish page
  activate L
  L-->>A: deployment queued
  deactivate L
  Note over L: the build runs

Class

classDiagram
  class Page {
    +String pageId
    +publish()
  }
  class Revision {
    +String path
  }
  Page "1" --> "*" Revision : has

State

stateDiagram-v2
  [*] --> Draft
  Draft --> Published: publish
  Published --> Draft: edit
  Published --> [*]

Entity relationship

erDiagram
  PAGE ||--o{ REVISION : has
  PAGE {
    string pageId
    string slug
  }

Requirement

requirementDiagram
  requirement contrast {
    id: 1
    text: "chart steps clear 3:1"
    risk: medium
    verifymethod: test
  }
  element ramp {
    type: palette
  }
  ramp - satisfies -> contrast

User journey

journey
  title Theming a diagram
  section Palette
    Write the JSON: 5: Developer
    Freeze the literals: 3: Developer
  section Proof
    Render both schemes: 5: Developer

Gantt

gantt
  title A styling pass
  dateFormat YYYY-MM-DD
  section Work
    Palette   :a1, 2026-09-01, 4d
    Stylesheet:after a1, 6d

Pie

pie title Where the build time goes
  "Minify" : 44
  "Render" : 31
  "Tailwind" : 25

Quadrant

quadrantChart
  title Effort against value
  x-axis Low effort --> High effort
  y-axis Low value --> High value
  quadrant-1 Do now
  quadrant-2 Plan
  quadrant-3 Drop
  quadrant-4 Maybe
  Palette: [0.3, 0.8]
  Stylesheet: [0.7, 0.4]

Git graph

gitGraph
  commit
  branch theming
  commit
  checkout main
  merge theming
  commit

Mindmap

mindmap
  root((Styling))
    Palette
      Tokens
      Literals
    Stylesheet
      Cascade

Timeline

timeline
  title A theming pass
  Day 1 : Tokens
  Day 2 : Palette : Stylesheet
  Day 3 : Review

Sankey

sankey-beta
Pages,Built,80
Pages,Skipped,20
Built,Deployed,80

XY chart

xychart-beta
  title "Builds per day"
  x-axis [mon, tue, wed, thu, fri]
  y-axis "Builds" 0 --> 100
  bar [30, 45, 60, 50, 80]
  line [30, 45, 60, 50, 80]

Block

block-beta
  columns 3
  Browser space Worker
  space:3
  D1:3

Packet

packet-beta
  0-15: "Page id"
  16-31: "Revision"
  32-63: "Body digest"

Architecture

architecture-beta
  group site(cloud)[Your site]
  service worker(server)[Worker] in site
  service db(database)[D1] in site
  worker:R --> L:db

Kanban

kanban
  Todo
    [Write the palette]
  Doing
    [Theme the diagrams]
  Done
    [Pick the tokens]

C4 context

UpdateElementStyle is not decoration here — it is the only thing that colors a C4 diagram, for the reason given above.

C4Context
  title Publishing
  Person(author, "Author")
  System(cms, "Leed CMS")
  Rel(author, cms, "publishes")
  UpdateElementStyle(author, $bgColor="#e60076", $borderColor="#c6005c", $fontColor="#ffffff")
  UpdateElementStyle(cms, $bgColor="#8a8688", $borderColor="#686466", $fontColor="#ffffff")

Swimlane

swimlane-beta
  Author
  CMS
  Build

Radar

Four curves on purpose: one series would not show whether the ramp is being read at all.

radar-beta
  title Theming coverage
  axis pal["Palette"], css["Stylesheet"], dark["Dark"], zoom["Zoom"], docs["Docs"]
  curve now["Today"]{4, 3, 5, 2, 3}
  curve next["Next"]{2, 5, 3, 4, 1}
  curve gaps["Gaps"]{1, 2, 2, 5, 4}
  curve later["Later"]{5, 1, 4, 3, 2}
  max 5

Treemap

treemap-beta
"Your repository"
  "Templates": 40
  "Stylesheets": 35
  "Static": 25

Tree view

treeView-beta
    raw-content/
        src/
            docs/
                start-here/
                    what-is-leed.md :::highlight ## the set's starting page
            _includes/
                docs-header.hbs ## eject to customise it
        tailwind/
            docs/

Venn

venn-beta
  title Where a colour can live
  set palette
  set stylesheet

Ishikawa

ishikawa-beta
  title Diagram renders unstyled
  category Palette
    cause theme base not set

Cynefin

All four domains, so the five regions — four plus the confusion wedge at the center — are on screen at once.

cynefin-beta
  title Choosing a fix
  clear "Set a theme variable"
  complicated "Write a scoped rule"
  complex "Re-derive the ramp"
  chaotic "Every diagram is raw source"

What genuinely still needs a rule

Kept separate from the stale list above so the two do not blur, and framed as three reusable classes of fix rather than as a list of one site’s rules. When you meet a color the palette does not reach, work out which class it is and the weight follows.

Class 1 — a presentation attribute. A plain declaration wins. The journey and timeline actor rings are stroked #000 as an attribute, with no theme variable behind them:

pre.mermaid svg circle[class^="actor-"] {
  stroke: var(--fg-muted);
}

Verdict: measured, a plain declaration wins. Note the attribute-shaped selector used for the section and task bars — rect.task[stroke="#666"] targets exactly the literal being replaced, so if a future release themes it the rule stops matching instead of silently overriding a value the palette had started supplying correctly.

Class 2 — an inline style. !important is required. The architecture-beta icon pack wraps every glyph in a hardcoded blue plate written as an inline style:

pre.mermaid svg[aria-roledescription="architecture"] rect[style*="#087ebf"] {
  fill: var(--primary) !important;
}

Verdict: measured, a plain declaration loses. The attribute selector matches the raw style string, so #087ebf works even though the parsed value reads back as rgb(8, 126, 191).

Class 3 — a color named inside Mermaid’s own id-scoped block. !important is required and nothing else will do. The journey face ring and mouth sit beside a themeable fill in the same Mermaid rule:

pre.mermaid svg circle.face { stroke: var(--fg-muted) !important; }
pre.mermaid svg .mouth     { stroke: var(--fg-muted) !important; }

Verdict: measured, a plain declaration loses. The face fill is not here at all, because faceColor in the palette moves it — the same diagram, one color, two different mechanisms sitting on adjacent lines.

Run the same attribution check on your own diagram rather than copying these rules: inspect the element, find where the value is actually written, and pick the weight from the ranked table.

Two traps documented but deliberately not fixed

A rule you can see and a comment explaining why one is absent are both cheaper than a rediscovery.

The sequence-diagram box frame. Mermaid still hardcodes stroke="rgb(0,0,0, 0.5)" as a presentation attribute on the frame that box … end draws. The obvious-looking rule for it is inert, because 11.17.2 also emits an id-scoped rect.rect { stroke: nodeBorder }, which outranks both the attribute and anything a stylesheet can write. leed.ai used to ship that rule; it was measured to change nothing, and had it won it would have been a downgrade — replacing the brand accent with a flat border color. It was removed with a comment recording why. This is the sharpest illustration of the ranked cascade on this page.

The composite-state alternating band. A real literal — #e0e0e0 — inside Mermaid’s own block, which no theme variable reaches, whose fix needs !important. It is left unwritten because no diagram in this documentation set uses composite states. Document what ships, not what might.

Clicking a diagram

Every diagram is click-to-zoom, and the viewer is why a diagram may carry more detail than fits the reading column. Because an SVG cannot go through an <img> — a data: URI would put it in a separate document where the page’s custom properties no longer resolve — the node is cloned into the page, and max-width: none !important defeats the inline width Mermaid sized it to for its column.

Two consequences, both requirements rather than options.

var() survives the clone; selectors do not. The viewer clones the diagram’s <pre> as a carrier, id stripped and class kept, so a rule scoped to pre.mermaid svg matches in both places and no second selector is needed. If a zoomed diagram loses its colors while the page copy keeps them, the installed site-management predates that carrier — check with grep -c "cloneNode(false)" dist/static/javascript/zoomable.js, where a 0 means it does.

The stage paints var(--component-bg-color, #ffffff) over a 90%-black backdrop. Without that token a light diagram lands dark-on-dark and a dark one lands on hard white — a failure that appears only after a click, and only in one scheme. The resolution chain is:

--component-bg-color: var(--docs-bg-color, var(--site-bg-color, var(--default-bg-color)));

Both of the first two legs are load-bearing. --docs-bg-color carries documentation pages; --site-bg-color carries a diagram in a blog or resource page, where no docs theme class is present. The last rung is slate-950, a blue-black that will not match a neutral page. Set one of the first two in both schemes, and verify by zooming a diagram in dark mode. Both tokens are cataloged on the site token contract, and the reader-facing behavior of the viewer is on the documentation reading experience.

Keeping a palette honest

The JSON cannot carry comments — it is parsed with a strict JSON parser, so a // line is a build warning and an unthemed site. Put the derivation table for every frozen literal in the companion stylesheet, which does take comments, and treat the two files as one change.

Nothing warns you when a token moves. Every literal in the palette is a hand-copied resolution of a CSS custom property, and there is no mechanism by which the JSON could re-read it. Write the token name beside every hex.

Open this page in both schemes after every Mermaid upgrade. An upgrade both fixes and breaks. 11.17.2 routed class and state node fill through the palette — a genuine fix — and in the same release started theming the sequence box frame, which silently made a shipped rule inert. Mermaid is pinned by the site builder rather than by your repository, so an upgrade arrives with a builder release rather than with a change of yours.

Adopting your own scheme

The checklist that makes all of this transferable, in order:

  1. Pick the site-level tokens the palette will name, and check each one is declared on bare :root in both schemes.
  2. Copy the eleven literals and freeze them per scheme, resolving each from the token it stands for.
  3. Keep fontFamily as a literal stack, and keep noteBorderColor and sequenceNumberColor set.
  4. Derive a categorical ramp — its own problem, with its own arithmetic, in diagram color scales.
  5. Set the nested objects for the types you actually use.
  6. Run the sentinel test on anything still wrong, and put the fix in whichever file the test names.
  7. Open this page in both schemes.

Writing the diagram itself — the fence, the supported types and how it renders — is diagrams. A diagram follows the reader’s system setting for the same reason everything else does, and by the same mechanism, described on dark mode. The palette sits beside the extra highlight.js languages in the same directory and for the same reason, which is cataloged with every other injection point in adding your own CSS and JS. And the error and success colors your palette hands to gantt come from the same semantic tokens the alert registers use, on theming alerts.

ESC