@plannotator/ui 0.41.1 → 0.41.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,14 +48,21 @@ type Mermaid = Awaited<ReturnType<typeof loadMermaidRuntime>>;
48
48
 
49
49
  /**
50
50
  * The (palette, mode) a render is for: the same pair `useTheme()` resolves
51
- * for code fences. The mermaid entry passes it to `applyMermaidTheme`, which
52
- * runs the global `initialize` once per key; the graphviz entry recolors its
53
- * defaults onto CSS tokens and needs neither. A host without ThemeProvider
54
- * passes any palette id with the mode it renders in.
51
+ * for code fences, plus the node shadow amount the user chose. The mermaid
52
+ * entry passes them to `applyMermaidTheme`, which runs the global `initialize`
53
+ * once per key; the graphviz entry recolors its defaults onto CSS tokens and
54
+ * needs neither. A host without ThemeProvider passes any palette id with the
55
+ * mode it renders in.
55
56
  */
56
57
  export interface DiagramTheme {
57
58
  readonly colorTheme: string;
58
59
  readonly mode: MermaidThemeMode;
60
+ /**
61
+ * Node drop shadow strength, 0..1 (see `mermaidTheme.buildMermaidShadow`).
62
+ * Absent means the shipped default, so a host that builds its own
63
+ * `DiagramTheme` renders exactly what Plannotator renders.
64
+ */
65
+ readonly shadowAmount?: number;
59
66
  }
60
67
 
61
68
  export type DiagramRenderError = {
@@ -514,11 +521,11 @@ const mermaidRenderer: DiagramRenderer = {
514
521
  }
515
522
  // Derive every Mermaid theme variable from the page's tokens before each
516
523
  // render, so the diagram follows the palette and mode. Keyed on the
517
- // runtime plus (palette, mode), so the lazy runtime is themed on its
518
- // first render and re-initialized only when one of the three changes.
524
+ // runtime plus (palette, mode, shadow amount), so the lazy runtime is
525
+ // themed on its first render and re-initialized only when one changes.
519
526
  // With no tokens on the page it is a no-op and the static
520
527
  // MERMAID_CONFIG (securityLevel strict) applies.
521
- applyMermaidTheme(runtime, mermaidThemeKey(theme.colorTheme, theme.mode));
528
+ applyMermaidTheme(runtime, mermaidThemeKey(theme.colorTheme, theme.mode, theme.shadowAmount));
522
529
  return renderMermaid(runtime, renderId, source);
523
530
  },
524
531
  };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The user-facing scale for the Mermaid node drop shadow: whole percent, 0..100,
3
+ * where 100 is Mermaid 12's own default shadow and 0 is none.
4
+ *
5
+ * Deliberately free of imports so the settings registry can read it on every
6
+ * surface (the guides.show viewer included) without pulling the diagram theme
7
+ * mapping — and the runtime into which it feeds — into that module graph.
8
+ * `mermaidTheme.DEFAULT_MERMAID_SHADOW_AMOUNT` is the same value in the 0..1
9
+ * unit the mapping takes; `mermaidTheme.test.ts` pins the two together.
10
+ */
11
+
12
+ /** The steps Settings offers. A slider would imply a precision nobody can see. */
13
+ export const DIAGRAM_SHADOW_OPTIONS = [0, 40, 70, 100] as const;
14
+
15
+ /** Shipped default: the neo look with its grey halo toned down. */
16
+ export const DEFAULT_DIAGRAM_SHADOW = 70;
17
+
18
+ /** Any whole percent in range is valid, not only the offered steps. */
19
+ export function isDiagramShadow(value: unknown): value is number {
20
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 100;
21
+ }
22
+
23
+ /** The 0..1 amount `buildMermaidThemeVariables` takes. */
24
+ export function diagramShadowAmount(percent: number): number {
25
+ if (!Number.isFinite(percent)) return DEFAULT_DIAGRAM_SHADOW / 100;
26
+ return Math.min(100, Math.max(0, percent)) / 100;
27
+ }
package/utils/mermaid.ts CHANGED
@@ -45,6 +45,18 @@ export const MERMAID_CONFIG: MermaidConfig = {
45
45
  clusterBorder: '#475569',
46
46
  titleColor: '#f8fafc',
47
47
  edgeLabelBackground: '#1e293b',
48
+ /**
49
+ * Mermaid 12's neo look shadows every node from a fixed
50
+ * `drop-shadow(1px 2px 2px rgba(185,185,185,1))` grey, which reads as a
51
+ * halo. The token-driven mapping replaces it per palette
52
+ * (`buildMermaidShadow` in `./mermaidTheme`); this static config is what a
53
+ * host with no theme tokens renders with, so it carries the same
54
+ * toned-down 0.7 geometry with a fixed colour. The value is what
55
+ * `buildMermaidShadow(#1e293b, 0.7)` returns for THIS config's own slate
56
+ * ground, which is dark — hence a light shadow, exactly as Mermaid's own
57
+ * `insertLookDefs` uses a white flood colour on dark themes.
58
+ */
59
+ dropShadow: 'drop-shadow(0.79px 1.58px 1.58px rgba(210, 212, 217, 0.684))',
48
60
  },
