Skip to content

feat(ui): tone the Mermaid node shadow to 70 and derive its colour from the palette - #1563

Merged
backnotprop merged 6 commits into
mainfrom
feat/mermaid-shadow-default
Sep 18, 2026
Merged

backnotprop merged 6 commits into
mainfrom
feat/mermaid-shadow-default

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

The shipped look stays exactly as it is — Mermaid 12's neo look, ELK, our theme-aware palette mapping. Only the node drop shadow changes: it is toned down to 70 and its colour now comes from the palette instead of Mermaid's fixed grey.

Mermaid paints that shadow from drop-shadow(1px 2px 2px rgba(185,185,185,1)), a constant that follows no palette and reads as a grey halo around every node on a themed page.

What changed

buildMermaidThemeVariables(tokens, mode, options?) takes options.shadowAmount (0..1, default DEFAULT_MERMAID_SHADOW_AMOUNT = 0.7) and publishes themeVariables.dropShadow from the new pure buildMermaidShadow(ground, amount):

  • Geometry scales over a small floor — x = 0.3 + 0.7a, y = blur = 0.6 + 1.4a — so 1 reproduces Mermaid's 1px 2px 2px exactly and 0 publishes dropShadow: false (plus nodeShadow: false, the only thing that reaches the inline filter:url(#…-drop-shadow-small) on a state diagram's start/end dots), which the neo rules render as filter: none.
  • Colour comes from the page's own ground, and its polarity follows the page exactly as Mermaid's own insertLookDefs does (floodColor = theme.includes('dark') ? '#FFFFFF' : '#000000'): dark page → the ground lifted 82% toward white at alpha 0.18 + 0.72a; light page → darkened 40% toward black at 0.25 + 0.3a; each asserted to differ from the ground in the direction that reads, falling back to white/black, and to Mermaid's grey when the palette yields no usable ground. Polarity matters more than alpha here: a black shadow on a near-black ground is a valid filter that paints nothing.
  • The amount is a setting: cookie-only diagramShadow, Settings → Display → "Diagram Shadow" (None / 40 / 70 / 100), next to Grid Background. It rides into the renderer as DiagramTheme.shadowAmount and into the Mermaid cache key, so changing it re-initializes and re-renders every mounted diagram. At the default the key is byte-identical to the old (palette, mode) key.

Nothing in Mermaid itself is touched: this is themeVariables.dropShadow (plus nodeShadow at 0), our colour derivation, and the Display setting. No version bumps — the @plannotator/ui / @plannotator/core bump is left for merge time, depending on the order with #1562.

Before / after

Headless Chromium on 01-service-architecture.md, 1440×1000 @2x, palette pinned through the data dir and documentElement.className verified before measuring. Per diagram: % of pixels whose max channel delta exceeds 3, and the mean delta over those pixels.

comparison diagram 1 diagram 2 diagram 3
main vs branch (plannotator dark) 3.169% · 22.8 2.207% · 21.8 6.652% · 29.0
main vs branch (one-light) 2.094% · 8.7 1.298% · 8.2 6.444% · 9.0
amount 0 vs 70 (dark) 1.978% · 23.2 2.322% · 22.1 5.896% · 26.9
amount 0 vs 70 (light) 1.684% · 10.9 1.733% · 11.5 5.420% · 13.9
amount 0 vs 100 (dark) 3.862% · 30.6 2.434% · 36.6 8.769% · 40.0
70 vs 100 (dark, same colour) 3.253% · 22.5 2.252% · 21.5 6.712% · 28.9
main vs branch at 100 (dark) 0.097% · 4.1 0.234% · 4.5 0.655% · 4.2

The last row is the calibration check: at amount 100 the palette-derived colour lands within ~4/255 of Mermaid's own grey on this palette, so everything the other rows measure is the toning down, not a colour shift. Computed filters off a live node: main drop-shadow(rgb(185,185,185) 1px 2px 2px); branch drop-shadow(rgba(203,205,207,0.682) 0.79px 1.58px 1.58px) (plannotator dark) and rgba(125,125,125,0.46) (one-light); amount 0 → none.

Screenshots (main vs branch, both modes, plus 0 / 70 / 100) with a local index: scratchpad/mermaid-shadow-pr/index.html.

Tests

  • packages/ui/utils/mermaidTheme.test.ts: a default shadow present for every shipped palette in both modes, in the readable direction (lighter than the ground on a dark page, darker on a light one), never Mermaid's grey; absent at 0; geometry pinned at 0.7 and 1; the cache key carries the amount and the default key is the old one; and a fill/label fingerprint — every variable but the two shadow keys identical at amount 0 and 70, per palette, per mode.
  • packages/ui/config/diagramShadowSetting.test.ts: the registry contract, including the trap that 0 is a valid stored amount while Number(null) is 0, so an absent cookie must be rejected before parsing (without it, "never chosen" reads as "no shadow" for everyone).
  • packages/ui/components/MermaidBlock.theme.test.tsx: the setting actually reaches the runtime and re-renders mounted diagrams.
  • bun run typecheck; bun test packages/ui/utils packages/ui/config packages/core (1036 pass); DOM_TESTS=1 bun test --isolate packages/ui/components/MermaidBlock* packages/ui/components/diagram packages/ui/components/Settings* (33 pass); the DOM allowlist test; bun run --cwd packages/ui smoke:package; build:viewer + check:manifest (repinned — the viewer bundles the mapping, so its JS hash moved; the stylesheet did not, and main was verified in sync first).

