@djangocfg/widget-diagram 0.1.1

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/package.json +77 -0
  4. package/src/FloatingToolbar/FloatingToolbar.css +5 -0
  5. package/src/FloatingToolbar/actions/CopyAction.tsx +31 -0
  6. package/src/FloatingToolbar/actions/DownloadAction.tsx +51 -0
  7. package/src/FloatingToolbar/actions/ExpandAction.tsx +33 -0
  8. package/src/FloatingToolbar/actions/FullscreenAction.tsx +38 -0
  9. package/src/FloatingToolbar/actions/index.ts +4 -0
  10. package/src/FloatingToolbar/hooks/useScrollIsolation.ts +62 -0
  11. package/src/FloatingToolbar/index.tsx +184 -0
  12. package/src/Mermaid.client.tsx +97 -0
  13. package/src/builders/FlowDiagram/FlowDiagram.ts +96 -0
  14. package/src/builders/FlowDiagram/functions/getEdges.ts +50 -0
  15. package/src/builders/FlowDiagram/functions/getNodes.ts +43 -0
  16. package/src/builders/FlowDiagram/functions/getStyles.ts +90 -0
  17. package/src/builders/FlowDiagram/functions/index.ts +8 -0
  18. package/src/builders/FlowDiagram/index.ts +16 -0
  19. package/src/builders/FlowDiagram/types.ts +130 -0
  20. package/src/builders/JourneyDiagram/JourneyDiagram.ts +88 -0
  21. package/src/builders/JourneyDiagram/index.ts +12 -0
  22. package/src/builders/JourneyDiagram/types.ts +48 -0
  23. package/src/builders/SequenceDiagram/SequenceDiagram.ts +158 -0
  24. package/src/builders/SequenceDiagram/functions/getActivations.ts +30 -0
  25. package/src/builders/SequenceDiagram/functions/getBlocks.ts +112 -0
  26. package/src/builders/SequenceDiagram/functions/getMessages.ts +85 -0
  27. package/src/builders/SequenceDiagram/functions/getNotes.ts +94 -0
  28. package/src/builders/SequenceDiagram/functions/index.ts +16 -0
  29. package/src/builders/SequenceDiagram/index.ts +18 -0
  30. package/src/builders/SequenceDiagram/types.ts +192 -0
  31. package/src/builders/core/DiagramStore.ts +138 -0
  32. package/src/builders/core/index.ts +8 -0
  33. package/src/builders/core/sanitize.ts +83 -0
  34. package/src/builders/core/theme.ts +42 -0
  35. package/src/builders/core/types.ts +183 -0
  36. package/src/builders/index.ts +96 -0
  37. package/src/components/MermaidCodeViewer.tsx +95 -0
  38. package/src/components/MermaidErrorPanel.tsx +31 -0
  39. package/src/components/MermaidFullscreenModal.tsx +201 -0
  40. package/src/hooks/index.ts +4 -0
  41. package/src/hooks/useMermaidCleanup.ts +70 -0
  42. package/src/hooks/useMermaidFullscreen.ts +46 -0
  43. package/src/hooks/useMermaidRenderer.ts +329 -0
  44. package/src/hooks/useMermaidValidation.ts +97 -0
  45. package/src/index.tsx +79 -0
  46. package/src/lazy.tsx +40 -0
  47. package/src/mermaid.stories.tsx +217 -0
  48. package/src/types.ts +28 -0
  49. package/src/utils/mermaid-helpers.ts +157 -0