49
61
  flowchart: {
50
62
  htmlLabels: true,
@@ -24,7 +24,18 @@
24
24
  * Mermaid's colour library does not parse `oklch()`.
25
25
  * 3. `applyMermaidTheme(mermaid, key)` is the runtime step `MermaidBlock`
26
26
  * calls before every render: `mermaid.initialize` is global state, so it
27
- * runs only when the `(palette, mode)` key changed since the last apply.
27
+ * runs only when the `(palette, mode, shadow amount)` key changed since the
28
+ * last apply.
29
+ *
30
+ * Node shadow: Mermaid 12's neo look paints a drop shadow on every node,
31
+ * cluster and actor, from its own fixed `rgba(185,185,185,1)` grey at
32
+ * `1px 2px 2px`. The mapping keeps the look and replaces that one filter:
33
+ * `themeVariables.dropShadow` is built from the palette's own ground at
34
+ * `DEFAULT_MERMAID_SHADOW_AMOUNT` (0.7 of Mermaid's geometry), in the polarity
35
+ * the page reads in — see `buildMermaidShadow`. A host that wants Mermaid's
36
+ * grey back sets its own `themeVariables.dropShadow` after ours; a host that
37
+ * wants none passes `{ shadowAmount: 0 }`, which publishes `dropShadow: false`
38
+ * and the neo rules render `filter: none`.
28
39
  *
29
40
  * Fallback contract (hosts): when no tokens resolve, the runtime keeps the
30
41
  * static `MERMAID_CONFIG` it was initialized with, and nothing is
@@ -106,6 +117,35 @@ export type MermaidThemeTokens = Partial<Record<MermaidThemeTokenName, string>>;
106
117
  export interface MermaidThemeSpec {
107
118
  theme: 'dark' | 'default';
108
119
  themeVariables: Record<string, unknown>;
120
+ /** The node shadow amount baked into `themeVariables.dropShadow` (0..1). */
121
+ shadowAmount: number;
122
+ }
123
+
124
+ /** Options `buildMermaidThemeVariables` accepts beyond the tokens and mode. */
125
+ export interface MermaidThemeOptions {
126
+ /**
127
+ * How much node drop shadow to paint, 0..1, where 1 reproduces Mermaid's
128
+ * own default geometry (`1px 2px 2px`) and 0 means no shadow at all.
129
+ * Default: `DEFAULT_MERMAID_SHADOW_AMOUNT`.
130
+ */
131
+ readonly shadowAmount?: number;
132
+ }
133
+
134
+ /**
135
+ * The shipped shadow amount: Mermaid 12's neo look at 70% of its own shadow.
136
+ * The full-strength default reads as a grey halo around every node on a
137
+ * Plannotator page; 70 keeps the lift and drops the halo.
138
+ */
139
+ export const DEFAULT_MERMAID_SHADOW_AMOUNT = 0.7;
140
+
141
+ /** Mermaid 12's own node shadow, the colour included. Amount 1 reproduces
142
+ * this geometry; the colour is ours unless the palette yields nothing. */
143
+ export const MERMAID_DEFAULT_SHADOW_COLOR = 'rgba(185, 185, 185, 1)';
144
+
145
+ /** Clamp an amount to 0..1 and round it, so it is stable in a cache key. */
146
+ export function clampShadowAmount(amount: number | undefined): number {
147
+ if (typeof amount !== 'number' || !Number.isFinite(amount)) return DEFAULT_MERMAID_SHADOW_AMOUNT;
148
+ return Math.round(Math.min(1, Math.max(0, amount)) * 1000) / 1000;
109
149
  }
110
150
 
111
151
  /** WCAG minimums the guard enforces. */
@@ -317,6 +357,62 @@ export function isDarkBackground(background: RgbColor): boolean {
317
357
  return relativeLuminance(background) < 0.179;
318
358
  }
319
359
 
360
+ /** `rgba()` from an RgbColor plus an explicit alpha. */
361
+ function rgba(c: RgbColor, alpha: number): string {
362
+ const ch = (v: number) => Math.round(Math.max(0, Math.min(1, v)) * 255);
363
+ return `rgba(${ch(c.r)}, ${ch(c.g)}, ${ch(c.b)}, ${Math.round(alpha * 1000) / 1000})`;
364
+ }
365
+
366
+ /**
367
+ * The node drop shadow, as the `drop-shadow(x y blur colour)` string Mermaid's
368
+ * `dropShadow` theme variable takes, or `false` — its falsy value, which every
369
+ * `[data-look="neo"] … { filter: … }` rule turns into `filter: none`.
370
+ *
371
+ * Geometry scales with the amount over a small floor, so a toned-down shadow
372
+ * still reads as a lift rather than vanishing: at 1 it is exactly Mermaid's own
373
+ * `1px 2px 2px`, at 0 there is no shadow at all.
374
+ *
375
+ * The COLOUR comes from the palette rather than Mermaid's fixed
376
+ * `rgba(185,185,185,1)` grey — that grey is the halo that reads as wrong on a
377
+ * themed page — and its POLARITY follows the page, exactly as Mermaid's own
378
+ * `insertLookDefs` does (`floodColor = theme.includes('dark') ? '#FFFFFF' :
379
+ * '#000000'`): on a dark page a shadow's job is to LIFT the node off the
380
+ * ground, and a black shadow on a near-black ground paints nothing the eye can
381
+ * see. What is kept from the palette is the tint and the strength, not the
382
+ * direction:
383
+ * - dark page: the ground lifted 82% toward white, alpha 0.18 -> 0.90;
384
+ * - light page: the ground darkened 40% toward black, alpha 0.25 -> 0.55.
385
+ * Each is then asserted to differ from the ground in the READABLE direction,
386
+ * falling back to plain white or black, so no palette can produce an invisible
387
+ * shadow; a ground that is not a usable colour at all falls back to Mermaid's
388
+ * own grey.
389
+ *
390
+ * Pure. `ground` is the page's opaque background.
391
+ */
392
+ export function buildMermaidShadow(ground: RgbColor | undefined, amount: number): string | false {
393
+ const a = clampShadowAmount(amount);
394
+ if (a <= 0) return false;
395
+ const round = (v: number) => Math.round(v * 100) / 100;
396
+ const geometry = `${round(0.3 + 0.7 * a)}px ${round(0.6 + 1.4 * a)}px ${round(0.6 + 1.4 * a)}px`;
397
+ const usable =
398
+ ground !== undefined &&
399
+ Number.isFinite(ground.r) &&
400
+ Number.isFinite(ground.g) &&
401
+ Number.isFinite(ground.b);
402
+ // Nothing usable from the palette: Mermaid's own colour at our geometry.
403
+ if (!usable) return `drop-shadow(${geometry} ${MERMAID_DEFAULT_SHADOW_COLOR})`;
404
+ const dark = isDarkBackground(ground);
405
+ let colour = dark ? mixOklab(ground, WHITE, 0.82) : mixOklab(ground, BLACK, 0.4);
406
+ // The dark-page top end is calibrated so amount 1 reads as strongly as
407
+ // Mermaid's own `rgba(185,185,185,1)` over the same ground — 1 is defined as
408
+ // "Mermaid's default", so it has to actually look like it.
409
+ const alpha = dark ? 0.18 + 0.72 * a : 0.25 + 0.3 * a;
410
+ const groundL = relativeLuminance(ground);
411
+ if (dark && relativeLuminance(colour) <= groundL) colour = WHITE;
412
+ if (!dark && relativeLuminance(colour) >= groundL) colour = BLACK;
413
+ return `drop-shadow(${geometry} ${rgba(colour, alpha)})`;
414
+ }
415
+
320
416
  /**
321
417
  * Push a fill's lightness away from `ink` until `ink` reads on it at 4.5:1.
322
418
  * Used for the categorical scale, whose single ink is fixed per mode.
@@ -376,10 +472,15 @@ export function buildCategoricalScale(p: Palette, polarity: MermaidThemeMode): R
376
472
  * the tokens. Returns `null` when the required tokens are missing or
377
473
  * unparsable, which callers treat as "use the static config".
378
474
  */
379
- export function buildMermaidThemeVariables(tokens: MermaidThemeTokens | undefined, mode: MermaidThemeMode): MermaidThemeSpec | null {
475
+ export function buildMermaidThemeVariables(
476
+ tokens: MermaidThemeTokens | undefined,
477
+ mode: MermaidThemeMode,
478
+ options?: MermaidThemeOptions,
479
+ ): MermaidThemeSpec | null {
380
480
  if (!tokens) return null;
381
481
  const p = resolvePalette(tokens);
382
482
  if (!p) return null;
483
+ const shadowAmount = clampShadowAmount(options?.shadowAmount);
383
484
 
384
485
  // Base theme follows the mode; fill lightness and ink follow the measured page.
385
486
  const polarity: MermaidThemeMode = isDarkBackground(p.background) ? 'dark' : 'light';
@@ -428,6 +529,9 @@ export function buildMermaidThemeVariables(tokens: MermaidThemeTokens | undefine
428
529
  const scaleLabel = scale.map((c) => toHex(text(scaleInk, c)));
429
530
 
430
531
  const h = toHex;
532
+ // The shadow nodes, clusters and actors are painted with. Derived from the
533
+ // page's own ground, never Mermaid's fixed grey (see buildMermaidShadow).
534
+ const shadow = buildMermaidShadow(p.background, shadowAmount);
431
535
  const vars: Record<string, unknown> = {
432
536
  darkMode: mode === 'dark',
433
537
  ...(p.fontFamily ? { fontFamily: p.fontFamily } : {}),
@@ -459,6 +563,13 @@ export function buildMermaidThemeVariables(tokens: MermaidThemeTokens | undefine
459
563
  errorBkgColor: h(errorBg),
460
564
  errorTextColor: h(errorText),
461
565
  useGradient: false,
566
+ // Paint-time only: a filter on the shape, never a fill or a label colour.
567
+ dropShadow: shadow,
568
+ // `nodeShadow` gates ONE thing in Mermaid 12: the inline
569
+ // `filter:url(#…-drop-shadow-small)` a state diagram's small start / end
570
+ // dots carry, which no theme variable string reaches. Off at amount 0 so
571
+ // "no shadow" is true of every element.
572
+ ...(shadow === false ? { nodeShadow: false } : {}),
462
573
 
463
574
  // Flowchart
464
575
  nodeBkg: h(p.card),
@@ -669,7 +780,7 @@ export function buildMermaidThemeVariables(tokens: MermaidThemeTokens | undefine
669
780
  vars[`fillType${i}`] = scaleHex[i];
670
781
  vars[`venn${i + 1}`] = scaleHex[i];
671
782
  }
672
- return { theme: mode === 'dark' ? 'dark' : 'default', themeVariables: vars };
783
+ return { theme: mode === 'dark' ? 'dark' : 'default', themeVariables: vars, shadowAmount };
673
784
  }
674
785
 
675
786
  /** The full Mermaid config for a spec: `MERMAID_CONFIG` with the theme swapped. */
@@ -682,15 +793,33 @@ export function buildMermaidConfig(spec: MermaidThemeSpec | null): MermaidConfig
682
793
  // Runtime application (cached by key)
683
794
  // ---------------------------------------------------------------------------
684
795
 
685
- /** `mode:palette`, the cache key `applyMermaidTheme` compares. */
686
- export function mermaidThemeKey(colorTheme: string, mode: MermaidThemeMode): string {
687
- return `${mode}:${colorTheme}`;
796
+ /**
797
+ * `mode:palette`, the cache key `applyMermaidTheme` compares — plus a
798
+ * `#s<amount>` suffix when the shadow amount is not the shipped default, so a
799
+ * changed amount re-initializes the runtime. The default amount keeps the key
800
+ * byte-identical to the `(palette, mode)` pair it always was, which is also
801
+ * what keeps a host that never names an amount on the same key it had.
802
+ */
803
+ export function mermaidThemeKey(
804
+ colorTheme: string,
805
+ mode: MermaidThemeMode,
806
+ shadowAmount: number = DEFAULT_MERMAID_SHADOW_AMOUNT,
807
+ ): string {
808
+ const amount = clampShadowAmount(shadowAmount);
809
+ const base = `${mode}:${colorTheme}`;
810
+ return amount === DEFAULT_MERMAID_SHADOW_AMOUNT ? base : `${base}#s${amount}`;
688
811
  }
689
812
 
690
813
  function modeFromKey(key: string): MermaidThemeMode {
691
814
  return key.startsWith('light:') ? 'light' : 'dark';
692
815
  }
693
816
 
817
+ /** The amount a key carries, the default when it carries none. */
818
+ export function shadowAmountFromKey(key: string): number {
819
+ const match = /#s(\d*\.?\d+)$/u.exec(key);
820
+ return match ? clampShadowAmount(Number(match[1])) : DEFAULT_MERMAID_SHADOW_AMOUNT;
821
+ }
822
+
694
823
  export type MermaidThemeApplyResult = 'unchanged' | 'dynamic' | 'static';
695
824
 
696
825
  let appliedRuntime: Pick<Mermaid, 'initialize'> | null = null;
@@ -710,7 +839,9 @@ export function applyMermaidTheme(
710
839
  root?: Element | null,
711
840
  ): MermaidThemeApplyResult {
712
841
  if (appliedRuntime === mermaid && appliedKey === key) return 'unchanged';
713
- const spec = buildMermaidThemeVariables(readThemeTokens(root), modeFromKey(key));
842
+ const spec = buildMermaidThemeVariables(readThemeTokens(root), modeFromKey(key), {
843
+ shadowAmount: shadowAmountFromKey(key),
844
+ });
714
845
  const sameRuntime = appliedRuntime === mermaid;
715
846
  appliedRuntime = mermaid;
716
847
  appliedKey = key;