zombie-mermaid 2.2.1 → 2.2.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.
Files changed (251) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +7 -2
  3. package/dist/ascii.cjs +9 -9
  4. package/dist/ascii.cjs.map +1 -1
  5. package/dist/ascii.js +4069 -3969
  6. package/dist/ascii.js.map +1 -1
  7. package/dist/cli.js +7605 -7381
  8. package/dist/index.cjs +52 -52
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.js +7613 -7459
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp.cjs +14142 -13717
  13. package/dist/mcp.cjs.map +1 -1
  14. package/dist/mcp.js +14142 -13717
  15. package/dist/mcp.js.map +1 -1
  16. package/package.json +27 -5
  17. package/packages/ascii-renderer/LICENSE +22 -0
  18. package/{src/ascii → packages/ascii-renderer/src}/class-diagram.ts +277 -57
  19. package/{src/ascii → packages/ascii-renderer/src}/er-diagram.ts +2 -2
  20. package/packages/ascii-renderer/src/flowchart.ts +94 -0
  21. package/{src/ascii → packages/ascii-renderer/src}/grid.ts +23 -0
  22. package/{src/ascii → packages/ascii-renderer/src}/index.ts +13 -84
  23. package/{src/ascii → packages/ascii-renderer/src}/registry.ts +23 -12
  24. package/{src/ascii → packages/ascii-renderer/src}/sequence.ts +55 -8
  25. package/{src/ascii → packages/ascii-renderer/src}/xychart.ts +6 -3
  26. package/packages/core/LICENSE +22 -0
  27. package/packages/core/src/click-directive.ts +106 -0
  28. package/packages/core/src/color-utils.ts +205 -0
  29. package/packages/core/src/diagram-type.ts +35 -0
  30. package/packages/core/src/direction-override.ts +46 -0
  31. package/packages/core/src/direction.ts +56 -0
  32. package/packages/core/src/index.ts +34 -0
  33. package/packages/core/src/init-directive.ts +248 -0
  34. package/packages/core/src/multiline-utils.ts +275 -0
  35. package/packages/core/src/statements.ts +197 -0
  36. package/packages/core/src/style-directives.ts +214 -0
  37. package/packages/core/src/text-metrics.ts +498 -0
  38. package/packages/core/src/theme.ts +696 -0
  39. package/packages/core/src/types.ts +670 -0
  40. package/packages/mcp/LICENSE +22 -0
  41. package/{src/mcp → packages/mcp/src}/server.ts +34 -2
  42. package/{src/mcp → packages/mcp/src}/tools/check-sequence-activations.ts +8 -5
  43. package/packages/mcp/src/tools/fix-sequence-activations.ts +77 -0
  44. package/{src/mcp → packages/mcp/src}/tools/render-ascii.ts +2 -2
  45. package/{src/mcp → packages/mcp/src}/tools/render-svg.ts +1 -1
  46. package/packages/mermaid-parser/LICENSE +22 -0
  47. package/{src → packages/mermaid-parser/src}/class/parser.ts +86 -8
  48. package/{src → packages/mermaid-parser/src}/er/parser.ts +59 -26
  49. package/packages/mermaid-parser/src/index.ts +48 -0
  50. package/{src → packages/mermaid-parser/src}/sequence/activation-check.ts +4 -3
  51. package/packages/mermaid-parser/src/sequence/activation-fix.ts +112 -0
  52. package/{src → packages/mermaid-parser/src}/sequence/parser.ts +74 -16
  53. package/{src → packages/mermaid-parser/src}/xychart/parser.ts +84 -6
  54. package/packages/svg-renderer/LICENSE +22 -0
  55. package/{src → packages/svg-renderer/src}/class/layout.ts +8 -4
  56. package/{src → packages/svg-renderer/src}/class/renderer.ts +7 -6
  57. package/packages/svg-renderer/src/edge-curves.ts +204 -0
  58. package/packages/svg-renderer/src/elk-instance.ts +292 -0
  59. package/{src → packages/svg-renderer/src}/er/layout.ts +6 -4
  60. package/{src → packages/svg-renderer/src}/er/renderer.ts +4 -4
  61. package/packages/svg-renderer/src/index.ts +58 -0
  62. package/packages/svg-renderer/src/layout-engine/constants.ts +19 -0
  63. package/packages/svg-renderer/src/layout-engine/edge-bundling.ts +379 -0
  64. package/packages/svg-renderer/src/layout-engine/elk-adapter-utils.ts +81 -0
  65. package/packages/svg-renderer/src/layout-engine/elk-graph-builder.ts +240 -0
  66. package/packages/svg-renderer/src/layout-engine/from-elk.ts +685 -0
  67. package/packages/svg-renderer/src/layout-engine/layer-alignment.ts +174 -0
  68. package/packages/svg-renderer/src/layout-engine/to-elk.ts +695 -0
  69. package/packages/svg-renderer/src/layout-engine.ts +74 -0
  70. package/packages/svg-renderer/src/layout.ts +8 -0
  71. package/packages/svg-renderer/src/renderer.ts +1485 -0
  72. package/packages/svg-renderer/src/resolve-colors.ts +339 -0
  73. package/{src → packages/svg-renderer/src}/sequence/layout.ts +2 -6
  74. package/{src → packages/svg-renderer/src}/sequence/renderer.ts +4 -4
  75. package/packages/svg-renderer/src/shape-clipping.ts +197 -0
  76. package/packages/svg-renderer/src/styles.ts +118 -0
  77. package/{src → packages/svg-renderer/src}/xychart/layout.ts +2 -2
  78. package/{src → packages/svg-renderer/src}/xychart/renderer.ts +6 -6
  79. package/src/__tests__/ascii-ansi.test.ts +6 -2
  80. package/src/__tests__/ascii-canvas-write.test.ts +2 -2
  81. package/src/__tests__/ascii-charset-border-junctions.test.ts +1 -1
  82. package/src/__tests__/ascii-cjk-width.test.ts +1 -1
  83. package/src/__tests__/ascii-class-box-occupancy.test.ts +147 -45
  84. package/src/__tests__/ascii-class-column-width-488-489.test.ts +1 -1
  85. package/src/__tests__/ascii-class-cross-level-jog-corruption.test.ts +187 -0
  86. package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +1 -1
  87. package/src/__tests__/ascii-class-diagram-compartments.test.ts +1 -1
  88. package/src/__tests__/ascii-class-label-box-collision.test.ts +1 -1
  89. package/src/__tests__/ascii-class-label-row-collision-531.test.ts +1 -1
  90. package/src/__tests__/ascii-class-label-territory-row-awareness.test.ts +1 -1
  91. package/src/__tests__/ascii-class-padding.test.ts +1 -1
  92. package/src/__tests__/ascii-class-reciprocal-relationships-448.test.ts +86 -1
  93. package/src/__tests__/ascii-combining-mark-width.test.ts +2 -2
  94. package/src/__tests__/ascii-coords-overlay.test.ts +2 -2
  95. package/src/__tests__/ascii-decision-lr-box-start.test.ts +2 -2
  96. package/src/__tests__/ascii-display-width-unit.test.ts +1 -1
  97. package/src/__tests__/ascii-draw-arrows-coverage.test.ts +1 -1
  98. package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +12 -5
  99. package/src/__tests__/ascii-draw-bundles.test.ts +5 -5
  100. package/src/__tests__/ascii-edge-bundling-rank-violation-454.test.ts +1 -1
  101. package/src/__tests__/ascii-edge-bundling-routing.test.ts +9 -5
  102. package/src/__tests__/ascii-edge-cross-style-overlap.test.ts +10 -6
  103. package/src/__tests__/ascii-edge-ending-glyphs.test.ts +1 -1
  104. package/src/__tests__/ascii-edge-label-diagonal-fallback-418.test.ts +2 -2
  105. package/src/__tests__/ascii-edge-routing-fixes.test.ts +1 -1
  106. package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +8 -4
  107. package/src/__tests__/ascii-edge-styles.test.ts +1 -1
  108. package/src/__tests__/ascii-emoji-cluster-width.test.ts +2 -2
  109. package/src/__tests__/ascii-er-box-occupancy.test.ts +1 -1
  110. package/src/__tests__/ascii-er-cardinality.test.ts +1 -1
  111. package/src/__tests__/ascii-er-corner-glyphs.test.ts +1 -1
  112. package/src/__tests__/ascii-er-jog-stray-line.test.ts +1 -1
  113. package/src/__tests__/ascii-er-label-padding.test.ts +1 -1
  114. package/src/__tests__/ascii-er-padding.test.ts +1 -1
  115. package/src/__tests__/ascii-er-relationship-label-corruption-350.test.ts +1 -1
  116. package/src/__tests__/ascii-er-relationship-overwrite.test.ts +1 -1
  117. package/src/__tests__/ascii-er-stray-connectors.test.ts +7 -4
  118. package/src/__tests__/ascii-er-unrelated-stem-separation-411.test.ts +1 -1
  119. package/src/__tests__/ascii-er-vertical-one-marker.test.ts +1 -1
  120. package/src/__tests__/ascii-grid-deep-chain.test.ts +4 -4
  121. package/src/__tests__/ascii-hyperlinks.test.ts +9 -6
  122. package/src/__tests__/ascii-label-line-terminal-fallback.test.ts +8 -4
  123. package/src/__tests__/ascii-lane-search.test.ts +1 -1
  124. package/src/__tests__/ascii-multibox-cjk-width.test.ts +1 -1
  125. package/src/__tests__/ascii-multiline.test.ts +6 -3
  126. package/src/__tests__/ascii-nested-subgraph-sibling-order-444.test.ts +8 -5
  127. package/src/__tests__/ascii-package-boundary.test.ts +47 -37
  128. package/src/__tests__/ascii-padding-edge-cases.test.ts +1 -1
  129. package/src/__tests__/ascii-parallel-edges-329.test.ts +8 -5
  130. package/src/__tests__/ascii-pathfinder-route-edge.test.ts +14 -4
  131. package/src/__tests__/ascii-reverse-direction-edges-629.test.ts +9 -5
  132. package/src/__tests__/ascii-sequence-alt-else-label.test.ts +1 -1
  133. package/src/__tests__/ascii-sequence-block-wall-clearance.test.ts +1 -1
  134. package/src/__tests__/ascii-sequence-box-group.test.ts +1 -1
  135. package/src/__tests__/ascii-sequence-cjk-width.test.ts +1 -1
  136. package/src/__tests__/ascii-sequence-create-destroy.test.ts +1 -1
  137. package/src/__tests__/ascii-sequence-form-invariants.test.ts +48 -50
  138. package/src/__tests__/ascii-sequence-mermaid-parity.test.ts +1 -1
  139. package/src/__tests__/ascii-sequence-notes.test.ts +1 -1
  140. package/src/__tests__/ascii-sequence-padding.test.ts +1 -1
  141. package/src/__tests__/ascii-sequence-self-arrow.test.ts +1 -1
  142. package/src/__tests__/ascii-shape-diamond.test.ts +3 -3
  143. package/src/__tests__/ascii-shape-rectangle.test.ts +5 -5
  144. package/src/__tests__/ascii-shape-rounded.test.ts +3 -3
  145. package/src/__tests__/ascii-shapes-circle.test.ts +3 -3
  146. package/src/__tests__/ascii-shapes-hexagon.test.ts +3 -3
  147. package/src/__tests__/ascii-shapes-special.test.ts +10 -5
  148. package/src/__tests__/ascii-shapes-stadium.test.ts +3 -3
  149. package/src/__tests__/ascii-shapes-state.test.ts +15 -5
  150. package/src/__tests__/ascii-state-bidirectional-label-swap-530.test.ts +1 -1
  151. package/src/__tests__/ascii-subgraph-bbox.test.ts +8 -4
  152. package/src/__tests__/ascii-subgraph-direction-honored-445.test.ts +1 -1
  153. package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +88 -25
  154. package/src/__tests__/ascii-subgraph-later-edgeless-301.test.ts +8 -4
  155. package/src/__tests__/ascii-subgraph-title-padding.test.ts +1 -1
  156. package/src/__tests__/ascii-territory-unit.test.ts +2 -2
  157. package/src/__tests__/ascii-validate.test.ts +1 -1
  158. package/src/__tests__/ascii.test.ts +5 -2
  159. package/src/__tests__/box-color.test.ts +1 -1
  160. package/src/__tests__/class-arrow-directions.test.ts +1 -1
  161. package/src/__tests__/class-click-interactions.test.ts +3 -6
  162. package/src/__tests__/class-integration.test.ts +1 -1
  163. package/src/__tests__/class-notes.test.ts +3 -3
  164. package/src/__tests__/class-parser-error-messages.test.ts +162 -0
  165. package/src/__tests__/class-parser.test.ts +6 -6
  166. package/src/__tests__/class-styling.test.ts +2 -2
  167. package/src/__tests__/cli-render.test.ts +16 -1
  168. package/src/__tests__/direction-override.test.ts +1 -1
  169. package/src/__tests__/draw-lines.test.ts +2 -2
  170. package/src/__tests__/edge-cell-styles.test.ts +5 -2
  171. package/src/__tests__/elk-layout-cache.test.ts +6 -4
  172. package/src/__tests__/er-click-unsupported.test.ts +5 -8
  173. package/src/__tests__/er-parser-error-messages.test.ts +82 -0
  174. package/src/__tests__/er-parser.test.ts +3 -6
  175. package/src/__tests__/expanded-shapes.test.ts +1 -1
  176. package/src/__tests__/grid-occupancy.test.ts +1 -1
  177. package/src/__tests__/mcp-check-sequence-activations-tool.test.ts +1 -1
  178. package/src/__tests__/mcp-fix-sequence-activations-tool.test.ts +134 -0
  179. package/src/__tests__/mcp-render-ascii-tool.test.ts +5 -4
  180. package/src/__tests__/mcp-render-svg-theme-guard.test.ts +3 -3
  181. package/src/__tests__/mcp-render-svg-tool.test.ts +1 -1
  182. package/src/__tests__/mcp-server.test.ts +30 -1
  183. package/src/__tests__/multiline-labels.test.ts +29 -18
  184. package/src/__tests__/parser.test.ts +117 -5
  185. package/src/__tests__/pathfinder.test.ts +11 -4
  186. package/src/__tests__/render-options-overrides.test.ts +28 -21
  187. package/src/__tests__/render-options-per-type.test.ts +13 -9
  188. package/src/__tests__/resolve-colors.test.ts +1 -1
  189. package/src/__tests__/self-loop-edges.test.ts +112 -0
  190. package/src/__tests__/sequence-activation-check.test.ts +7 -8
  191. package/src/__tests__/sequence-activation-fix.test.ts +91 -0
  192. package/src/__tests__/sequence-layout.test.ts +4 -7
  193. package/src/__tests__/sequence-parser-syntax-errors.test.ts +179 -0
  194. package/src/__tests__/sequence-parser.test.ts +3 -6
  195. package/src/__tests__/statements.test.ts +53 -35
  196. package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +21 -21
  197. package/src/__tests__/theme-palette-contrast.test.ts +84 -0
  198. package/src/__tests__/workspace-package-boundaries.test.ts +199 -25
  199. package/src/__tests__/xychart-ascii.test.ts +1 -1
  200. package/src/__tests__/xychart-colors.test.ts +3 -2
  201. package/src/__tests__/xychart-layout.test.ts +1 -1
  202. package/src/__tests__/xychart-parser-errors.test.ts +95 -0
  203. package/src/browser.ts +8 -2
  204. package/src/cli/mcp.ts +1 -1
  205. package/src/cli/render.ts +59 -25
  206. package/src/cli/web.ts +1 -1
  207. package/src/diagram-registry.ts +190 -25
  208. package/src/index.ts +34 -131
  209. package/src/parser.ts +102 -40
  210. /package/{src/ascii → packages/ascii-renderer/src}/ansi.ts +0 -0
  211. /package/{src/ascii → packages/ascii-renderer/src}/canvas.ts +0 -0
  212. /package/{src/ascii → packages/ascii-renderer/src}/converter.ts +0 -0
  213. /package/{src/ascii → packages/ascii-renderer/src}/coords.ts +0 -0
  214. /package/{src/ascii → packages/ascii-renderer/src}/display-width.ts +0 -0
  215. /package/{src/ascii → packages/ascii-renderer/src}/draw-arrows.ts +0 -0
  216. /package/{src/ascii → packages/ascii-renderer/src}/draw-boxes.ts +0 -0
  217. /package/{src/ascii → packages/ascii-renderer/src}/draw-bundles.ts +0 -0
  218. /package/{src/ascii → packages/ascii-renderer/src}/draw-lines.ts +0 -0
  219. /package/{src/ascii → packages/ascii-renderer/src}/draw-subgraphs.ts +0 -0
  220. /package/{src/ascii → packages/ascii-renderer/src}/draw.ts +0 -0
  221. /package/{src/ascii → packages/ascii-renderer/src}/edge-bundling.ts +0 -0
  222. /package/{src/ascii → packages/ascii-renderer/src}/edge-cell-styles.ts +0 -0
  223. /package/{src/ascii → packages/ascii-renderer/src}/edge-routing.ts +0 -0
  224. /package/{src/ascii → packages/ascii-renderer/src}/grid-occupancy.ts +0 -0
  225. /package/{src/ascii → packages/ascii-renderer/src}/hyperlinks.ts +0 -0
  226. /package/{src/ascii → packages/ascii-renderer/src}/lane-search.ts +0 -0
  227. /package/{src/ascii → packages/ascii-renderer/src}/multiline-utils.ts +0 -0
  228. /package/{src/ascii → packages/ascii-renderer/src}/pathfinder.ts +0 -0
  229. /package/{src/ascii → packages/ascii-renderer/src}/shapes/circle.ts +0 -0
  230. /package/{src/ascii → packages/ascii-renderer/src}/shapes/corners.ts +0 -0
  231. /package/{src/ascii → packages/ascii-renderer/src}/shapes/diamond.ts +0 -0
  232. /package/{src/ascii → packages/ascii-renderer/src}/shapes/hexagon.ts +0 -0
  233. /package/{src/ascii → packages/ascii-renderer/src}/shapes/index.ts +0 -0
  234. /package/{src/ascii → packages/ascii-renderer/src}/shapes/rectangle.ts +0 -0
  235. /package/{src/ascii → packages/ascii-renderer/src}/shapes/rounded.ts +0 -0
  236. /package/{src/ascii → packages/ascii-renderer/src}/shapes/special.ts +0 -0
  237. /package/{src/ascii → packages/ascii-renderer/src}/shapes/stadium.ts +0 -0
  238. /package/{src/ascii → packages/ascii-renderer/src}/shapes/state.ts +0 -0
  239. /package/{src/ascii → packages/ascii-renderer/src}/shapes/types.ts +0 -0
  240. /package/{src/ascii → packages/ascii-renderer/src}/territory.ts +0 -0
  241. /package/{src/ascii → packages/ascii-renderer/src}/types.ts +0 -0
  242. /package/{src/ascii → packages/ascii-renderer/src}/validate.ts +0 -0
  243. /package/{src/mcp → packages/mcp/src}/index.ts +0 -0
  244. /package/{src → packages/mermaid-parser/src}/class/format.ts +0 -0
  245. /package/{src → packages/mermaid-parser/src}/class/types.ts +0 -0
  246. /package/{src → packages/mermaid-parser/src}/er/types.ts +0 -0
  247. /package/{src → packages/mermaid-parser/src}/expanded-shapes.ts +0 -0
  248. /package/{src → packages/mermaid-parser/src}/sequence/box-color.ts +0 -0
  249. /package/{src → packages/mermaid-parser/src}/sequence/types.ts +0 -0
  250. /package/{src → packages/mermaid-parser/src}/xychart/colors.ts +0 -0
  251. /package/{src → packages/mermaid-parser/src}/xychart/types.ts +0 -0