@@ -0,0 +1,201 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { X, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
6
+ import { TransformWrapper, TransformComponent, useControls } from 'react-zoom-pan-pinch';
7
+
8
+ import { Button } from '@djangocfg/ui-core/components';
9
+ import { applyMermaidTextColors, getTextColor } from '../utils/mermaid-helpers';
10
+
11
+ interface MermaidFullscreenModalProps {
12
+ isOpen: boolean;
13
+ svgContent: string;
14
+ /**
15
+ * Whether the source diagram is vertical. Kept for API compatibility
16
+ * with callers; the modal now auto-fits via measured bbox so it no
17
+ * longer needs an orientation hint.
18
+ */
19
+ isVertical?: boolean;
20
+ theme: string;
21
+ /** Diagram source. Reserved for future modal actions (copy/export). */
22
+ chart?: string;
23
+ fullscreenRef: React.RefObject<HTMLDivElement | null>;
24
+ onClose: () => void;
25
+ onBackdropClick: (e: React.MouseEvent) => void;
26
+ }
27
+
28
+ // Zoom controls component
29
+ function ZoomControls() {
30
+ const { zoomIn, zoomOut, resetTransform } = useControls();
31
+
32
+ return (
33
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-10">
34
+ <Button variant="secondary" size="icon" onClick={() => zoomOut()}>
35
+ <ZoomOut className="h-4 w-4" />
36
+ </Button>
37
+ <Button variant="secondary" size="icon" onClick={() => resetTransform()}>
38
+ <RotateCcw className="h-4 w-4" />
39
+ </Button>
40
+ <Button variant="secondary" size="icon" onClick={() => zoomIn()}>
41
+ <ZoomIn className="h-4 w-4" />
42
+ </Button>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ export const MermaidFullscreenModal: React.FC<MermaidFullscreenModalProps> = ({
48
+ isOpen,
49
+ svgContent,
50
+ theme,
51
+ fullscreenRef,
52
+ onClose,
53
+ onBackdropClick,
54
+ }) => {
55
+ // Auto-fit scale on open. Two failure modes drove this design:
56
+ //
57
+ // 1. Stale state across re-opens. Without a reset, the second
58
+ // open would still see the previous fit value in state; if
59
+ // the new SVG had the same dimensions the `key` swap below
60
+ // wouldn't fire and TransformWrapper would skip re-init.
61
+ // 2. SVG not in DOM yet at first rAF. Mermaid renders into
62
+ // `fullscreenRef` after the modal portal mounts; on a fast
63
+ // paint the first `querySelector('svg')` returned null and
64
+ // the scale stayed at the fallback `1`. Retry across a few
65
+ // frames until the bbox is real, then commit.
66
+ //
67
+ // `openSeq` increments on every open so the `key` always changes,
68
+ // forcing a fresh TransformWrapper instance even when the fit
69
+ // value happens to repeat.
70
+ const [initialScale, setInitialScale] = useState<number | null>(null);
71
+ const [openSeq, setOpenSeq] = useState(0);
72
+ useEffect(() => {
73
+ if (!isOpen) {
74
+ // Reset so the next open recomputes from scratch.
75
+ setInitialScale(null);
76
+ return;
77
+ }
78
+ setOpenSeq((n) => n + 1);
79
+ let cancelled = false;
80
+ let attempts = 0;
81
+ const tick = () => {
82
+ if (cancelled) return;
83
+ attempts += 1;
84
+ const svg = fullscreenRef.current?.querySelector('svg');
85
+ const bbox = svg?.getBoundingClientRect();
86
+ if (svg && bbox && bbox.width > 1 && bbox.height > 1) {
87
+ const targetW = window.innerWidth * 0.9;
88
+ const targetH = window.innerHeight * 0.9;
89
+ const fit = Math.min(targetW / bbox.width, targetH / bbox.height);
90
+ setInitialScale(Math.max(1, Math.min(fit, 6)));
91
+ return;
92
+ }
93
+ // Give Mermaid up to ~30 frames (~0.5s @ 60fps) to paint
94
+ // before settling for the unscaled fallback.
95
+ if (attempts < 30) {
96
+ requestAnimationFrame(tick);
97
+ } else {
98
+ setInitialScale(1);
99
+ }
100
+ };
101
+ requestAnimationFrame(tick);
102
+ return () => {
103
+ cancelled = true;
104
+ };
105
+ }, [isOpen, svgContent, fullscreenRef]);
106
+
107
+ // Re-assert theme text colors on the fullscreen SVG. The shared
108
+ // `getTextColor` reads the (already fully-wrapped) `--foreground`
109
+ // token — it never double-wraps `hsl(...)`.
110
+ useEffect(() => {
111
+ if (isOpen && fullscreenRef.current) {
112
+ applyMermaidTextColors(fullscreenRef.current, getTextColor(theme));
113
+ }
114
+ }, [isOpen, theme, fullscreenRef, svgContent]);
115
+
116
+ // Handle escape key
117
+ useEffect(() => {
118
+ if (!isOpen) return;
119
+
120
+ const handleKeyDown = (e: KeyboardEvent) => {
121
+ if (e.key === 'Escape') onClose();
122
+ };
123
+
124
+ document.addEventListener('keydown', handleKeyDown);
125
+ return () => document.removeEventListener('keydown', handleKeyDown);
126
+ }, [isOpen, onClose]);
127
+
128
+ if (!isOpen || typeof document === 'undefined') return null;
129
+
130
+ // Hoist derived values out of JSX (COMPONENTS.md "Data Preparation
131
+ // Before Render"). Keeps the returned tree pure markup, makes it
132
+ // obvious at the top of the function which inputs feed which
133
+ // node, and surfaces every dependency to a reader at a glance.
134
+ const transformInitialScale = initialScale ?? 1;
135
+ const transformKey = `${openSeq}-${initialScale ?? 'pending'}`;
136
+
137
+ return createPortal(
138
+ <div
139
+ className="fixed inset-0 z-9999 bg-background/95 backdrop-blur-sm"
140
+ onClick={onBackdropClick}
141
+ >
142
+ {/* Close button */}
143
+ <Button
144
+ variant="ghost"
145
+ size="icon"
146
+ className="absolute top-4 right-4 z-10"
147
+ onClick={onClose}
148
+ >
149
+ <X className="h-5 w-5" />
150
+ </Button>
151
+
152
+ {/* Zoomable diagram. `key={openSeq}-${initialScale ?? 'pending'}`
153
+ forces a fresh TransformWrapper:
154
+ - on every modal open (openSeq increments) so the
155
+ re-opened modal never inherits the prior session's
156
+ transform;
157
+ - whenever the auto-fit value lands (null → number)
158
+ so the wrapper, which only reads `initialScale`
159
+ at mount time, picks up the freshly measured fit.
160
+ We can't gate the whole subtree on `initialScale != null`
161
+ because the SVG host (`fullscreenRef` div) lives inside
162
+ TransformComponent — without it in the DOM, the rAF
163
+ measure loop has nothing to read and we'd deadlock at
164
+ null forever. Mounting with placeholder `1` first and
165
+ re-mounting once we know the fit is the cheap fix. */}
166
+ <TransformWrapper
167
+ key={transformKey}
168
+ initialScale={transformInitialScale}
169
+ minScale={0.1}
170
+ maxScale={10}
171
+ centerOnInit
172
+ wheel={{ step: 0.1 }}
173
+ pinch={{ step: 5 }}
174
+ doubleClick={{ mode: 'reset' }}
175
+ >
176
+ <ZoomControls />
177
+ <TransformComponent
178
+ wrapperStyle={{
179
+ width: '100%',
180
+ height: '100%',
181
+ }}
182
+ contentStyle={{
183
+ width: '100%',
184
+ height: '100%',
185
+ display: 'flex',
186
+ alignItems: 'center',
187
+ justifyContent: 'center',
188
+ }}
189
+ >
190
+ <div
191
+ ref={fullscreenRef}
192
+ className="p-8"
193
+ dangerouslySetInnerHTML={{ __html: svgContent }}
194
+ onClick={(e) => e.stopPropagation()}
195
+ />
196
+ </TransformComponent>
197
+ </TransformWrapper>
198
+ </div>,
199
+ document.body
200
+ );
201
+ };
@@ -0,0 +1,4 @@
1
+ export { useMermaidRenderer } from './useMermaidRenderer';
2
+ export { useMermaidValidation } from './useMermaidValidation';
3
+ export { useMermaidCleanup } from './useMermaidCleanup';
4
+ export { useMermaidFullscreen } from './useMermaidFullscreen';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Hook for cleaning up orphaned Mermaid DOM nodes
3
+ */
4
+
5
+ import { useCallback, useEffect } from 'react';
6
+
7
+ export function useMermaidCleanup() {
8
+ const cleanupMermaidErrors = useCallback(() => {
9
+ if (typeof document === 'undefined') return;
10
+
11
+ // Remove all orphaned mermaid elements from body
12
+ // Mermaid can append: SVGs, divs with errors, text nodes
13
+
14
+ // 1. Remove elements with mermaid-* IDs directly in body
15
+ document.querySelectorAll('[id^="mermaid-"]').forEach((node) => {
16
+ if (node.parentNode === document.body) {
17
+ node.remove();
18
+ }
19
+ });
20
+
21
+ // 2. Remove elements with d prefix (mermaid diagram IDs) directly in body
22
+ document.querySelectorAll('[id^="d"]').forEach((node) => {
23
+ if (node.parentNode === document.body && node.id.match(/^d\d+$/)) {
24
+ node.remove();
25
+ }
26
+ });
27
+
28
+ // 3. Remove orphaned SVG elements that mermaid creates in body
29
+ document.querySelectorAll('body > svg').forEach((node) => {
30
+ // Check if it's a mermaid SVG (has mermaid classes or aria-roledescription)
31
+ if (node.getAttribute('aria-roledescription') ||
32
+ node.classList.contains('mermaid') ||
33
+ node.querySelector('.mermaid') ||
34
+ node.id?.includes('mermaid')) {
35
+ node.remove();
36
+ }
37
+ });
38
+
39
+ // 4. Remove any orphaned error divs with "Syntax error" text
40
+ document.querySelectorAll('body > div').forEach((node) => {
41
+ const text = node.textContent || '';
42
+ if (text.includes('Syntax error in text') ||
43
+ text.includes('mermaid version') ||
44
+ node.id?.startsWith('mermaid-') ||
45
+ node.id?.startsWith('d') && node.id.match(/^d\d+$/)) {
46
+ node.remove();
47
+ }
48
+ });
49
+
50
+ // 5. Remove orphaned pre elements with error info
51
+ document.querySelectorAll('body > pre').forEach((node) => {
52
+ const text = node.textContent || '';
53
+ if (text.includes('Syntax error') || text.includes('mermaid')) {
54
+ node.remove();
55
+ }
56
+ });
57
+ }, []);
58
+
59
+ // Cleanup on unmount
60
+ useEffect(() => {
61
+ return () => {
62
+ cleanupMermaidErrors();
63
+ };
64
+ }, [cleanupMermaidErrors]);
65
+
66
+ // Removed periodic cleanup - it causes unnecessary re-renders
67
+ // Cleanup only happens on unmount now
68
+
69
+ return { cleanupMermaidErrors };
70
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Hook for managing Mermaid fullscreen modal
3
+ */
4
+
5
+ import { useEffect, useRef, useState } from 'react';
6
+
7
+ export function useMermaidFullscreen() {
8
+ const [isFullscreen, setIsFullscreen] = useState(false);
9
+ const fullscreenRef = useRef<HTMLDivElement>(null);
10
+
11
+ const openFullscreen = () => setIsFullscreen(true);
12
+ const closeFullscreen = () => setIsFullscreen(false);
13
+
14
+ const handleBackdropClick = (e: React.MouseEvent) => {
15
+ if (e.target === e.currentTarget) {
16
+ closeFullscreen();
17
+ }
18
+ };
19
+
20
+ // Handle ESC key
21
+ useEffect(() => {
22
+ const handleEscKey = (event: KeyboardEvent) => {
23
+ if (event.key === 'Escape' && isFullscreen) {
24
+ closeFullscreen();
25
+ }
26
+ };
27
+
28
+ if (isFullscreen) {
29
+ document.addEventListener('keydown', handleEscKey);
30
+ document.body.style.overflow = 'hidden';
31
+ }
32
+
33
+ return () => {
34
+ document.removeEventListener('keydown', handleEscKey);
35
+ document.body.style.overflow = 'unset';
36
+ };
37
+ }, [isFullscreen]);
38
+
39
+ return {
40
+ isFullscreen,
41
+ fullscreenRef,
42
+ openFullscreen,
43
+ closeFullscreen,
44
+ handleBackdropClick,
45
+ };
46
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Hook for rendering Mermaid diagrams with debounced, race-safe rendering.
3
+ *
4
+ * Mermaid (~800KB) is imported eagerly here because the whole component
5
+ * tree is already lazy-loaded behind `Mermaid.client` — splitting again
6
+ * would just add a second waterfall for no win.
7
+ */
8
+
9
+ import mermaid from 'mermaid';
10
+ import { useEffect, useRef, useState } from 'react';
11
+
12
+ import {
13
+ applyMermaidErRowColors,
14
+ applyMermaidTextColors,
15
+ getThemeColor,
16
+ getTextColor,
17
+ isVerticalDiagram,
18
+ } from '../utils/mermaid-helpers';
19
+ import { useMermaidCleanup } from './useMermaidCleanup';
20
+ import { useMermaidValidation } from './useMermaidValidation';
21
+
22
+ interface UseMermaidRendererProps {
23
+ chart: string;
24
+ theme: string;
25
+ isCompact?: boolean;
26
+ /** Debounce window in ms before (re)rendering. Default 300. */
27
+ debounceMs?: number;
28
+ }
29
+
30
+ interface MermaidRenderResult {
31
+ mermaidRef: React.RefObject<HTMLDivElement | null>;
32
+ svgContent: string;
33
+ isVertical: boolean;
34
+ isRendering: boolean;
35
+ /** Set when the last render failed with a syntax / parse error. */
36
+ error: string | null;
37
+ }
38
+
39
+ /**
40
+ * Section color scales for timeline / journey / mindmap / pie diagrams.
41
+ *
42
+ * These diagram types do NOT use `mainBkg` for their boxes — they cycle
43
+ * through `cScale0..N` (and `pie1..N`) instead. Mermaid's `base` theme
44
+ * derives those scales from `primaryColor`, which lands far too dark in
45
+ * light mode (dark box) while the section label still inherits the
46
+ * default dark `textColor` — i.e. dark text on a dark box.
47
+ *
48
+ * We pin an explicit, theme-aware palette: mid-saturation backgrounds
49
+ * that read on either page background, each paired with an explicit
50
+ * contrasting label color via `cScaleLabel*`. `cScalePeer*` colors the
51
+ * sub-task boxes that sit under a section.
52
+ */
53
+ const SECTION_SCALES = {
54
+ light: [
55
+ { bg: '#1f6f8b', label: '#ffffff', peer: '#3a8ba6' },
56
+ { bg: '#7b5ea7', label: '#ffffff', peer: '#977dc0' },
57
+ { bg: '#2e8b57', label: '#ffffff', peer: '#4caf7d' },
58
+ { bg: '#c25a3a', label: '#ffffff', peer: '#d6805f' },
59
+ { bg: '#3a6ea5', label: '#ffffff', peer: '#5d8cc0' },
60
+ { bg: '#a8456b', label: '#ffffff', peer: '#c06b8b' },
61
+ { bg: '#5f8f3a', label: '#ffffff', peer: '#80aa5d' },
62
+ { bg: '#9a7d2e', label: '#ffffff', peer: '#b89c50' },
63
+ ],
64
+ dark: [
65
+ { bg: '#3aa6c9', label: '#0b1620', peer: '#2b7d99' },
66
+ { bg: '#b39ddb', label: '#1a142b', peer: '#8a72b5' },
67
+ { bg: '#66c990', label: '#0c1f15', peer: '#479a6b' },
68
+ { bg: '#e8956f', label: '#2a1409', peer: '#bd6f4c' },
69
+ { bg: '#79a8d9', label: '#0d1726', peer: '#577fad' },
70
+ { bg: '#d987a8', label: '#2a0f1b', peer: '#ad6082' },
71
+ { bg: '#a4cf7a', label: '#142008', peer: '#7da352' },
72
+ { bg: '#d4bb6e', label: '#241c08', peer: '#a8924c' },
73
+ ],
74
+ } as const;
75
+
76
+ /**
77
+ * Build Mermaid `themeVariables` from our semantic tokens.
78
+ *
79
+ * Tokens are read live from the DOM so the diagram tracks light/dark
80
+ * without a hard-coded palette. Fallbacks only fire during SSR or before
81
+ * stylesheets load.
82
+ */
83
+ function buildThemeVariables(theme: string, fontSize: string) {
84
+ const isDark = theme === 'dark';
85
+ const fg = getThemeColor('--foreground', isDark ? 'hsl(0 0% 98%)' : 'hsl(0 0% 9%)');
86
+ const card = getThemeColor('--card', isDark ? 'hsl(0 0% 8%)' : 'hsl(0 0% 100%)');
87
+ const muted = getThemeColor('--muted', isDark ? 'hsl(0 0% 15%)' : 'hsl(0 0% 96%)');
88
+ const border = getThemeColor('--border', isDark ? 'hsl(0 0% 15%)' : 'hsl(0 0% 90%)');
89
+ const primary = getThemeColor('--primary', isDark ? 'hsl(189 100% 50%)' : 'hsl(192 90% 35%)');
90
+ const accent = getThemeColor('--accent', muted);
91
+ const secondary = getThemeColor('--secondary', muted);
92
+ const background = getThemeColor('--background', isDark ? 'hsl(0 0% 4%)' : 'hsl(0 0% 94%)');
93
+ const destructive = getThemeColor('--destructive', 'hsl(0 84% 60%)');
94
+ const destructiveFg = getThemeColor('--destructive-foreground', 'hsl(0 0% 98%)');
95
+
96
+ const scales = SECTION_SCALES[isDark ? 'dark' : 'light'];
97
+ // `cScale*` / `pie*` / `fillType*` are flat keys (cScale0, pie1, ...).
98
+ //
99
+ // `fillType*` is what the **journey** diagram actually paints its
100
+ // section / task rects with. Left unset, Mermaid derives it by
101
+ // rotating the hue of `cScale*` and forcing odd indexes to
102
+ // `hsl(H, 0%, 9%)` — a near-black box. Pinning `fillType*` to our
103
+ // explicit palette kills the dark-on-dark sections.
104
+ const sectionVars: Record<string, string> = {};
105
+ scales.forEach((s, i) => {
106
+ sectionVars[`cScale${i}`] = s.bg;
107
+ sectionVars[`cScaleLabel${i}`] = s.label;
108
+ sectionVars[`cScaleInv${i}`] = s.label;
109
+ sectionVars[`cScalePeer${i}`] = s.peer;
110
+ sectionVars[`fillType${i}`] = s.bg;
111
+ sectionVars[`surface${i}`] = s.bg;
112
+ // Pie slices are 1-indexed and have no separate label var — the
113
+ // slice text color is global (`pieSectionTextColor`).
114
+ sectionVars[`pie${i + 1}`] = s.bg;
115
+ });
116
+
117
+ return {
118
+ primaryColor: primary,
119
+ primaryTextColor: fg,
120
+ primaryBorderColor: primary,
121
+ secondaryColor: secondary,
122
+ secondaryTextColor: fg,
123
+ secondaryBorderColor: border,
124
+ tertiaryColor: accent,
125
+ tertiaryTextColor: fg,
126
+ tertiaryBorderColor: border,
127
+ mainBkg: card,
128
+ textColor: fg,
129
+ nodeBorder: border,
130
+ nodeTextColor: fg,
131
+ secondBkg: muted,
132
+ lineColor: primary,
133
+ edgeLabelBackground: card,
134
+ clusterBkg: muted,
135
+ clusterBorder: primary,
136
+ background,
137
+ labelBackground: card,
138
+ labelTextColor: fg,
139
+ errorBkgColor: destructive,
140
+ errorTextColor: destructiveFg,
141
+
142
+ // --- ER diagram ---
143
+ // ER attribute-row zebra fills are derived from `mainBkg` by
144
+ // Mermaid and ignore `themeVariables` — they are re-asserted
145
+ // post-render via `applyMermaidErRowColors`.
146
+
147
+ // --- Section-colored diagrams (timeline / journey / mindmap) ---
148
+ ...sectionVars,
149
+ // Timeline / journey section title bars + the global label fallback.
150
+ cScaleLabel0: scales[0]?.label ?? fg,
151
+ // Journey actor faces / labels in the legend.
152
+ actorBkg: card,
153
+ actorBorder: border,
154
+ actorTextColor: fg,
155
+ actorLineColor: border,
156
+ // Mindmap nodes inherit `cScale*`; their text uses `nodeTextColor`
157
+ // on the outer ring — keep it readable on the page background.
158
+
159
+ // --- Pie chart ---
160
+ pieTitleTextColor: fg,
161
+ pieSectionTextColor: isDark ? '#0b1620' : '#ffffff',
162
+ pieSectionTextSize: '13px',
163
+ pieLegendTextColor: fg,
164
+ pieLegendTextSize: '13px',
165
+ pieStrokeColor: background,
166
+ pieStrokeWidth: '2px',
167
+ pieOuterStrokeColor: border,
168
+ pieOuterStrokeWidth: '1px',
169
+ pieOpacity: '1',
170
+
171
+ // --- Git graph ---
172
+ git0: scales[0]?.bg ?? primary,
173
+ git1: scales[1]?.bg ?? secondary,
174
+ git2: scales[2]?.bg ?? accent,
175
+ git3: scales[3]?.bg ?? primary,
176
+ git4: scales[4]?.bg ?? secondary,
177
+ git5: scales[5]?.bg ?? accent,
178
+ git6: scales[6]?.bg ?? primary,
179
+ git7: scales[7]?.bg ?? secondary,
180
+ gitBranchLabel0: scales[0]?.label ?? fg,
181
+ gitBranchLabel1: scales[1]?.label ?? fg,
182
+ gitBranchLabel2: scales[2]?.label ?? fg,
183
+ gitBranchLabel3: scales[3]?.label ?? fg,
184
+ gitBranchLabel4: scales[4]?.label ?? fg,
185
+ gitBranchLabel5: scales[5]?.label ?? fg,
186
+ gitBranchLabel6: scales[6]?.label ?? fg,
187
+ gitBranchLabel7: scales[7]?.label ?? fg,
188
+ gitInv0: scales[0]?.label ?? fg,
189
+ commitLabelColor: fg,
190
+ commitLabelBackground: card,
191
+ tagLabelColor: fg,
192
+ tagLabelBackground: muted,
193
+ tagLabelBorder: border,
194
+
195
+ fontSize,
196
+ fontFamily: 'Inter, system-ui, sans-serif',
197
+ };
198
+ }
199
+
200
+ export function useMermaidRenderer({
201
+ chart,
202
+ theme,
203
+ isCompact = false,
204
+ debounceMs = 300,
205
+ }: UseMermaidRendererProps): MermaidRenderResult {
206
+ const mermaidRef = useRef<HTMLDivElement>(null);
207
+ const renderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
208
+ // Monotonic token: every effect run bumps it, and an in-flight async
209
+ // render checks it before touching the DOM. Guards against a stale
210
+ // chart's `mermaid.render` resolving after a newer one started.
211
+ const renderSeqRef = useRef(0);
212
+
213
+ const [svgContent, setSvgContent] = useState<string>('');
214
+ const [isVertical, setIsVertical] = useState(false);
215
+ const [isRendering, setIsRendering] = useState(false);
216
+ const [error, setError] = useState<string | null>(null);
217
+
218
+ const { isMermaidCodeComplete } = useMermaidValidation();
219
+ const { cleanupMermaidErrors } = useMermaidCleanup();
220
+
221
+ useEffect(() => {
222
+ const seq = ++renderSeqRef.current;
223
+ const isStale = () => seq !== renderSeqRef.current;
224
+
225
+ const fontSize = isCompact ? '12px' : '14px';
226
+
227
+ const renderChart = async () => {
228
+ const host = mermaidRef.current;
229
+ if (!host || !chart) {
230
+ setIsRendering(false);
231
+ return;
232
+ }
233
+
234
+ // Streaming guard: an incomplete diagram (still being typed /
235
+ // streamed) would throw a parse error. Keep the previous SVG
236
+ // visible and just show the spinner — don't flash an error.
237
+ if (!isMermaidCodeComplete(chart)) {
238
+ setIsRendering(true);
239
+ return;
240
+ }
241
+
242
+ setIsRendering(true);
243
+ setError(null);
244
+
245
+ // `mermaid.initialize` is global + idempotent; re-running it
246
+ // per render keeps `themeVariables` in sync with the live
247
+ // light/dark tokens (cheap, no library reload).
248
+ mermaid.initialize({
249
+ startOnLoad: false,
250
+ theme: 'base',
251
+ securityLevel: 'loose',
252
+ suppressErrorRendering: true,
253
+ fontFamily: 'Inter, system-ui, sans-serif',
254
+ flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'basis' },
255
+ themeVariables: buildThemeVariables(theme, fontSize),
256
+ });
257
+
258
+ const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
259
+
260
+ try {
261
+ // `mermaid.parse` validates without mutating the DOM —
262
+ // catches syntax errors before `render` appends anything.
263
+ await mermaid.parse(chart);
264
+ const { svg } = await mermaid.render(id, chart);
265
+
266
+ if (isStale() || !mermaidRef.current) return;
267
+
268
+ const textColor = getTextColor(theme);
269
+ const processedSvg = svg.replace(
270
+ /<svg /,
271
+ `<svg style="--mermaid-text-color: ${textColor};" `,
272
+ );
273
+
274
+ mermaidRef.current.innerHTML = processedSvg;
275
+ applyMermaidTextColors(mermaidRef.current, textColor);
276
+ // ER attribute rows zebra-stripe off `mainBkg` and ignore
277
+ // `themeVariables` — re-assert themed, contrasting fills.
278
+ applyMermaidErRowColors(
279
+ mermaidRef.current,
280
+ getThemeColor('--card', theme === 'dark' ? 'hsl(0 0% 8%)' : 'hsl(0 0% 100%)'),
281
+ getThemeColor('--muted', theme === 'dark' ? 'hsl(0 0% 15%)' : 'hsl(0 0% 96%)'),
282
+ );
283
+
284
+ const svgElement = mermaidRef.current.querySelector('svg');
285
+ if (svgElement) {
286
+ svgElement.style.maxWidth = '100%';
287
+ svgElement.style.height = 'auto';
288
+ svgElement.style.display = 'block';
289
+ setIsVertical(isVerticalDiagram(svgElement));
290
+ }
291
+
292
+ setSvgContent(processedSvg);
293
+ setError(null);
294
+ setIsRendering(false);
295
+ } catch (err) {
296
+ // `render` may still have appended an orphan node to
297
+ // <body> despite `suppressErrorRendering` — sweep it.
298
+ cleanupMermaidErrors();
299
+ if (isStale()) return;
300
+
301
+ const message =
302
+ err instanceof Error ? err.message : 'Failed to render diagram';
303
+ setError(message);
304
+ setSvgContent('');
305
+ setIsVertical(false);
306
+ setIsRendering(false);
307
+ if (mermaidRef.current) {
308
+ mermaidRef.current.innerHTML = '';
309
+ }
310
+ }
311
+ };
312
+
313
+ if (renderTimerRef.current) {
314
+ clearTimeout(renderTimerRef.current);
315
+ }
316
+ renderTimerRef.current = setTimeout(renderChart, debounceMs);
317
+
318
+ return () => {
319
+ // Invalidate any in-flight async render from this effect run.
320
+ renderSeqRef.current++;
321
+ if (renderTimerRef.current) {
322
+ clearTimeout(renderTimerRef.current);
323
+ renderTimerRef.current = null;
324
+ }
325
+ };
326
+ }, [chart, theme, isCompact, debounceMs, isMermaidCodeComplete, cleanupMermaidErrors]);
327
+
328
+ return { mermaidRef, svgContent, isVertical, isRendering, error };
329
+ }