From dde33e73a8773bf0fbf2e7c6b7177f729519d792 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 17 Sep 2026 16:33:04 -0700 Subject: [PATCH 1/6] feat(ui): derive the Mermaid node shadow from the palette, at 70% of 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` 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. --- packages/ui/utils/mermaid.ts | 12 ++ packages/ui/utils/mermaidTheme.test.ts | 156 +++++++++++++++++++++---- packages/ui/utils/mermaidTheme.ts | 145 +++++++++++++++++++++-- 3 files changed, 284 insertions(+), 29 deletions(-) diff --git a/packages/ui/utils/mermaid.ts b/packages/ui/utils/mermaid.ts index fd371dc7b..4e7f85ce3 100644 --- a/packages/ui/utils/mermaid.ts +++ b/packages/ui/utils/mermaid.ts @@ -45,6 +45,18 @@ export const MERMAID_CONFIG: MermaidConfig = { clusterBorder: '#475569', titleColor: '#f8fafc', edgeLabelBackground: '#1e293b', + /** + * Mermaid 12's neo look shadows every node from a fixed + * `drop-shadow(1px 2px 2px rgba(185,185,185,1))` grey, which reads as a + * halo. The token-driven mapping replaces it per palette + * (`buildMermaidShadow` in `./mermaidTheme`); this static config is what a + * host with no theme tokens renders with, so it carries the same + * toned-down 0.7 geometry with a fixed colour. The value is what + * `buildMermaidShadow(#1e293b, 0.7)` returns for THIS config's own slate + * ground, which is dark — hence a light shadow, exactly as Mermaid's own + * `insertLookDefs` uses a white flood colour on dark themes. + */ + dropShadow: 'drop-shadow(0.79px 1.58px 1.58px rgba(210, 212, 217, 0.684))', }, flowchart: { htmlLabels: true, diff --git a/packages/ui/utils/mermaidTheme.test.ts b/packages/ui/utils/mermaidTheme.test.ts index bf7474b9d..45adbf5b0 100644 --- a/packages/ui/utils/mermaidTheme.test.ts +++ b/packages/ui/utils/mermaidTheme.test.ts @@ -19,22 +19,49 @@ 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 { compositeOver, contrastRatio, parseCssColor, relativeLuminance, toHex, type RgbColor } from './cssColor'; +import { DEFAULT_DIAGRAM_SHADOW, DIAGRAM_SHADOW_OPTIONS } from './diagramShadow'; import { + DEFAULT_MERMAID_SHADOW_AMOUNT, MERMAID_LINE_CONTRAST_MIN, MERMAID_TEXT_CONTRAST_MIN, __resetMermaidThemeForTests, applyMermaidTheme, buildMermaidConfig, + buildMermaidShadow, buildMermaidThemeVariables, ensureContrast, + isDarkBackground, mermaidThemeKey, + shadowAmountFromKey, type MermaidThemeMode, type MermaidThemeTokens, } from './mermaidTheme'; const HEX = /^#[0-9a-f]{6}$/; +/** + * Every shipped palette, both modes. Reads the tokens straight out of the CSS + * so a palette added later is swept automatically (by the contrast guard AND + * by the shadow sweep below). + */ +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; +} + /** The Plannotator base palette, as `themes/plannotator.css` writes it. */ const PLANNOTATOR_DARK: MermaidThemeTokens = { background: 'oklch(0.15 0.02 260)', @@ -258,27 +285,6 @@ describe('contrast guard', () => { 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'], @@ -385,3 +391,109 @@ describe('applyMermaidTheme', () => { } }); }); + +/** + * Node drop shadow (the `dropShadow` theme variable). + * + * What regresses if these fail: + * - the shipped look changes: Mermaid 12's neo shadow comes back at full + * strength (the grey halo), or disappears entirely; + * - the shadow is derived in the wrong polarity, which on a dark page paints a + * black shadow on a near-black ground — a valid filter that renders nothing, + * the exact bug the lab found; + * - a shadow amount stops reaching the runtime because the cache key ignores + * it, so the setting silently does nothing until the palette changes; + * - the shadow leaks into a fill or a label colour (it is a paint-time filter + * and must touch nothing else). + */ +describe('node shadow', () => { + /** The `rgba(r, g, b, a)` inside a `drop-shadow(x y blur rgba(...))`. */ + function shadowColor(filter: string): { color: RgbColor; alpha: number } { + const m = /rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/u.exec(filter); + if (!m) throw new Error(`no rgba in ${filter}`); + return { + color: { r: Number(m[1]) / 255, g: Number(m[2]) / 255, b: Number(m[3]) / 255, a: 1 }, + alpha: Number(m[4]), + }; + } + + test('the setting scale and the mapping amount are the same number', () => { + expect(DEFAULT_DIAGRAM_SHADOW / 100).toBe(DEFAULT_MERMAID_SHADOW_AMOUNT); + expect(DIAGRAM_SHADOW_OPTIONS).toContain(DEFAULT_DIAGRAM_SHADOW); + }); + + test('amount 1 reproduces Mermaid 12 own geometry, 0.7 is the shipped toning', () => { + const ground = parseCssColor('#111318')!; + expect(buildMermaidShadow(ground, 1)).toStartWith('drop-shadow(1px 2px 2px '); + expect(buildMermaidShadow(ground, 0.7)).toStartWith('drop-shadow(0.79px 1.58px 1.58px '); + expect(buildMermaidShadow(ground, 0)).toBe(false); + // Out of range is clamped, not trusted. + expect(buildMermaidShadow(ground, 4)).toBe(buildMermaidShadow(ground, 1)); + expect(buildMermaidShadow(ground, -1)).toBe(false); + }); + + test('a ground the palette could not produce falls back to Mermaid own colour', () => { + expect(buildMermaidShadow(undefined, 0.7)).toContain('rgba(185, 185, 185, 1)'); + }); + + test('amount 0 publishes no shadow at all, including the state dots', () => { + const spec = buildMermaidThemeVariables(PLANNOTATOR_DARK, 'dark', { shadowAmount: 0 })!; + expect(spec.themeVariables.dropShadow).toBe(false); + expect(spec.themeVariables.nodeShadow).toBe(false); + expect(spec.shadowAmount).toBe(0); + }); + + test('the shadow touches nothing but the shadow', () => { + const off = buildMermaidThemeVariables(PLANNOTATOR_DARK, 'dark', { shadowAmount: 0 })!.themeVariables; + const on = buildMermaidThemeVariables(PLANNOTATOR_DARK, 'dark')!.themeVariables; + const fingerprint = (vars: Record) => + Object.entries(vars) + .filter(([k]) => k !== 'dropShadow' && k !== 'nodeShadow') + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .sort() + .join('\n'); + expect(fingerprint(on)).toBe(fingerprint(off)); + }); + + test('the cache key carries the amount, and the default key is the old (palette, mode) key', () => { + expect(mermaidThemeKey('plannotator', 'dark')).toBe('dark:plannotator'); + expect(mermaidThemeKey('plannotator', 'dark', DEFAULT_MERMAID_SHADOW_AMOUNT)).toBe('dark:plannotator'); + expect(mermaidThemeKey('plannotator', 'dark', 0)).not.toBe('dark:plannotator'); + expect(mermaidThemeKey('plannotator', 'dark', 0)).not.toBe(mermaidThemeKey('plannotator', 'dark', 0.4)); + expect(shadowAmountFromKey(mermaidThemeKey('plannotator', 'dark', 0.4))).toBe(0.4); + expect(shadowAmountFromKey(mermaidThemeKey('plannotator', 'dark'))).toBe(DEFAULT_MERMAID_SHADOW_AMOUNT); + // A palette id is never mistaken for an amount. + expect(shadowAmountFromKey('light:one-light')).toBe(DEFAULT_MERMAID_SHADOW_AMOUNT); + }); + + /** Every shipped palette, both modes: a shadow at the default, in the direction that reads. */ + 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}: a default shadow that differs from the ground in the readable direction`, () => { + expect(tokens, `${selector} block in ${file}`).toBeDefined(); + const spec = buildMermaidThemeVariables(tokens, mode)!; + expect(spec.shadowAmount).toBe(DEFAULT_MERMAID_SHADOW_AMOUNT); + const filter = spec.themeVariables.dropShadow; + expect(typeof filter).toBe('string'); + expect(filter as string).toStartWith('drop-shadow(0.79px 1.58px 1.58px '); + const ground = compositeOver(parseCssColor(tokens!.background!)!, { r: 1, g: 1, b: 1, a: 1 }); + const { color, alpha } = shadowColor(filter as string); + const groundL = relativeLuminance(ground); + if (isDarkBackground(ground)) { + // A shadow on a dark page LIFTS: darker than the ground is invisible. + expect(relativeLuminance(color)).toBeGreaterThan(groundL); + expect(alpha).toBeCloseTo(0.684, 3); + } else { + expect(relativeLuminance(color)).toBeLessThan(groundL); + expect(alpha).toBeCloseTo(0.46, 3); + } + // Never Mermaid's fixed grey: the tint comes from the palette. + expect(filter as string).not.toContain('185, 185, 185'); + }); + } + } +}); diff --git a/packages/ui/utils/mermaidTheme.ts b/packages/ui/utils/mermaidTheme.ts index c158a0931..556cd4151 100644 --- a/packages/ui/utils/mermaidTheme.ts +++ b/packages/ui/utils/mermaidTheme.ts @@ -24,7 +24,18 @@ * 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. + * runs only when the `(palette, mode, shadow amount)` key changed since the + * last apply. + * + * Node shadow: Mermaid 12's neo look paints a drop shadow on every node, + * cluster and actor, from its own fixed `rgba(185,185,185,1)` grey at + * `1px 2px 2px`. The mapping keeps the look and replaces that one filter: + * `themeVariables.dropShadow` is built from the palette's own ground at + * `DEFAULT_MERMAID_SHADOW_AMOUNT` (0.7 of Mermaid's geometry), in the polarity + * the page reads in — see `buildMermaidShadow`. A host that wants Mermaid's + * grey back sets its own `themeVariables.dropShadow` after ours; a host that + * wants none passes `{ shadowAmount: 0 }`, which publishes `dropShadow: false` + * and the neo rules render `filter: none`. * * Fallback contract (hosts): when no tokens resolve, the runtime keeps the * static `MERMAID_CONFIG` it was initialized with, and nothing is @@ -106,6 +117,35 @@ export type MermaidThemeTokens = Partial>; export interface MermaidThemeSpec { theme: 'dark' | 'default'; themeVariables: Record; + /** The node shadow amount baked into `themeVariables.dropShadow` (0..1). */ + shadowAmount: number; +} + +/** Options `buildMermaidThemeVariables` accepts beyond the tokens and mode. */ +export interface MermaidThemeOptions { + /** + * How much node drop shadow to paint, 0..1, where 1 reproduces Mermaid's + * own default geometry (`1px 2px 2px`) and 0 means no shadow at all. + * Default: `DEFAULT_MERMAID_SHADOW_AMOUNT`. + */ + readonly shadowAmount?: number; +} + +/** + * The shipped shadow amount: Mermaid 12's neo look at 70% of its own shadow. + * The full-strength default reads as a grey halo around every node on a + * Plannotator page; 70 keeps the lift and drops the halo. + */ +export const DEFAULT_MERMAID_SHADOW_AMOUNT = 0.7; + +/** Mermaid 12's own node shadow, the colour included. Amount 1 reproduces + * this geometry; the colour is ours unless the palette yields nothing. */ +export const MERMAID_DEFAULT_SHADOW_COLOR = 'rgba(185, 185, 185, 1)'; + +/** Clamp an amount to 0..1 and round it, so it is stable in a cache key. */ +export function clampShadowAmount(amount: number | undefined): number { + if (typeof amount !== 'number' || !Number.isFinite(amount)) return DEFAULT_MERMAID_SHADOW_AMOUNT; + return Math.round(Math.min(1, Math.max(0, amount)) * 1000) / 1000; } /** WCAG minimums the guard enforces. */ @@ -317,6 +357,62 @@ export function isDarkBackground(background: RgbColor): boolean { return relativeLuminance(background) < 0.179; } +/** `rgba()` from an RgbColor plus an explicit alpha. */ +function rgba(c: RgbColor, alpha: number): string { + const ch = (v: number) => Math.round(Math.max(0, Math.min(1, v)) * 255); + return `rgba(${ch(c.r)}, ${ch(c.g)}, ${ch(c.b)}, ${Math.round(alpha * 1000) / 1000})`; +} + +/** + * The node drop shadow, as the `drop-shadow(x y blur colour)` string Mermaid's + * `dropShadow` theme variable takes, or `false` — its falsy value, which every + * `[data-look="neo"] … { filter: … }` rule turns into `filter: none`. + * + * Geometry scales with the amount over a small floor, so a toned-down shadow + * still reads as a lift rather than vanishing: at 1 it is exactly Mermaid's own + * `1px 2px 2px`, at 0 there is no shadow at all. + * + * The COLOUR comes from the palette rather than Mermaid's fixed + * `rgba(185,185,185,1)` grey — that grey is the halo that reads as wrong on a + * themed page — and its POLARITY follows the page, exactly as Mermaid's own + * `insertLookDefs` does (`floodColor = theme.includes('dark') ? '#FFFFFF' : + * '#000000'`): on a dark page a shadow's job is to LIFT the node off the + * ground, and a black shadow on a near-black ground paints nothing the eye can + * see. What is kept from the palette is the tint and the strength, not the + * direction: + * - dark page: the ground lifted 82% toward white, alpha 0.18 -> 0.90; + * - light page: the ground darkened 40% toward black, alpha 0.25 -> 0.55. + * Each is then asserted to differ from the ground in the READABLE direction, + * falling back to plain white or black, so no palette can produce an invisible + * shadow; a ground that is not a usable colour at all falls back to Mermaid's + * own grey. + * + * Pure. `ground` is the page's opaque background. + */ +export function buildMermaidShadow(ground: RgbColor | undefined, amount: number): string | false { + const a = clampShadowAmount(amount); + if (a <= 0) return false; + const round = (v: number) => Math.round(v * 100) / 100; + const geometry = `${round(0.3 + 0.7 * a)}px ${round(0.6 + 1.4 * a)}px ${round(0.6 + 1.4 * a)}px`; + const usable = + ground !== undefined && + Number.isFinite(ground.r) && + Number.isFinite(ground.g) && + Number.isFinite(ground.b); + // Nothing usable from the palette: Mermaid's own colour at our geometry. + if (!usable) return `drop-shadow(${geometry} ${MERMAID_DEFAULT_SHADOW_COLOR})`; + const dark = isDarkBackground(ground); + let colour = dark ? mixOklab(ground, WHITE, 0.82) : mixOklab(ground, BLACK, 0.4); + // The dark-page top end is calibrated so amount 1 reads as strongly as + // Mermaid's own `rgba(185,185,185,1)` over the same ground — 1 is defined as + // "Mermaid's default", so it has to actually look like it. + const alpha = dark ? 0.18 + 0.72 * a : 0.25 + 0.3 * a; + const groundL = relativeLuminance(ground); + if (dark && relativeLuminance(colour) <= groundL) colour = WHITE; + if (!dark && relativeLuminance(colour) >= groundL) colour = BLACK; + return `drop-shadow(${geometry} ${rgba(colour, alpha)})`; +} + /** * 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. @@ -376,10 +472,15 @@ export function buildCategoricalScale(p: Palette, polarity: MermaidThemeMode): R * 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 { +export function buildMermaidThemeVariables( + tokens: MermaidThemeTokens | undefined, + mode: MermaidThemeMode, + options?: MermaidThemeOptions, +): MermaidThemeSpec | null { if (!tokens) return null; const p = resolvePalette(tokens); if (!p) return null; + const shadowAmount = clampShadowAmount(options?.shadowAmount); // Base theme follows the mode; fill lightness and ink follow the measured page. const polarity: MermaidThemeMode = isDarkBackground(p.background) ? 'dark' : 'light'; @@ -428,6 +529,9 @@ export function buildMermaidThemeVariables(tokens: MermaidThemeTokens | undefine const scaleLabel = scale.map((c) => toHex(text(scaleInk, c))); const h = toHex; + // The shadow nodes, clusters and actors are painted with. Derived from the + // page's own ground, never Mermaid's fixed grey (see buildMermaidShadow). + const shadow = buildMermaidShadow(p.background, shadowAmount); const vars: Record = { darkMode: mode === 'dark', ...(p.fontFamily ? { fontFamily: p.fontFamily } : {}), @@ -459,6 +563,13 @@ export function buildMermaidThemeVariables(tokens: MermaidThemeTokens | undefine errorBkgColor: h(errorBg), errorTextColor: h(errorText), useGradient: false, + // Paint-time only: a filter on the shape, never a fill or a label colour. + dropShadow: shadow, + // `nodeShadow` gates ONE thing in Mermaid 12: the inline + // `filter:url(#…-drop-shadow-small)` a state diagram's small start / end + // dots carry, which no theme variable string reaches. Off at amount 0 so + // "no shadow" is true of every element. + ...(shadow === false ? { nodeShadow: false } : {}), // Flowchart nodeBkg: h(p.card), @@ -669,7 +780,7 @@ export function buildMermaidThemeVariables(tokens: MermaidThemeTokens | undefine vars[`fillType${i}`] = scaleHex[i]; vars[`venn${i + 1}`] = scaleHex[i]; } - return { theme: mode === 'dark' ? 'dark' : 'default', themeVariables: vars }; + return { theme: mode === 'dark' ? 'dark' : 'default', themeVariables: vars, shadowAmount }; } /** The full Mermaid config for a spec: `MERMAID_CONFIG` with the theme swapped. */ @@ -682,15 +793,33 @@ export function buildMermaidConfig(spec: MermaidThemeSpec | null): MermaidConfig // Runtime application (cached by key) // --------------------------------------------------------------------------- -/** `mode:palette`, the cache key `applyMermaidTheme` compares. */ -export function mermaidThemeKey(colorTheme: string, mode: MermaidThemeMode): string { - return `${mode}:${colorTheme}`; +/** + * `mode:palette`, the cache key `applyMermaidTheme` compares — plus a + * `#s` suffix when the shadow amount is not the shipped default, so a + * changed amount re-initializes the runtime. The default amount keeps the key + * byte-identical to the `(palette, mode)` pair it always was, which is also + * what keeps a host that never names an amount on the same key it had. + */ +export function mermaidThemeKey( + colorTheme: string, + mode: MermaidThemeMode, + shadowAmount: number = DEFAULT_MERMAID_SHADOW_AMOUNT, +): string { + const amount = clampShadowAmount(shadowAmount); + const base = `${mode}:${colorTheme}`; + return amount === DEFAULT_MERMAID_SHADOW_AMOUNT ? base : `${base}#s${amount}`; } function modeFromKey(key: string): MermaidThemeMode { return key.startsWith('light:') ? 'light' : 'dark'; } +/** The amount a key carries, the default when it carries none. */ +export function shadowAmountFromKey(key: string): number { + const match = /#s(\d*\.?\d+)$/u.exec(key); + return match ? clampShadowAmount(Number(match[1])) : DEFAULT_MERMAID_SHADOW_AMOUNT; +} + export type MermaidThemeApplyResult = 'unchanged' | 'dynamic' | 'static'; let appliedRuntime: Pick | null = null; @@ -710,7 +839,9 @@ export function applyMermaidTheme( root?: Element | null, ): MermaidThemeApplyResult { if (appliedRuntime === mermaid && appliedKey === key) return 'unchanged'; - const spec = buildMermaidThemeVariables(readThemeTokens(root), modeFromKey(key)); + const spec = buildMermaidThemeVariables(readThemeTokens(root), modeFromKey(key), { + shadowAmount: shadowAmountFromKey(key), + }); const sameRuntime = appliedRuntime === mermaid; appliedRuntime = mermaid; appliedKey = key; From ce734f818664818491f06ece36c41c99ba30427a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 17 Sep 2026 16:33:11 -0700 Subject: [PATCH 2/6] feat(ui): a Diagram Shadow setting, 0 / 40 / 70 / 100, default 70 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/components/DiagramBlock.tsx | 14 +++- .../ui/components/MermaidBlock.theme.test.tsx | 32 +++++++++ packages/ui/components/Settings.tsx | 30 ++++++++ .../ui/config/diagramShadowSetting.test.ts | 68 +++++++++++++++++++ packages/ui/config/settings.ts | 22 ++++++ packages/ui/utils/diagram-render.ts | 21 ++++-- packages/ui/utils/diagramShadow.ts | 27 ++++++++ 7 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 packages/ui/config/diagramShadowSetting.test.ts create mode 100644 packages/ui/utils/diagramShadow.ts diff --git a/packages/ui/components/DiagramBlock.tsx b/packages/ui/components/DiagramBlock.tsx index 6bba8c642..caa9a54ea 100644 --- a/packages/ui/components/DiagramBlock.tsx +++ b/packages/ui/components/DiagramBlock.tsx @@ -2,7 +2,9 @@ import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, use import { diagramTargetText, type DiagramKind } from '@plannotator/core/diagram-anchor'; import type { AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter'; import { AnnotationType, type Annotation, type Block } from '../types'; +import { useConfigValue } from '../config'; import type { DiagramTheme } from '../utils/diagram-render'; +import { diagramShadowAmount } from '../utils/diagramShadow'; import { getIdentity } from '../utils/identity'; import { createRuntimeRetryEpoch } from '../utils/runtimeRetry'; import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims'; @@ -103,9 +105,17 @@ export const DiagramBlock: React.FC = // provider renders exactly as before. A key change re-runs the render, // which is what re-themes an already rendered diagram. const { colorTheme, resolvedMode } = useTheme(); + // The node shadow reaches the renderer the same way the palette does: as + // part of the theme key, so changing it re-initializes Mermaid and re-renders + // every mounted diagram. + const diagramShadow = useConfigValue('diagramShadow'); const theme = useMemo( - () => ({ colorTheme, mode: resolvedMode === 'light' ? 'light' : 'dark' }), - [colorTheme, resolvedMode], + () => ({ + colorTheme, + mode: resolvedMode === 'light' ? 'light' : 'dark', + shadowAmount: diagramShadowAmount(diagramShadow), + }), + [colorTheme, resolvedMode, diagramShadow], ); // A sibling's Retry re-attempts this block too, but only while its own diff --git a/packages/ui/components/MermaidBlock.theme.test.tsx b/packages/ui/components/MermaidBlock.theme.test.tsx index 42f83bad9..70f34a4e6 100644 --- a/packages/ui/components/MermaidBlock.theme.test.tsx +++ b/packages/ui/components/MermaidBlock.theme.test.tsx @@ -21,6 +21,8 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import { configStore } from '../config'; +import { DEFAULT_DIAGRAM_SHADOW } from '../utils/diagramShadow'; import type { Block } from '../types'; import { installInertDiagramSvgParser } from '../test-setup/diagramSvg'; import { MermaidBlock, __setMermaidRuntimeLoaderForTests } from './MermaidBlock'; @@ -185,6 +187,36 @@ describe('MermaidBlock theming', () => { expect(svgCount()).toBe(2); }); + test.skipIf(!hasDom)('the diagram shadow setting reaches the runtime and re-renders mounted diagrams', async () => { + const { runtime, recorded } = fakeRuntime(); + __setMermaidRuntimeLoaderForTests(async () => runtime, { retryDelayMs: 5 }); + + await mount(); + await settle(); + + const first = recorded.initialize[0] as { themeVariables: Record }; + // The shipped default: a toned-down shadow derived from the palette. + expect(first.themeVariables.dropShadow as string).toStartWith('drop-shadow(0.79px '); + + try { + await act(async () => { + configStore.set('diagramShadow', 0); + }); + await settle(); + + expect(recorded.initialize).toHaveLength(2); + const off = recorded.initialize[1] as { themeVariables: Record }; + expect(off.themeVariables.dropShadow).toBe(false); + // The mounted diagram was re-rendered, not left on the old shadow. + expect(recorded.renders).toBe(2); + expect(svgCount()).toBe(1); + } finally { + await act(async () => { + configStore.set('diagramShadow', DEFAULT_DIAGRAM_SHADOW); + }); + } + }); + test.skipIf(!hasDom)('without theme tokens on the document the runtime is never re-initialized (host fallback)', async () => { styleEl?.remove(); styleEl = null; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index f58a305bf..dc5740a4d 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -6,6 +6,7 @@ import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import type { TokenHoverDelay } from '@plannotator/core/token-hover'; import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config'; import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference'; +import { DIAGRAM_SHADOW_OPTIONS } from '../utils/diagramShadow'; import { loadDiffFont } from '../utils/diffFonts'; import { TaterSpritePullup } from './TaterSpritePullup'; import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity'; @@ -959,6 +960,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange }, [themePreview]); const [activeTab, setActiveTab] = useState('general'); const gridEnabled = useConfigValue('gridEnabled'); + const diagramShadow = useConfigValue('diagramShadow'); const vimModeEnabled = useConfigValue('vimModeEnabled'); const vimHudEnabled = useConfigValue('vimHudEnabled'); const vimHudKeyPanelEnabled = useConfigValue('vimHudKeyPanelEnabled'); @@ -1656,6 +1658,34 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange
+ {/* Diagram Shadow */} +
+
+
Diagram Shadow
+
+ Drop shadow under diagram nodes (100 = Mermaid's own) +
+
+
+ {DIAGRAM_SHADOW_OPTIONS.map((value) => ( + + ))} +
+
+ +
+ {/* Plan Width */}
diff --git a/packages/ui/config/diagramShadowSetting.test.ts b/packages/ui/config/diagramShadowSetting.test.ts new file mode 100644 index 000000000..eeba71181 --- /dev/null +++ b/packages/ui/config/diagramShadowSetting.test.ts @@ -0,0 +1,68 @@ +/** + * The diagram shadow setting. + * + * The failure this guards is specific and was real during development: 0 is a + * VALID stored amount here, and `Number(null)` is 0, so a `fromCookie` that + * parses before checking for an absent cookie reads "never chosen" as "no + * shadow" — every diagram would ship flat, for everyone, with no way to tell + * the default from a choice. The rest pins the registry contract the Settings + * UI and `DiagramBlock` read through. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { resetStorageBackend, setStorageBackend } from '../utils/storage'; +import { DEFAULT_DIAGRAM_SHADOW, DIAGRAM_SHADOW_OPTIONS, diagramShadowAmount } from '../utils/diagramShadow'; +import { SETTINGS } from './settings'; + +const KEY = 'plannotator-diagram-shadow'; + +function installBackend(seed: Record = {}): Map { + const values = new Map(Object.entries(seed)); + setStorageBackend({ + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { values.set(key, value); }, + removeItem: (key) => { values.delete(key); }, + }); + return values; +} + +afterEach(() => { + resetStorageBackend(); +}); + +describe('diagramShadow', () => { + test('an absent cookie is undefined, not 0', () => { + installBackend(); + expect(SETTINGS.diagramShadow.fromCookie()).toBeUndefined(); + installBackend({ [KEY]: '' }); + expect(SETTINGS.diagramShadow.fromCookie()).toBeUndefined(); + // Deliberate default: the shipped look is the toned-down shadow, not none. + expect(SETTINGS.diagramShadow.defaultValue).toBe(DEFAULT_DIAGRAM_SHADOW); + }); + + test('round-trips every offered step, 0 included, and rejects anything else', () => { + const values = installBackend(); + for (const step of DIAGRAM_SHADOW_OPTIONS) { + SETTINGS.diagramShadow.toCookie(step); + expect(values.get(KEY)).toBe(String(step)); + expect(SETTINGS.diagramShadow.fromCookie()).toBe(step); + } + for (const bad of ['-10', '101', '70.5', 'lots']) { + values.set(KEY, bad); + expect(SETTINGS.diagramShadow.fromCookie()).toBeUndefined(); + } + }); + + test('the stored percent becomes the 0..1 amount the mapping takes', () => { + expect(diagramShadowAmount(0)).toBe(0); + expect(diagramShadowAmount(70)).toBe(0.7); + expect(diagramShadowAmount(100)).toBe(1); + // Nothing downstream ever sees an out-of-range amount. + expect(diagramShadowAmount(-5)).toBe(0); + expect(diagramShadowAmount(400)).toBe(1); + expect(diagramShadowAmount(Number.NaN)).toBe(DEFAULT_DIAGRAM_SHADOW / 100); + }); + + test('it is cookie-only: no server key, so it never writes ~/.plannotator/config.json', () => { + expect(SETTINGS.diagramShadow.serverKey).toBeUndefined(); + }); +}); diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index a748ad912..82f4a4454 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -22,6 +22,7 @@ import { type TokenHoverDelay, type TokenHoverTrigger, } from '@plannotator/core/token-hover'; +import { DEFAULT_DIAGRAM_SHADOW, isDiagramShadow } from '../utils/diagramShadow'; import { storage } from '../utils/storage'; import { generateIdentity } from '../utils/generateIdentity'; import { @@ -170,6 +171,27 @@ export const SETTINGS = { serverKey: undefined, fromServer: undefined, toServer: undefined, }, + /** + * How strong the drop shadow under Mermaid diagram nodes is, 0..100, where + * 100 is Mermaid 12's own default geometry. Default 70: the shipped neo look + * with its halo toned down (the colour is always derived from the palette, + * see `utils/mermaidTheme`). Cookie-only, like the other display knobs. + */ + diagramShadow: { + defaultValue: DEFAULT_DIAGRAM_SHADOW as number, + fromCookie: () => { + // `Number(null)` and `Number('')` are 0, which is a VALID amount here + // (unlike the token-hover steps), so an absent cookie must be rejected + // before the guard sees it — otherwise no cookie reads as "no shadow". + const raw = storage.getItem('plannotator-diagram-shadow'); + if (raw === null || raw.trim() === '') return undefined; + const parsed = Number(raw); + return isDiagramShadow(parsed) ? parsed : undefined; + }, + toCookie: (value: number) => storage.setItem('plannotator-diagram-shadow', String(value)), + serverKey: undefined, fromServer: undefined, toServer: undefined, + }, + vimModeEnabled: { // Vim bindings deliberately default OFF. Unmodified letter keys must remain // inert for existing users until they explicitly opt into modal document diff --git a/packages/ui/utils/diagram-render.ts b/packages/ui/utils/diagram-render.ts index 21a04c62b..5b33581b5 100644 --- a/packages/ui/utils/diagram-render.ts +++ b/packages/ui/utils/diagram-render.ts @@ -48,14 +48,21 @@ type Mermaid = Awaited>; /** * The (palette, mode) a render is for: the same pair `useTheme()` resolves - * for code fences. The mermaid entry passes it to `applyMermaidTheme`, which - * runs the global `initialize` once per key; the graphviz entry recolors its - * defaults onto CSS tokens and needs neither. A host without ThemeProvider - * passes any palette id with the mode it renders in. + * for code fences, plus the node shadow amount the user chose. The mermaid + * entry passes them to `applyMermaidTheme`, which runs the global `initialize` + * once per key; the graphviz entry recolors its defaults onto CSS tokens and + * needs neither. A host without ThemeProvider passes any palette id with the + * mode it renders in. */ export interface DiagramTheme { readonly colorTheme: string; readonly mode: MermaidThemeMode; + /** + * Node drop shadow strength, 0..1 (see `mermaidTheme.buildMermaidShadow`). + * Absent means the shipped default, so a host that builds its own + * `DiagramTheme` renders exactly what Plannotator renders. + */ + readonly shadowAmount?: number; } export type DiagramRenderError = { @@ -514,11 +521,11 @@ const mermaidRenderer: DiagramRenderer = { } // Derive every Mermaid theme variable from the page's tokens before each // render, so the diagram follows the palette and mode. Keyed on the - // runtime plus (palette, mode), so the lazy runtime is themed on its - // first render and re-initialized only when one of the three changes. + // runtime plus (palette, mode, shadow amount), so the lazy runtime is + // themed on its first render and re-initialized only when one changes. // With no tokens on the page it is a no-op and the static // MERMAID_CONFIG (securityLevel strict) applies. - applyMermaidTheme(runtime, mermaidThemeKey(theme.colorTheme, theme.mode)); + applyMermaidTheme(runtime, mermaidThemeKey(theme.colorTheme, theme.mode, theme.shadowAmount)); return renderMermaid(runtime, renderId, source); }, }; diff --git a/packages/ui/utils/diagramShadow.ts b/packages/ui/utils/diagramShadow.ts new file mode 100644 index 000000000..69b6aadb5 --- /dev/null +++ b/packages/ui/utils/diagramShadow.ts @@ -0,0 +1,27 @@ +/** + * The user-facing scale for the Mermaid node drop shadow: whole percent, 0..100, + * where 100 is Mermaid 12's own default shadow and 0 is none. + * + * Deliberately free of imports so the settings registry can read it on every + * surface (the guides.show viewer included) without pulling the diagram theme + * mapping — and the runtime into which it feeds — into that module graph. + * `mermaidTheme.DEFAULT_MERMAID_SHADOW_AMOUNT` is the same value in the 0..1 + * unit the mapping takes; `mermaidTheme.test.ts` pins the two together. + */ + +/** The steps Settings offers. A slider would imply a precision nobody can see. */ +export const DIAGRAM_SHADOW_OPTIONS = [0, 40, 70, 100] as const; + +/** Shipped default: the neo look with its grey halo toned down. */ +export const DEFAULT_DIAGRAM_SHADOW = 70; + +/** Any whole percent in range is valid, not only the offered steps. */ +export function isDiagramShadow(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 100; +} + +/** The 0..1 amount `buildMermaidThemeVariables` takes. */ +export function diagramShadowAmount(percent: number): number { + if (!Number.isFinite(percent)) return DEFAULT_DIAGRAM_SHADOW / 100; + return Math.min(100, Math.max(0, percent)) / 100; +} From 32e7cdc072765ec14494ee96c85e688a74cfcbc3 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 17 Sep 2026 16:33:16 -0700 Subject: [PATCH 3/6] docs: the palette-derived Mermaid shadow, its default, and the host escape 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. --- AGENTS.md | 2 +- packages/ui/HANDOFF.md | 8 +++++++- packages/ui/README.md | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 06af20e5c..33b898208 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1091,7 +1091,7 @@ There is **one** highlighter in the app: the Shiki instance `@pierre/diffs` alre **Diagram engine (ui 0.41.0):** every Mermaid and Graphviz diagram renders through ONE renderer slot and ONE canvas, the viewer moved in from Workspaces (owner ruling: the new engine, not an option). `MermaidBlock` and `GraphvizBlock` are thin wrappers over `packages/ui/components/DiagramBlock.tsx`, which keeps the fence side (`diagramLanguages.ts`, the pending fence under "Rendering diagram…", the error panel with the source, the per-engine Retry epoch, the Show-source toggle, the natural-height box) and renders `DiagramViewer` (`packages/ui/components/diagram/`) inline and again at full size in `DiagramPopout` (the `TablePopout` chrome; one code path). The renderer slot is `packages/ui/utils/diagram-render.ts`: `renderDiagram(kind, renderId, source, theme)` loads the engine through its runtime slot (`utils/mermaid.ts` as before, with `applyMermaidTheme` per (palette, mode) before every render; `utils/graphviz.ts`, new, the same shape, `@viz-js/viz` pinned exactly `3.30.0`), sanitizes the output into a NODE (`sanitizeDiagramSvg` = DOMPurify `parseDiagramSvg` + the in-place `scrubDiagramSvg` belt: no script, no `on*`, no `javascript:`/`data:` reference, no `` — a Mermaid click binding must never turn a pinpoint click into a navigation), and pairs it with the engine's finder (`utils/diagram-anchor.ts` for Mermaid's id grammar, `utils/diagram-anchor-graphviz.ts` keyed on `g.node > title`, never `nodeN`). A load failure is a value (`runtimeUnavailable: true`), which is the one failure Retry can change; one automatic re-attempt runs after the slot's retry delay. The canvas (`DiagramCanvas`) mounts the node with `replaceChildren` inside a CSS-transformed wrapper — wheel/drag/`+`/`-`/`0`/arrow keys; click-to-select vs drag-to-pan on a pointer-type-aware threshold (4 px mouse, 10 px finger); NO hover targeting on a plain mouse-over (owner ruling: it read as messy and fought the pan hand — the ring under the pointer appears only while the platform modifier is held, `isModKeyHeld`, and disarms on its release, on any other key, and on blur); canvas keys ignore Meta/Ctrl/Alt so browser chords pass through; inline the canvas is `touch-action: pan-y` (a finger scrolls the page past it) and only the popout is `touch-none`; the zoom strip is `data-print-hide` while rings and badges print. Every edge gets an invisible 14 px hit path in ONE layer appended last in the svg root (`widenEdgeHitAreas`: bare geometry, ancestors' transforms composed, no id/class/`data-*`, `diagramHitSource` maps it back) — measured in Chromium, the only thing painted over an edge is its OWN label box, centred on its midpoint, so a label is a target that resolves to its edge, and because the hit layer sits over the nodes the canvas resolves a click by PRIORITY over `elementsFromPoint` (node, then edge, then cluster), never by `event.target`. A click that resolves no part comments on the WHOLE diagram (kind `diagram`), so a click never does nothing. The scrub also scopes every `