@@ -0,0 +1,696 @@
1
+ // ============================================================================
2
+ // Theme system — CSS custom property-based theming for mermaid SVG diagrams.
3
+ //
4
+ // Architecture:
5
+ // - Two required variables: --bg (background) and --fg (foreground)
6
+ // - Five optional enrichment variables: --line, --accent, --muted, --surface, --border
7
+ // - Unset optionals fall back to color-mix() derivations from bg + fg
8
+ // - All derived values computed in a <style> block inside the SVG
9
+ //
10
+ // This means the SVG is a function of its CSS variables. The caller provides
11
+ // colors, and the SVG adapts. No light/dark mode detection needed.
12
+ // ============================================================================
13
+
14
+ import { escapeXml, escapeAttr } from './multiline-utils.ts'
15
+ import { parseHexColor } from './color-utils.ts'
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ /**
22
+ * How the renderers emit their two inline-style surfaces — the `<style>`
23
+ * element(s) and the root `<svg style="--bg: …">` attribute. Both exist so a
24
+ * host page with a strict `Content-Security-Policy` (`style-src` without
25
+ * `'unsafe-inline'`) can still render diagrams with their colours intact;
26
+ * see `RenderOptions.nonce` / `RenderOptions.styleAttribute` in packages/core/src/types.ts
27
+ * and GitHub issue #216. Threaded as one object through every diagram
28
+ * renderer so the two options can't drift apart per diagram type.
29
+ */
30
+ export interface SvgEmitOptions {
31
+ /** Value for a `nonce` attribute on every emitted `<style>` element. */
32
+ nonce?: string
33
+ /** Emit the root `style="--bg: …"` attribute. Default: true. */
34
+ styleAttribute?: boolean
35
+ }
36
+
37
+ /**
38
+ * Diagram color configuration.
39
+ *
40
+ * Required: bg + fg give you a clean mono diagram.
41
+ * Optional: line, accent, muted, surface, border bring in richer color
42
+ * from Shiki themes or custom palettes. Each falls back to a color-mix()
43
+ * derivation from bg + fg if not set.
44
+ */
45
+ export interface DiagramColors {
46
+ /** Background color → CSS variable --bg */
47
+ bg: string
48
+ /** Foreground / primary text color → CSS variable --fg */
49
+ fg: string
50
+
51
+ // -- Optional enrichment (each falls back to color-mix from bg+fg) --
52
+
53
+ /** Edge/connector color → CSS variable --line */
54
+ line?: string
55
+ /** Arrow heads, highlights, special nodes → CSS variable --accent */
56
+ accent?: string
57
+ /** Secondary text, edge labels → CSS variable --muted */
58
+ muted?: string
59
+ /** Node/box fill tint → CSS variable --surface */
60
+ surface?: string
61
+ /** Node/group stroke color → CSS variable --border */
62
+ border?: string
63
+ }
64
+
65
+ // ============================================================================
66
+ // Defaults
67
+ // ============================================================================
68
+
69
+ /** Default bg/fg when no colors are provided (zinc light) */
70
+ export const DEFAULTS: Readonly<{ bg: string; fg: string }> = {
71
+ bg: '#FFFFFF',
72
+ fg: '#27272A',
73
+ } as const
74
+
75
+ // ============================================================================
76
+ // color-mix() weights for derived CSS variables
77
+ //
78
+ // When an optional enrichment variable is NOT set, we compute the derived
79
+ // value by mixing --fg into --bg at these percentages. This produces a
80
+ // coherent mono hierarchy on any bg/fg combination.
81
+ // ============================================================================
82
+
83
+ export const MIX = {
84
+ /** Primary text: near-full fg */
85
+ text: 100, // just use --fg directly
86
+ /** Secondary text (group headers): fg mixed at 60% */
87
+ textSec: 60,
88
+ /** Muted text (edge labels, notes): fg mixed at 40% */
89
+ textMuted: 40,
90
+ /** Faint text (de-emphasized): fg mixed at 25% */
91
+ textFaint: 25,
92
+ /** Edge/connector lines: fg mixed at 50% for clear visibility */
93
+ line: 50,
94
+ /** Arrow head fill: fg mixed at 85% for clear visibility */
95
+ arrow: 85,
96
+ /** Node fill tint: fg mixed at 3% */
97
+ nodeFill: 3,
98
+ /** Node/group stroke: fg mixed at 20% */
99
+ nodeStroke: 20,
100
+ /** Group header band tint: fg mixed at 5% */
101
+ groupHeader: 5,
102
+ /** Inner divider strokes: fg mixed at 12% */
103
+ innerStroke: 12,
104
+ /** Key badge background opacity (ER diagrams) */
105
+ keyBadge: 10,
106
+ } as const
107
+
108
+ // ============================================================================
109
+ // Well-known theme palettes
110
+ //
111
+ // Curated bg/fg pairs (+ optional enrichment) for popular editor themes.
112
+ // Users can also extract from Shiki theme objects via fromShikiTheme().
113
+ // ============================================================================
114
+
115
+ export const THEMES: Record<string, DiagramColors> = {
116
+ 'zinc-light': {
117
+ bg: '#FFFFFF',
118
+ fg: '#27272A',
119
+ },
120
+ 'zinc-dark': {
121
+ bg: '#18181B',
122
+ fg: '#FAFAFA',
123
+ },
124
+ 'tokyo-night': {
125
+ bg: '#1a1b26',
126
+ fg: '#a9b1d6',
127
+ line: '#3d59a1',
128
+ accent: '#7aa2f7',
129
+ muted: '#565f89',
130
+ },
131
+ 'tokyo-night-storm': {
132
+ bg: '#24283b',
133
+ fg: '#a9b1d6',
134
+ line: '#3d59a1',
135
+ accent: '#7aa2f7',
136
+ muted: '#565f89',
137
+ },
138
+ 'tokyo-night-light': {
139
+ bg: '#d5d6db',
140
+ fg: '#343b58',
141
+ line: '#34548a',
142
+ accent: '#34548a',
143
+ muted: '#9699a3',
144
+ },
145
+ 'catppuccin-mocha': {
146
+ bg: '#1e1e2e',
147
+ fg: '#cdd6f4',
148
+ line: '#585b70',
149
+ accent: '#cba6f7',
150
+ muted: '#6c7086',
151
+ },
152
+ 'catppuccin-latte': {
153
+ bg: '#eff1f5',
154
+ fg: '#4c4f69',
155
+ line: '#9ca0b0',
156
+ accent: '#8839ef',
157
+ muted: '#9ca0b0',
158
+ },
159
+ nord: {
160
+ bg: '#2e3440',
161
+ fg: '#d8dee9',
162
+ line: '#4c566a',
163
+ accent: '#88c0d0',
164
+ muted: '#616e88',
165
+ },
166
+ 'nord-light': {
167
+ bg: '#eceff4',
168
+ fg: '#2e3440',
169
+ line: '#aab1c0',
170
+ accent: '#5e81ac',
171
+ muted: '#7b88a1',
172
+ },
173
+ dracula: {
174
+ bg: '#282a36',
175
+ fg: '#f8f8f2',
176
+ line: '#6272a4',
177
+ accent: '#bd93f9',
178
+ muted: '#6272a4',
179
+ },
180
+ 'github-light': {
181
+ bg: '#ffffff',
182
+ fg: '#1f2328',
183
+ line: '#d1d9e0',
184
+ accent: '#0969da',
185
+ muted: '#59636e',
186
+ },
187
+ 'github-dark': {
188
+ bg: '#0d1117',
189
+ fg: '#e6edf3',
190
+ line: '#3d444d',
191
+ accent: '#4493f8',
192
+ muted: '#9198a1',
193
+ },
194
+ 'solarized-light': {
195
+ bg: '#fdf6e3',
196
+ fg: '#657b83',
197
+ line: '#93a1a1',
198
+ accent: '#268bd2',
199
+ muted: '#93a1a1',
200
+ },
201
+ 'solarized-dark': {
202
+ bg: '#002b36',
203
+ fg: '#839496',
204
+ line: '#586e75',
205
+ accent: '#268bd2',
206
+ muted: '#586e75',
207
+ },
208
+ 'one-dark': {
209
+ bg: '#282c34',
210
+ fg: '#abb2bf',
211
+ line: '#4b5263',
212
+ accent: '#c678dd',
213
+ muted: '#5c6370',
214
+ },
215
+ } as const
216
+
217
+ export type ThemeName = keyof typeof THEMES
218
+
219
+ // ============================================================================
220
+ // Shiki theme extraction
221
+ //
222
+ // Extracts DiagramColors from a Shiki ThemeRegistrationResolved object.
223
+ // This provides native compatibility with any VS Code / TextMate theme.
224
+ // ============================================================================
225
+
226
+ /**
227
+ * Minimal subset of Shiki's ThemeRegistrationResolved that we need.
228
+ * We don't import from shiki to avoid a hard dependency.
229
+ */
230
+ interface ShikiThemeLike {
231
+ type?: string
232
+ colors?: Record<string, string>
233
+ tokenColors?: Array<{
234
+ scope?: string | string[]
235
+ settings?: { foreground?: string }
236
+ }>
237
+ }
238
+
239
+ /**
240
+ * Extract diagram colors from a Shiki theme object.
241
+ * Works with any VS Code / TextMate theme loaded by Shiki.
242
+ *
243
+ * Maps editor UI colors to diagram roles:
244
+ * editor.background → bg
245
+ * editor.foreground → fg
246
+ * editorLineNumber.fg → line (optional)
247
+ * focusBorder / keyword → accent (optional)
248
+ * comment token → muted (optional)
249
+ * editor.selectionBackground→ surface (optional)
250
+ * editorWidget.border → border (optional)
251
+ *
252
+ * @example
253
+ * ```ts
254
+ * import { getSingletonHighlighter } from 'shiki'
255
+ * import { fromShikiTheme } from 'zombie-mermaid'
256
+ *
257
+ * const hl = await getSingletonHighlighter({ themes: ['tokyo-night'] })
258
+ * const colors = fromShikiTheme(hl.getTheme('tokyo-night'))
259
+ * const svg = renderMermaidSVG(code, colors)
260
+ * ```
261
+ */
262
+ export function fromShikiTheme(theme: ShikiThemeLike): DiagramColors {
263
+ const c = theme.colors ?? {}
264
+ const dark = theme.type === 'dark'
265
+
266
+ // Helper: find a token color by scope name
267
+ const tokenColor = (scope: string): string | undefined =>
268
+ theme.tokenColors?.find((t) =>
269
+ Array.isArray(t.scope) ? t.scope.includes(scope) : t.scope === scope,
270
+ )?.settings?.foreground
271
+
272
+ return {
273
+ bg: c['editor.background'] ?? (dark ? '#1e1e1e' : '#ffffff'),
274
+ fg: c['editor.foreground'] ?? (dark ? '#d4d4d4' : '#333333'),
275
+ line: c['editorLineNumber.foreground'] ?? undefined,
276
+ accent: c['focusBorder'] ?? tokenColor('keyword') ?? undefined,
277
+ muted:
278
+ tokenColor('comment') ?? c['editorLineNumber.foreground'] ?? undefined,
279
+ surface: c['editor.selectionBackground'] ?? undefined,
280
+ border: c['editorWidget.border'] ?? undefined,
281
+ }
282
+ }
283
+
284
+ // ============================================================================
285
+ // SVG style block — the CSS variable derivation system
286
+ //
287
+ // Generates the <style> content that maps user-facing variables (--bg, --fg,
288
+ // --line, etc.) to internal derived variables (--_text, --_line, etc.) using
289
+ // color-mix() fallbacks.
290
+ // ============================================================================
291
+
292
+ /**
293
+ * Characters that must never appear in a `font` value once it's embedded in
294
+ * the generated `<style>` block — whether it's treated as a CSS `var()`
295
+ * reference or as a literal font name. Angle brackets would let the value
296
+ * break out of the `<style>...</style>` element (e.g. a literal `</style>`
297
+ * or an injected `<script>`); semicolons/braces would let it terminate the
298
+ * current CSS declaration/rule and inject new ones.
299
+ */
300
+ const UNSAFE_FONT_CHARS_RE = /[<>{};]/
301
+
302
+ /**
303
+ * Detect a syntactically well-formed CSS `var()` reference, e.g.
304
+ * `var(--font-family-body)` or `var(--font, 'Fallback Font')` (a `var()`
305
+ * with a quoted fallback argument). Requires balanced parens and rejects
306
+ * any of `UNSAFE_FONT_CHARS_RE`.
307
+ *
308
+ * `font.startsWith('var(')` alone isn't enough: a caller could pass
309
+ * something that merely starts with `var(` but isn't actually one (e.g.
310
+ * `var(--x); } .evil{...}`). Anything that fails this stricter check is
311
+ * treated as an untrusted/malformed value and falls back to being quoted
312
+ * as a literal font name below — which neutralizes it, since a quoted
313
+ * string isn't parsed as a `var()` call by CSS.
314
+ */
315
+ function isSafeCssVarReference(font: string): boolean {
316
+ const trimmed = font.trim()
317
+ if (!trimmed.startsWith('var(') || !trimmed.endsWith(')')) return false
318
+ if (UNSAFE_FONT_CHARS_RE.test(trimmed)) return false
319
+ let depth = 0
320
+ for (const ch of trimmed) {
321
+ if (ch === '(') depth++
322
+ else if (ch === ')') {
323
+ depth--
324
+ if (depth < 0) return false
325
+ }
326
+ }
327
+ return depth === 0
328
+ }
329
+
330
+ /**
331
+ * CSS generic font-family keywords. These are resolved by the browser/user
332
+ * agent to a locally available font and are never real font names a Google
333
+ * Fonts `@import` could fetch.
334
+ */
335
+ const GENERIC_FONT_FAMILIES = new Set([
336
+ 'sans-serif',
337
+ 'serif',
338
+ 'monospace',
339
+ 'system-ui',
340
+ 'ui-sans-serif',
341
+ 'ui-serif',
342
+ 'ui-monospace',
343
+ 'ui-rounded',
344
+ 'cursive',
345
+ 'fantasy',
346
+ 'math',
347
+ 'emoji',
348
+ 'fangsong',
349
+ ])
350
+
351
+ /**
352
+ * Detect a `font` value that names a font *stack* (comma-separated list,
353
+ * e.g. `"ui-sans-serif, system-ui, sans-serif"`) or a single CSS generic
354
+ * family keyword (e.g. `"system-ui"`) rather than a single concrete font
355
+ * name. Neither is something a Google Fonts `@import` could ever
356
+ * successfully fetch: a stack has no single `family=` value to request, and
357
+ * a generic keyword is resolved locally by the browser, not hosted by
358
+ * Google Fonts. Skipping the `@import` for these avoids baking a dead,
359
+ * always-404 request into the SVG (see #223).
360
+ */
361
+ function isFontStackOrGenericFamily(font: string): boolean {
362
+ const trimmed = font.trim()
363
+ if (trimmed.includes(',')) return true
364
+ return GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())
365
+ }
366
+
367
+ /**
368
+ * The opening `<style>` tag every renderer uses — with a `nonce` attribute
369
+ * when the caller supplied one (see `RenderOptions.nonce`, #216).
370
+ *
371
+ * All `<style>` emission points go through this one function so a nonce
372
+ * can't be missed on one of them: under a nonce-based CSP a single
373
+ * un-nonced `<style>` is silently dropped by the browser, which for the
374
+ * theme block means an unstyled diagram with no console hint as to why.
375
+ *
376
+ * An empty/whitespace-only nonce is treated as unset — `nonce=""` would
377
+ * authorise nothing and only mislead a reader into thinking it did.
378
+ * The value is attribute-escaped; a real nonce is base64 and never needs
379
+ * escaping, but the option is a plain string and this keeps a stray `"`
380
+ * from breaking out of the attribute.
381
+ */
382
+ export function styleOpenTag(nonce?: string): string {
383
+ if (nonce === undefined || nonce.trim() === '') return '<style>'
384
+ return `<style nonce="${escapeAttr(nonce)}">`
385
+ }
386
+
387
+ /**
388
+ * Build the CSS variable derivation rules for the SVG <style> block.
389
+ *
390
+ * When an optional variable (--line, --accent, etc.) is set on the SVG or
391
+ * a parent element, it's used directly. When unset, the fallback computes
392
+ * a blended value from --fg and --bg using color-mix().
393
+ *
394
+ * `nonce`, when given, lands on the `<style>` element as a `nonce`
395
+ * attribute — see `styleOpenTag()`.
396
+ *
397
+ * `font` is user-supplied input. When it's a CSS `var(...)` reference (e.g.
398
+ * `var(--font-family-body)`, letting the SVG inherit a host page's
399
+ * design-system font), the Google Fonts `@import` is skipped — it would
400
+ * otherwise URL-encode the literal `var(...)` string into a `family=` query
401
+ * param and produce a guaranteed no-op request — and the value is emitted
402
+ * unquoted, since a quoted string isn't parsed as a `var()` call by CSS.
403
+ *
404
+ * The `@import` is likewise skipped when `font` is a font *stack* (a
405
+ * comma-separated list, e.g. `"ui-sans-serif, system-ui, sans-serif"`) or a
406
+ * single CSS generic family keyword (e.g. `"system-ui"`) — see
407
+ * `isFontStackOrGenericFamily`. Neither names a single concrete font that
408
+ * Google Fonts could host, so importing one always produces a dead,
409
+ * always-404 request (#223). This is additive to the `var()` skip above,
410
+ * and only affects the `@import` decision — the `text { font-family: ... }`
411
+ * rule still renders the literal value exactly as it does today.
412
+ *
413
+ * Any other value is treated as a literal font name: sanitized and quoted
414
+ * exactly as before.
415
+ */
416
+ export function buildStyleBlock(
417
+ font: string,
418
+ hasMonoFont: boolean,
419
+ nonce?: string,
420
+ ): string {
421
+ const isVarReference = isSafeCssVarReference(font)
422
+ // Literal font names are sanitized before quoting (strip characters that
423
+ // could break out of the CSS string or the <style> block). A validated
424
+ // var() reference is passed through as-is so `var(--x, 'Fallback')` keeps
425
+ // its own internal quotes intact.
426
+ const safeFont = isVarReference ? font.trim() : font.replace(/[<>{};'"]/g, '')
427
+ const skipImport = isVarReference || isFontStackOrGenericFamily(font)
428
+
429
+ const fontImports = [
430
+ ...(skipImport
431
+ ? []
432
+ : [
433
+ `@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(safeFont)}:wght@400;500;600;700&amp;display=swap');`,
434
+ ]),
435
+ ...(hasMonoFont
436
+ ? [
437
+ `@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&amp;display=swap');`,
438
+ ]
439
+ : []),
440
+ ]
441
+
442
+ // Derived CSS variables: use override if set, else mix from bg+fg.
443
+ // The --_ prefix signals "private/derived" — not meant for external override.
444
+ const derivedVars = `
445
+ /* Derived from --bg and --fg (overridable via --line, --accent, etc.) */
446
+ --_text: var(--fg);
447
+ --_text-sec: var(--muted, color-mix(in srgb, var(--fg) ${MIX.textSec}%, var(--bg)));
448
+ --_text-muted: var(--muted, color-mix(in srgb, var(--fg) ${MIX.textMuted}%, var(--bg)));
449
+ --_text-faint: color-mix(in srgb, var(--fg) ${MIX.textFaint}%, var(--bg));
450
+ --_line: var(--line, color-mix(in srgb, var(--fg) ${MIX.line}%, var(--bg)));
451
+ --_arrow: var(--accent, color-mix(in srgb, var(--fg) ${MIX.arrow}%, var(--bg)));
452
+ --_node-fill: var(--surface, color-mix(in srgb, var(--fg) ${MIX.nodeFill}%, var(--bg)));
453
+ --_node-stroke: var(--border, color-mix(in srgb, var(--fg) ${MIX.nodeStroke}%, var(--bg)));
454
+ --_group-fill: var(--bg);
455
+ --_group-hdr: color-mix(in srgb, var(--fg) ${MIX.groupHeader}%, var(--bg));
456
+ --_inner-stroke: color-mix(in srgb, var(--fg) ${MIX.innerStroke}%, var(--bg));
457
+ --_key-badge: color-mix(in srgb, var(--fg) ${MIX.keyBadge}%, var(--bg));`
458
+
459
+ return [
460
+ styleOpenTag(nonce),
461
+ ...(fontImports.length > 0 ? [` ${fontImports.join('\n ')}`] : []),
462
+ ` text { font-family: ${isVarReference ? safeFont : `'${safeFont}'`}, system-ui, sans-serif; }`,
463
+ ...(hasMonoFont
464
+ ? [
465
+ ` .mono { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; }`,
466
+ ]
467
+ : []),
468
+ ` svg {${derivedVars}`,
469
+ ` }`,
470
+ '</style>',
471
+ ].join('\n')
472
+ }
473
+
474
+ // ============================================================================
475
+ // Fill → text color contrast
476
+ //
477
+ // When a node has a custom `fill` (from classDef/style) but no explicit
478
+ // `color`, defaulting text to the ambient theme foreground can be unreadable
479
+ // (e.g. white text on a light pastel fill in dark mode). If the fill is a
480
+ // concrete, resolvable color (hex), compute a readable black/white text
481
+ // color from its perceptual luminance. Anything else (CSS variable
482
+ // references, named colors, malformed values) is left alone — the caller
483
+ // falls back to the theme foreground.
484
+ // ============================================================================
485
+
486
+ /**
487
+ * Perceptual luminance of an sRGB color on a 0-1 scale (0 = black, 1 =
488
+ * white), using the ITU-R BT.601 luma weights (0.299/0.587/0.114).
489
+ *
490
+ * This is the simple, non-gamma-corrected "perceived brightness" formula
491
+ * (as opposed to the gamma-corrected WCAG relative-luminance formula used
492
+ * for contrast-ratio math). It's the right tool here: we just need a
493
+ * black-vs-white pick, and BT.601 luma crosses black/white legibility at
494
+ * ~0.5 on its own 0-1 scale, so the threshold in getReadableTextColor()
495
+ * lines up directly with the formula instead of needing a separately
496
+ * derived crossover constant. (True WCAG relative luminance would need a
497
+ * ~0.18 threshold, not 0.5, to give the same black/white picks — see
498
+ * https://www.w3.org/TR/WCAG20/#relativeluminancedef.)
499
+ */
500
+ function perceptualLuminance(r: number, g: number, b: number): number {
501
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255
502
+ }
503
+
504
+ /**
505
+ * Pick a readable text color for a given fill color.
506
+ *
507
+ * If `fill` is a concrete, resolvable hex color, returns pure black or
508
+ * white depending on the fill's perceptual luminance (threshold 0.5: light
509
+ * fills get dark text, dark fills get light text). If `fill` is undefined
510
+ * or isn't a resolvable color (a `var(...)` reference, a named CSS color,
511
+ * or malformed input), returns `fallback` unchanged so callers don't guess
512
+ * at colors they can't actually evaluate.
513
+ */
514
+ export function getReadableTextColor(
515
+ fill: string | undefined,
516
+ fallback: string,
517
+ ): string {
518
+ if (!fill) return fallback
519
+ const rgb = parseHexColor(fill)
520
+ if (!rgb) return fallback
521
+ const luminance = perceptualLuminance(...rgb)
522
+ return luminance > 0.5 ? '#000000' : '#FFFFFF'
523
+ }
524
+
525
+ /**
526
+ * Page-unique suffix generator for the root accessible-name `<title>` id.
527
+ *
528
+ * Mirrors `markerSuffix()` in packages/svg-renderer/src/renderer.ts, which solves the same
529
+ * "multiple SVGs inlined into one HTML page share a single id namespace"
530
+ * problem for arrow-marker ids — a marker's id is sanitized from its stroke
531
+ * color, since two markers with the same color are meant to share one
532
+ * definition. A title has no such natural per-call input (two diagrams can
533
+ * easily have the same title text and must still get distinct ids), so this
534
+ * increments a module-level counter instead: every `svgOpenTag()` call that
535
+ * renders a title gets a fresh, never-repeated id for the lifetime of the
536
+ * process, which is exactly what's needed for `aria-labelledby` to resolve
537
+ * correctly when several rendered diagrams are concatenated into one page.
538
+ */
539
+ let titleIdCounter = 0
540
+
541
+ function nextTitleId(): string {
542
+ titleIdCounter += 1
543
+ return `zm-title-${titleIdCounter}`
544
+ }
545
+
546
+ /**
547
+ * Test-only: reset the title-id counter so id assertions in tests don't
548
+ * depend on how many titled diagrams earlier tests in the same file rendered.
549
+ * Not part of the public API (not re-exported from src/index.ts).
550
+ */
551
+ export function __resetSvgTitleIdCounterForTests(): void {
552
+ titleIdCounter = 0
553
+ }
554
+
555
+ /**
556
+ * The exact CSS declaration list the root `<svg style="…">` attribute
557
+ * carries: the theme custom properties (`--bg`, `--fg`, plus whichever
558
+ * enrichment variables were actually provided — unset ones fall back to the
559
+ * color-mix() derivations in the `<style>` block) and, unless `transparent`,
560
+ * a `background: var(--bg)`.
561
+ *
562
+ * Exposed (via `themeCssVariables()` in src/index.ts) so a host that turns
563
+ * the attribute off with `styleAttribute: false` — because its
564
+ * Content-Security-Policy forbids `style=` attributes, which a nonce can
565
+ * never authorise (#216) — can put the very same declarations in its own
566
+ * stylesheet on a wrapper instead. Single source of truth for the variable
567
+ * list: `svgOpenTag()` and the helper both call this, so the two can't
568
+ * drift.
569
+ *
570
+ * Emitted compactly (`--bg:#fff;--fg:#000;background:var(--bg)`) rather
571
+ * than pretty-printed: it's the same string the attribute has always
572
+ * carried, so default output stays byte-identical. Colour values are the
573
+ * caller's own `RenderOptions` and are not escaped here — same as before.
574
+ */
575
+ export function themeStyleDeclarations(
576
+ colors: DiagramColors,
577
+ transparent?: boolean,
578
+ ): string {
579
+ const vars = [
580
+ `--bg:${colors.bg}`,
581
+ `--fg:${colors.fg}`,
582
+ colors.line ? `--line:${colors.line}` : '',
583
+ colors.accent ? `--accent:${colors.accent}` : '',
584
+ colors.muted ? `--muted:${colors.muted}` : '',
585
+ colors.surface ? `--surface:${colors.surface}` : '',
586
+ colors.border ? `--border:${colors.border}` : '',
587
+ ]
588
+ .filter(Boolean)
589
+ .join(';')
590
+
591
+ const bgStyle = transparent ? '' : ';background:var(--bg)'
592
+ return `${vars}${bgStyle}`
593
+ }
594
+
595
+ /**
596
+ * Build the SVG opening tag with CSS variables set as inline styles.
597
+ * Only includes optional variables that are actually provided — unset ones
598
+ * will fall back to the color-mix() derivations in the <style> block.
599
+ *
600
+ * `styleAttribute: false` drops the `style="…"` attribute entirely (the
601
+ * host supplies those declarations from its own stylesheet — see
602
+ * `themeStyleDeclarations()` and `RenderOptions.styleAttribute`, #216).
603
+ * Every other attribute — `xmlns`, `viewBox`, `width`/`height`, the
604
+ * `role`/`aria-*` accessibility attributes, and anything a caller splices
605
+ * on afterwards (`data-src`, `data-xychart-colors`) — is unaffected.
606
+ *
607
+ * Also handles the SVG's accessible name (see `RenderOptions.title` /
608
+ * `RenderOptions.decorative` in packages/core/src/types.ts, and GitHub issue #215):
609
+ *
610
+ * - `decorative: true` → `aria-hidden="true"` on the root, no `role` or
611
+ * name. Use for a diagram already described in surrounding prose; `title`
612
+ * is ignored when this is set.
613
+ * - `title` given (and not decorative) → `role="img"` +
614
+ * `aria-labelledby="zm-title-N"` on the root, plus a `<title id="zm-title-N">`
615
+ * as the SVG's first child holding the escaped text. This is the standard
616
+ * SVG/WAI-ARIA "accessible name via a referenced title element" technique
617
+ * (see MDN "SVG accessibility" and the WAI-ARIA `img` role: an element
618
+ * with `role="img"` needs a computed accessible name to be meaningful to
619
+ * assistive tech; `aria-labelledby` pointing at a `<title>` is the
620
+ * documented way to supply one for inline SVG).
621
+ * - Neither given → `role="img"` only, no name. This still stops assistive
622
+ * tech from treating the SVG as a plain group (which announces every
623
+ * node/edge label individually, out of reading order — the core bug in
624
+ * #215), without fabricating a name the library can't honestly claim. It
625
+ * reads the same as an `<img>` with no `alt`: present as a single image,
626
+ * unlabeled.
627
+ *
628
+ * `hasInteractiveLinks` overrides all of the above (see #239): a `click A
629
+ * "url"` statement renders a real, focusable `<a href>` inside the SVG, and
630
+ * both `role="img"` and `aria-hidden="true"` are unsafe on an ancestor of a
631
+ * focusable element — `role="img"` tells assistive tech to stop descending
632
+ * into children (the link becomes unreachable by screen reader while still
633
+ * Tab-focusable), and `aria-hidden="true"` on a focusable descendant is an
634
+ * explicit WAI-ARIA violation (the link vanishes from the accessibility tree
635
+ * but not from the tab order). When any node has a link, the root gets no
636
+ * `role` at all — `decorative` is silently overridden rather than honored,
637
+ * since aria-hiding a diagram that contains an actionable link is not a safe
638
+ * request to fulfill — but `title`/`aria-labelledby` still apply if given:
639
+ * an accessible name is still valid on an element with no explicit role.
640
+ *
641
+ * The per-node/per-point `<title>` tooltips added by earlier work (nested
642
+ * inside `<g>` elements, with no `id` attribute) are unaffected: they never
643
+ * collide with the root title's generated id, and a root `<title>` alongside
644
+ * unrelated descendant `<title>` elements is valid SVG.
645
+ *
646
+ * @param transparent - If true, omits the background style for transparent SVGs
647
+ * @param title - Accessible name text. Ignored when `decorative` is true
648
+ * and there are no interactive links (see `hasInteractiveLinks`).
649
+ * @param decorative - Marks the SVG as decorative (`aria-hidden="true"`).
650
+ * Overridden when `hasInteractiveLinks` is true.
651
+ * @param hasInteractiveLinks - True if any node has a `click`-based `href`.
652
+ * See #239 — forces no root `role` so the link
653
+ * stays reachable, regardless of `decorative`.
654
+ * @param styleAttribute - Emit the root `style="…"` attribute. Default true.
655
+ */
656
+ export function svgOpenTag(
657
+ width: number,
658
+ height: number,
659
+ colors: DiagramColors,
660
+ transparent?: boolean,
661
+ title?: string,
662
+ decorative?: boolean,
663
+ hasInteractiveLinks?: boolean,
664
+ styleAttribute: boolean = true,
665
+ ): string {
666
+ const styleAttr = styleAttribute
667
+ ? ` style="${themeStyleDeclarations(colors, transparent)}"`
668
+ : ''
669
+
670
+ let a11yAttrs = ''
671
+ let titleEl = ''
672
+ if (hasInteractiveLinks) {
673
+ // No root role: role="img" or aria-hidden would hide a real, focusable
674
+ // <a href> descendant from assistive tech while leaving it Tab-reachable.
675
+ if (title) {
676
+ const id = nextTitleId()
677
+ a11yAttrs = ` aria-labelledby="${id}"`
678
+ titleEl = `\n <title id="${id}">${escapeXml(title)}</title>`
679
+ }
680
+ } else if (decorative) {
681
+ a11yAttrs = ' aria-hidden="true"'
682
+ } else {
683
+ a11yAttrs = ' role="img"'
684
+ if (title) {
685
+ const id = nextTitleId()
686
+ a11yAttrs += ` aria-labelledby="${id}"`
687
+ titleEl = `\n <title id="${id}">${escapeXml(title)}</title>`
688
+ }
689
+ }
690
+
691
+ return (
692
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" ` +
693
+ `width="${width}" height="${height}"${a11yAttrs}${styleAttr}>` +
694
+ titleEl
695
+ )
696
+ }