The diagram engine is unaffected: the shadow is a paint-time filter on nodes, clusters and actors. The edge hit layer sits outside .node, the overlay rings and badges are DOM outside the SVG, and sanitizeDiagramSvg's scoped-style rule keeps the [data-look="neo"] rules (they are under #renderId, and a literal drop-shadow(…) has no url( for cssFetches to refuse).

For hosts

@plannotator/ui consumers get the toned-down default with no change. To keep Mermaid's grey, pass your own themeVariables.dropShadow after ours; to ship none, pass { shadowAmount: 0 }. The static MERMAID_CONFIG a token-less host renders with carries the same 70 geometry with one fixed light colour (its own slate palette is dark) — that is the one byte that changed in the fallback config. Documented in packages/ui/README.md and HANDOFF.md.

…Mermaid's own

Mermaid 12's neo look shadows every node, cluster and actor from its own fixed
`drop-shadow(1px 2px 2px rgba(185,185,185,1))`. On a themed page that grey reads
as a halo around every node and follows no palette. Keep the look, replace the
one filter.

`buildMermaidThemeVariables` takes `options.shadowAmount` (0..1, default 0.7)
and publishes `themeVariables.dropShadow` from the new pure
`buildMermaidShadow(ground, amount)`. Geometry scales over a small floor, so 1
reproduces Mermaid's `1px 2px 2px` exactly and 0 publishes `dropShadow: false`
(plus `nodeShadow: false`, the only thing that reaches the inline filter on a
state diagram's small start/end dots), which the neo rules render as
`filter: none`.

The colour comes from the page's own ground and its POLARITY follows the page,
the same rule Mermaid's `insertLookDefs` uses: dark page -> the ground lifted
82% toward white at alpha 0.18..0.90; light page -> darkened 40% toward black
at 0.25..0.55; each asserted to differ from the ground in the direction that
reads. Polarity matters more than alpha here: a black shadow on a near-black
ground is a valid filter that paints nothing.

`mermaidThemeKey` takes the amount and appends `#s<amount>` only when it is not
the default, so the key for the shipped default is byte-identical to the
`(palette, mode)` key it has always been. The static `MERMAID_CONFIG` a
token-less host renders with gets the same 70 geometry with one fixed light
colour, since its own slate palette is dark.
The amount reaches the renderer the way the palette does: `DiagramBlock` reads
the cookie-only `diagramShadow` setting through `useConfigValue`, puts it on
`DiagramTheme.shadowAmount`, and the renderer folds it into the theme key — so
changing it re-initializes Mermaid and re-renders every mounted diagram, with
no new context and no server write.

The scale lives in `utils/diagramShadow` and is deliberately import-free: the
settings registry is read on every surface (the guides.show viewer included)
and must not pull the diagram theme mapping into that module graph.

One trap worth the comment it carries: 0 is a VALID stored amount here, and
`Number(null)` is 0, so `fromCookie` rejects an absent cookie BEFORE parsing.
Without that, "never chosen" reads as "no shadow" and every diagram ships flat.
…scape hatches

AGENTS.md gets the rule (colour from the ground, polarity from the page, 70 by
default, the setting); README and HANDOFF get what a host has to know: the new
`options.shadowAmount`, that a host wanting Mermaid's grey passes its own
`themeVariables.dropShadow` after ours, that `{ shadowAmount: 0 }` is how to
ship none, and that the cache key is unchanged at the default.
…d 70

The shadow is a paint-time filter. Every variable but the two shadow keys must
be identical with it off — swept over every shipped palette in both modes, plus
the light/dark base pair explicitly.
The viewer bundles the diagram theme mapping, so its JS hash moved; the
stylesheet did not. Regenerated with build:viewer && sync:manifest.
Palette-derived Mermaid node shadow at a default of 70% plus the
Settings → Display "Diagram Shadow" control. Core stays pinned at
0.25.4 (unchanged from 0.41.1).
@backnotprop
backnotprop force-pushed the feat/mermaid-shadow-default branch from fd826c7 to 3dfbf7a Compare September 18, 2026 01:53
@backnotprop
backnotprop merged commit 7846706 into main Sep 18, 2026
28 checks passed
@backnotprop
backnotprop deleted the feat/mermaid-shadow-default branch September 18, 2026 02:04
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