@humanforest/slidev-theme 0.1.0

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 (124) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +577 -0
  3. package/assetUrl.js +38 -0
  4. package/components/Alert.vue +209 -0
  5. package/components/AreaBox.vue +152 -0
  6. package/components/AreaChart.vue +145 -0
  7. package/components/Avatar.vue +267 -0
  8. package/components/AvatarGroup.vue +94 -0
  9. package/components/BadgeStrip.vue +203 -0
  10. package/components/Bars.vue +207 -0
  11. package/components/BoroughShape.vue +127 -0
  12. package/components/BrowserFrame.vue +484 -0
  13. package/components/CalendarGrid.vue +480 -0
  14. package/components/Card.vue +70 -0
  15. package/components/Carousel.vue +266 -0
  16. package/components/ChartFrame.vue +426 -0
  17. package/components/ChordChart.vue +264 -0
  18. package/components/ComposedChart.vue +187 -0
  19. package/components/Contents.vue +86 -0
  20. package/components/DataTable.vue +244 -0
  21. package/components/DeckMap.vue +409 -0
  22. package/components/DeviceFrame.vue +331 -0
  23. package/components/DonutChart.vue +103 -0
  24. package/components/DottedMap.vue +126 -0
  25. package/components/Endorsement.vue +76 -0
  26. package/components/Grid.vue +169 -0
  27. package/components/GroupedBarChart.vue +141 -0
  28. package/components/IconTile.vue +116 -0
  29. package/components/Kpi.vue +188 -0
  30. package/components/LineChart.vue +133 -0
  31. package/components/Logo.vue +203 -0
  32. package/components/LondonMap.vue +348 -0
  33. package/components/MarkPair.vue +109 -0
  34. package/components/Marquee.vue +263 -0
  35. package/components/NestedDonutChart.vue +295 -0
  36. package/components/Placeholder.vue +88 -0
  37. package/components/SankeyChart.vue +223 -0
  38. package/components/ScatterChart.vue +142 -0
  39. package/components/StackedBarChart.vue +143 -0
  40. package/components/StatCard.vue +134 -0
  41. package/components/StatusTrack.vue +334 -0
  42. package/components/Timeline.vue +249 -0
  43. package/components/TimelineChart.vue +329 -0
  44. package/components/TreemapChart.vue +267 -0
  45. package/components/backgrounds.js +135 -0
  46. package/components/boroughGeometry.ts +59 -0
  47. package/components/chartData.ts +277 -0
  48. package/components/chartTip.ts +201 -0
  49. package/components/codeTheme.ts +239 -0
  50. package/components/deckPalette.ts +157 -0
  51. package/components/dottedMapGeometry.js +60 -0
  52. package/components/iconSlugs.js +55 -0
  53. package/components/iconUrl.js +51 -0
  54. package/components/mermaidTheme.ts +972 -0
  55. package/components/motionDuration.ts +41 -0
  56. package/components/tableRules.ts +257 -0
  57. package/confidential-mark.vue +151 -0
  58. package/custom-nav-controls.vue +79 -0
  59. package/endorsements/b-corp-black.svg +30 -0
  60. package/endorsements/b-corp-white.svg +30 -0
  61. package/global-top.vue +1662 -0
  62. package/icons/forest/bike-asterisk.svg +21 -0
  63. package/icons/forest/bike-down.svg +19 -0
  64. package/icons/forest/bike-off.svg +20 -0
  65. package/icons/forest/bike-up.svg +19 -0
  66. package/icons/forest/bike-x.svg +19 -0
  67. package/icons/forest/bike.svg +18 -0
  68. package/icons/forest/mark-fill.svg +6 -0
  69. package/icons/forest/mark.svg +6 -0
  70. package/icons/forest/parking.svg +3 -0
  71. package/icons/forest/star-fill.svg +4 -0
  72. package/icons/forest/star.svg +4 -0
  73. package/icons/forest/traffic-light-caution.svg +8 -0
  74. package/icons/forest/traffic-light-go.svg +8 -0
  75. package/icons/forest/traffic-light-lit.svg +8 -0
  76. package/icons/forest/traffic-light-stop.svg +8 -0
  77. package/icons/forest/traffic-light.svg +8 -0
  78. package/icons/lucide/battery.svg +1 -0
  79. package/icons/lucide/bike.svg +1 -0
  80. package/icons/lucide/chart-line.svg +1 -0
  81. package/icons/lucide/check.svg +1 -0
  82. package/icons/lucide/circle-check.svg +1 -0
  83. package/icons/lucide/clock.svg +1 -0
  84. package/icons/lucide/code.svg +1 -0
  85. package/icons/lucide/component.svg +1 -0
  86. package/icons/lucide/frame.svg +1 -0
  87. package/icons/lucide/layout-grid.svg +1 -0
  88. package/icons/lucide/map-pin.svg +1 -0
  89. package/icons/lucide/map.svg +1 -0
  90. package/icons/lucide/palette.svg +1 -0
  91. package/icons/lucide/table.svg +1 -0
  92. package/icons/lucide/triangle-alert.svg +1 -0
  93. package/icons/lucide/type.svg +1 -0
  94. package/icons/lucide/wrench.svg +1 -0
  95. package/layouts/bleed.vue +317 -0
  96. package/layouts/cover.vue +1514 -0
  97. package/layouts/default.vue +187 -0
  98. package/layouts/divider.vue +286 -0
  99. package/layouts/end.vue +384 -0
  100. package/layouts/quote.vue +218 -0
  101. package/layouts/split.vue +1875 -0
  102. package/layouts/stack.vue +944 -0
  103. package/layouts/statement.vue +67 -0
  104. package/manifest/chrome.ts +36 -0
  105. package/manifest/components.ts +49 -0
  106. package/manifest/index.ts +57 -0
  107. package/manifest/layouts.ts +37 -0
  108. package/manifest/manifest.json +6514 -0
  109. package/manifest/presets.ts +635 -0
  110. package/notch.js +458 -0
  111. package/notchPath.js +188 -0
  112. package/package.json +77 -0
  113. package/section.js +110 -0
  114. package/setup/mermaid-renderer.ts +2120 -0
  115. package/setup/mermaid.ts +30 -0
  116. package/setup/shiki.ts +44 -0
  117. package/setup/shortcuts.ts +28 -0
  118. package/slide-bottom.vue +305 -0
  119. package/slide-top.vue +268 -0
  120. package/styles/base.css +2611 -0
  121. package/styles/index.ts +71 -0
  122. package/styles/tokens.css +144 -0
  123. package/uno.config.ts +231 -0
  124. package/useNotchCuts.js +200 -0
@@ -0,0 +1,2120 @@
1
+ // A CUSTOM MERMAID RENDERER, and the reason it has to be a renderer rather than a config.
2
+ //
3
+ // Slidev offers two hooks. `setup/mermaid.ts` returns a config object — but @slidev/client's
4
+ // setup/mermaid.ts wraps it in `createSingletonPromise`, so it is resolved ONCE for the lifetime of
5
+ // the page. One config, one palette. This deck runs light and marks individual slides
6
+ // `class: deck-dark` (deck.css: "dark is punctuation, not the ground"), so a single frozen config
7
+ // can serve one of the two registers and gets the other wrong — which, on a diagram whose edges are
8
+ // drawn in the accent, means arrows a shade off the ground they sit on.
9
+ //
10
+ // `setup/mermaid-renderer.ts` is the other hook, and it is called PER RENDER
11
+ // (@slidev/client/modules/mermaid.ts: `for (const setup of mermaidRenderers) { const renderer =
12
+ // await setup(); if (renderer) { … } }`). That is what lets one deck hold both registers.
13
+ //
14
+ // ★ THE REGISTER TRAVELS AS `theme`, deliberately reusing mermaid's own prop rather than inventing
15
+ // one. Two things fall out for free. Slidev already passes `theme: isDark ? 'dark' : undefined`
16
+ // from the global toggle, so pressing `d` re-renders every diagram in the dark palette with no
17
+ // author involvement. And `options` is part of modules/mermaid.ts's own CACHE KEY
18
+ // (`lzEncoded + JSON.stringify(options)`), so the light and dark renders of the same diagram are
19
+ // two entries rather than one stale one — which a bespoke prop outside that key would not have been.
20
+ // A slide opts in per diagram with:
21
+ //
22
+ // ```mermaid {theme: 'dark'}
23
+ //
24
+ // ★★ AND `useMaxWidth` IS WHY THE SVG FITS. mermaid stamps a fixed `width`/`max-width` on its root
25
+ // <svg> from the diagram's own natural size. With labels set at the deck's 36px body rung — which
26
+ // is what makes a diagram legible from the back of a room in the first place — natural size is
27
+ // routinely wider than the slide, and the overflow leaves the canvas rather than scaling. Each
28
+ // diagram family has its own `useMaxWidth` switch (they do not share one), and the ones this deck
29
+ // draws are set in components/mermaidTheme.ts; the post-processing below is the backstop for the
30
+ // families that ignore it.
31
+ import type { MermaidRenderFn } from '@slidev/types';
32
+ import type { Register } from '../components/deckPalette';
33
+ import { MERMAID_FONT_FAMILY, MERMAID_FONT_PX, MERMAID_EDGE_LABEL_FONT_PX, MERMAID_INDEX_FONT_PX, MERMAID_TAG_FONT_PX, MERMAID_LEGEND_FONT_PX, DIAGRAM_STROKE_PX, mermaidConfig, mermaidInkRules, mermaidBackgroundRules } from '../components/mermaidTheme';
34
+
35
+ /** mermaid's ESM build, the same specifier @slidev/client resolves. */
36
+ async function loadMermaid() {
37
+ const mod = await import('mermaid/dist/mermaid.esm.mjs');
38
+ return (mod as { default: typeof import('mermaid').default }).default;
39
+ }
40
+
41
+ let counter = 0;
42
+
43
+ /**
44
+ * Fit the rendered SVG to its column — SHRINK ONLY, never stretch.
45
+ *
46
+ * mermaid emits a hard `width` and a `style="max-width: …px"` from the diagram's natural size.
47
+ * Left alone, a diagram wider than its column paints past the slide edge, and it does so as an SVG
48
+ * overflow that scripts/deckAudit.ts cannot see, because the host element's own border box is still
49
+ * inside the column.
50
+ *
51
+ * ★★ AND THE OBVIOUS FIX IS WORSE THAN THE PROBLEM. A plain `width:100%` makes every diagram fill
52
+ * its column, including the ones that are narrower than it — measured on a five-state vertical
53
+ * `stateDiagram-v2` (viewBox 263 x 510, aspect 0.52): stretched across a 1160px column it came out
54
+ * **2247px tall**, four slides' worth of height for five boxes, with the first label alone taller
55
+ * than the slide heading. The cap has to be the diagram's OWN width, so `min(natural, 100%)`:
56
+ * a wide diagram shrinks to fit, a narrow one is left at the size mermaid laid it out at.
57
+ *
58
+ * ★★ AND THE HEIGHT IS A CAP TOO, because for one whole family it is the binding one. A sequence
59
+ * diagram grows a ROW per message and a COLUMN per actor, so it runs out of slide vertically long
60
+ * before it runs out of column. MEASURED: the specimen's own three-message sequence is 882x739 in a
61
+ * 1728px column — no width shrink at all, and 739 against the 720 the slide has between the
62
+ * diagram's top and the bottom inset. Three messages already over. A five-actor, sixteen-message
63
+ * one measured 1776x2380 and the audit put it 1500px past the bottom of the canvas.
64
+ *
65
+ * The cap is expressed as a THIRD TERM IN THE WIDTH, not as a max-height: the SVG is a replaced
66
+ * element whose height follows its width by the viewBox ratio, so `H_MAX x naturalW / naturalH` is
67
+ * the width at which the drawing is exactly H_MAX tall. min() then picks whichever bound bites.
68
+ * A max-height would need a definite height on a parent this function cannot reach.
69
+ *
70
+ * A diagram shorter than the budget is untouched — the third term is larger than its natural width,
71
+ * so min() never selects it.
72
+ *
73
+ * Rewritten in the string rather than styled: the SVG is inside a shadow root, so no stylesheet in
74
+ * this theme can reach it. The viewBox is left exactly as mermaid computed it, so nothing is
75
+ * re-laid-out — only the box the drawing is fitted into changes.
76
+ */
77
+ /*
78
+ The height a diagram may occupy, in canvas px, and the CSS variable a layout overrides it with.
79
+
80
+ 720 is the DEFAULT layout: 1080 canvas − the 264 a diagram's top sits at under a heading − the 96
81
+ bottom inset. MEASURED on slide 245, whose host box starts at 264 and whose safe area ends at 984.
82
+
83
+ ★★ A LAYOUT THAT GIVES A DIAGRAM MORE HAS TO SAY SO, because this function cannot see where it is.
84
+ A split's figure column carries no heading above it, so its budget is 1080 − 96 − 96 = 888 — a
85
+ quarter more, which for a height-bound diagram is a quarter more of everything. split.vue sets the
86
+ variable; anything that does not, gets the default.
87
+ */
88
+ export const DIAGRAM_MAX_H = 720;
89
+ export const DIAGRAM_MAX_H_VAR = '--deck-diagram-max-h';
90
+
91
+ export function fitToColumn(svg: string): string {
92
+ const viewBox = /viewBox="([^"]+)"/.exec(svg)?.[1]?.trim().split(/\s+/);
93
+ const naturalWidth = viewBox?.length === 4 ? Number(viewBox[2]) : Number.NaN;
94
+ const naturalHeight = viewBox?.length === 4 ? Number(viewBox[3]) : Number.NaN;
95
+ // No viewBox is not a case to guess at: without one there is no natural size to cap against, and
96
+ // 100% would be the stretch this function exists to avoid.
97
+ const sized = Number.isFinite(naturalWidth) && naturalWidth > 0;
98
+ const tall = sized && Number.isFinite(naturalHeight) && naturalHeight > DIAGRAM_MAX_H;
99
+ const ratio = naturalWidth / naturalHeight;
100
+ const terms = sized
101
+ ? [`${Math.ceil(naturalWidth)}px`, '100%',
102
+ ...(tall ? [`calc(var(${DIAGRAM_MAX_H_VAR}, ${DIAGRAM_MAX_H}px) * ${ratio.toFixed(4)})`] : [])]
103
+ : [];
104
+ const cap = terms.length ? `min(${terms.join(', ')})` : '100%';
105
+ return svg
106
+ .replace(/<svg([^>]*?)\swidth="[^"]*"/, '<svg$1')
107
+ .replace(/<svg([^>]*?)\sheight="[^"]*"/, '<svg$1')
108
+ .replace(/<svg([^>]*?)\sstyle="[^"]*"/, '<svg$1')
109
+ .replace(/<svg\b/, `<svg style="width:${cap};height:auto;display:block;margin:0 auto"`);
110
+ }
111
+
112
+ /*
113
+ ── THE ROOM-SCALE FLOOR ───────────────────────────────────────────────────────────────────────
114
+
115
+ Some of mermaid's own sizes are not configurable at all. They are literals inside the stylesheet
116
+ it generates, driven by no theme variable and no config key, and two of them are wrong for a deck:
117
+
118
+ · `.edgeLabel .label { font-size: 14px }` — the ER diagram's relationship labels ("contains",
119
+ "holds"). Measured: 14px beside 36px entity names, on a 1920px canvas. `er.fontSize` does not
120
+ move it, because nothing reads that key for this rule.
121
+ · `.slice { font-family: "trebuchet ms", verdana, arial, … }` — the pie chart's labels, hard-
122
+ coded past the `fontFamily` the rest of the diagram obeys.
123
+
124
+ ★ THE SHADOW ROOT IS NOT THE OBSTACLE HERE, because this runs BEFORE the SVG becomes one. The
125
+ renderer returns a STRING, and mermaid's own <style> block is inside it, so appending rules to
126
+ that block puts them in the same shadow root at the same specificity — and later in source order,
127
+ which is what wins a tie. A stylesheet in this theme could never do this; a string rewrite can.
128
+
129
+ Deliberately a SHORT, NAMED list rather than a blanket `* { font-size: … }`: mermaid uses relative
130
+ sizes in places, and flattening all of them would break diagrams that are currently correct. Each
131
+ entry is a measured literal with the diagram family it belongs to.
132
+ */
133
+ /*
134
+ ★★ A STROKE IS IN THE DRAWING'S OWN UNITS, SO IT SCALES WITH IT.
135
+
136
+ fitToColumn caps a wide diagram at its column, which scales the whole SVG down — measured on the
137
+ specimen's flowchart, a 1603-unit viewBox drawn into 872px, a factor of 0.544. Mermaid's stroke
138
+ widths are in SVG units, so they shrink with it: the node border and every link, both set to 1px,
139
+ render at 0.54 canvas px. A half-pixel line is a line the back row cannot see, and because the
140
+ factor is per-diagram, two diagrams on consecutive slides carry visibly different weights.
141
+
142
+ This was `vector-effect: non-scaling-stroke`, which takes stroke width out of the scaled
143
+ coordinate space so the value below renders whatever the diagram's size. It cures the thin end by
144
+ making the line the ONE thing on a slide that does not scale, and the thick end is just as wrong:
145
+ at a fit of 0.37 — the sixteen-message sequence — every box, gap and letter shrinks while the ink
146
+ stays put, so the line reads thirteen times heavier relative to its diagram than at natural size.
147
+
148
+ Stated in UNITS instead, it scales like the corner radius, the type and the node it outlines. At
149
+ natural size it is still 4 canvas px, where every other strong edge in the deck sits; at 0.5 it is
150
+ 2, and the diagram reads as the same drawing seen from further away rather than a different one.
151
+
152
+ ⚠ The thin end is back in principle, and the height cap is what holds it off — a diagram cannot be
153
+ scaled arbitrarily small before fitToColumn stops it. At the smallest fit the specimen actually
154
+ produces this is 1.5 canvas px against mermaid's own 0.37. Past that the answer is fewer things on
155
+ the slide, not a heavier line.
156
+
157
+ The FILL is deliberately left alone. A mermaid node is a Card, and the deck draws a card as a quiet
158
+ --deck-surface fill read by its border and its corner — not by a loud ground. Fixing the border so
159
+ it renders is the system-consistent repair; reaching for a stronger fill would be treating the
160
+ symptom.
161
+ */
162
+ /* ★ DECLARED IN mermaidTheme.ts AND RE-EXPORTED HERE. It moved the day radar needed it: that
163
+ family states its axis, graticule and curve weights as THEME VARIABLES, which are built in
164
+ mermaidTheme, and this file already imports from there — so declaring it here and importing it
165
+ back would have been a cycle. Re-exported rather than relocated in every caller, because the
166
+ rules below are where the number is most often read and the import list is not the story. */
167
+ export { DIAGRAM_STROKE_PX } from '../components/mermaidTheme';
168
+ /*
169
+ ★ THE RADIUS IS IN SVG UNITS AND THOSE SCALE, which is why this is not one of the deck's own
170
+ corner steps. fitToColumn draws a wide diagram at a fraction of its natural size — 0.544 on the
171
+ specimen's flowchart — so the rendered corner is this value times that fraction. At the item step
172
+ (8) it came out at 4 canvas px, a square with the edge taken off rather than a rounded box. There
173
+ is no non-scaling equivalent for a corner the way there is for a stroke, so the number is chosen
174
+ for what it RENDERS at, and a diagram drawn at a different scale carries a proportionally different
175
+ corner — which is right for a radius, unlike for a line.
176
+ */
177
+ const DIAGRAM_RADIUS = 28;
178
+ /*
179
+ ★★ A POLYGON ROUNDS FAR LESS THAN A RECT, AND THE ARROWS ARE WHY. Mermaid computes an edge's
180
+ endpoint against the SHARP shape and leaves a fixed gap for the head to cover — 4 user units to a
181
+ rectangle. Rounding a corner pulls the silhouette back from the vertex the arrow was aimed at, and
182
+ on a 45-degree diamond point the tip retreats by roughly the radius times cos 45. At the rect's 28
183
+ that is ~20 units of daylight the head has no way to cross, because the path was laid out before
184
+ this rewrite and cannot be moved without redoing mermaid's own intersection maths.
185
+
186
+ Mermaid already leaves a diamond 14.4 units rather than 4 — measured on the specimen's flowchart —
187
+ so the gap starts wider and this only decides how much worse it gets. Small enough to read as a
188
+ softened point, not so large that the arrow stops in mid-air.
189
+ */
190
+ const POLYGON_RADIUS = 10;
191
+
192
+
193
+ /*
194
+ The fragment keyword's size is mermaidTheme's, because the TAB it sits in is scaled from the same
195
+ number there. Set through CSS because mermaid's sequence font keys are all dead.
196
+ */
197
+ const TAG_FONT_PX = MERMAID_TAG_FONT_PX;
198
+
199
+ /* The gap mermaid leaves between an autonumber's disc centre and the baseline it writes the digit
200
+ on — MEASURED off the rendered markup, a marker at y=175 against `<text y="179">`. It is half a
201
+ cap height at mermaid's own 12px, and a constant, so it does not follow the deck's type. */
202
+ const SEQUENCE_NUMBER_BASELINE_DROP = 4;
203
+
204
+ /* The same gap for a fragment keyword, and it comes from a different place: drawLabel puts the text
205
+ y at the tab's centre and drawText's valign-middle yfunc then adds boxTextMargin/2 on top of it,
206
+ so every keyword is laid out below its own tab's centre by half the tag scale (6/2, plus 0.5 from
207
+ the rounding). MEASURED off the rendered markup, not derived. */
208
+ const TAG_BASELINE_DROP = 3.5;
209
+
210
+ /* The disc's radius as a fraction of the index's type, which is left where it is — this number
211
+ moves the disc alone. mermaid's own is a flat half, r=6 against a 12px digit, and at deck size
212
+ that reads as a token the eye stops on rather than a marker on the line. Tightened twice: 0.44
213
+ still sat as a filled counter beside the message, 0.36 reads as a bullet the number happens to
214
+ be in. A two-digit index overhangs at any of these, mermaid's own included — the disc is a mark
215
+ on the line, not a container. */
216
+ const SEQUENCE_NUMBER_DISC_RATIO = 0.36;
217
+
218
+ // The pie legend's own size, the same 0.8 step mermaidTheme sets `pieLegendTextSize` from — one
219
+ // number, so the spacing pass and the text it is spacing cannot disagree.
220
+ const PIE_LEGEND_PX = Math.round(MERMAID_FONT_PX * 0.8);
221
+
222
+ /*
223
+ The radar legend's swatch, and the gap between it and the word it names. mermaid draws a 12x12
224
+ chip and puts the text 16 units to its right — both sized for its own ~12px legend, and both
225
+ presentation attributes, so a rule and a rewrite reach them. Stated as fractions of the legend
226
+ rung rather than as numbers, because the whole point of the pass below is that the three move
227
+ together.
228
+ */
229
+ const RADAR_SWATCH_PX = Math.round(MERMAID_LEGEND_FONT_PX * 0.8);
230
+ const RADAR_SWATCH_GAP_PX = Math.round(MERMAID_LEGEND_FONT_PX * 0.4);
231
+
232
+ // How far a sequence message label drops so its INK, rather than its em box, is what sits between
233
+ // the two lines. A quarter of an em is half the descender depth this face reserves and never uses
234
+ // on these labels; see the rule below for the measurement it corrects.
235
+ export const MESSAGE_INK_DROP = 0.25;
236
+
237
+ // A journey's satisfaction face, as a multiple of the label beside it. mermaid draws it at r=15,
238
+ // sized for its own 14px type; this is what makes it a mark rather than a speck at the deck's rung.
239
+ export const FACE_DIAMETER_EM = 1.7;
240
+
241
+ /*
242
+ ★★ A GANTT LABEL SITS LOW BECAUSE OF A CONSTANT THAT DOES NOT SCALE. mermaid places the baseline
243
+ from its own renderer as
244
+
245
+ text y = order * gap + barHeight / 2 + (fontSize / 2 - 2) + topPad
246
+ rect y = order * gap + topPad
247
+
248
+ so the baseline drops `fontSize / 2 - 2` below the bar's centre. At mermaid's own 11px that `- 2`
249
+ is 18% of the term and the result lands close to centring the CAP HEIGHT, which is what the eye
250
+ reads as centred. At this deck's 36px the same 2 is 5.5%, and the label drops low.
251
+
252
+ MEASURED against the shipped face: cap height 25.21 at 36px (0.70em), so cap-centring wants the
253
+ baseline 12.6 below the middle and mermaid puts it at 16 — 3.4 units low, uniform across every
254
+ row. The ratio is a property of the face and is pinned here rather than assumed; a face swap moves
255
+ it, which is why it is named.
256
+ */
257
+ export const GANTT_CAP_RATIO = 0.7;
258
+ export const GANTT_LABEL_RISE = MERMAID_FONT_PX / 2 - 2 - (GANTT_CAP_RATIO * MERMAID_FONT_PX) / 2;
259
+
260
+ const ROOM_SCALE_RULES = [
261
+ // ER relationship labels — mermaid's hard 14px.
262
+ /*
263
+ ★ A TRANSITION NAME IS AN ANNOTATION ON A LINE, not prose, and takes the same rung the autonumber
264
+ index does. mermaid's own is a hard 14px — sized for its 16px type — and this rule used to lift
265
+ it to the full body rung, which put the word on a transition at the same size as the state it
266
+ joins. It is already muted to 75% for the same reason; the size and the mute are one argument.
267
+ */
268
+ `.edgeLabel .label{font-size:${MERMAID_EDGE_LABEL_FONT_PX}px;}`,
269
+ /*
270
+ ★ THE GIT COMMIT ID IS ANOTHER HARD LITERAL. mermaid writes `.commit-label` at 10px — its own
271
+ 16px-era size — with no config key that reaches it, the fourth instance of this pattern in the
272
+ family after the ER edge label's 14, the gantt title's 18 and the autonumber's 12. It names a
273
+ commit, so it takes the same rung a transition name does.
274
+ */
275
+ `.commit-label{font-size:${MERMAID_EDGE_LABEL_FONT_PX}px;}`,
276
+ /*
277
+ ★★ THE TIMELINE'S CONNECTORS ARE HARD-CODED PASTELS, and they are the last unthemed colour in
278
+ the family. mermaid draws the line from a period band down to its events as `line.node-line-N`
279
+ with a literal per-index pastel — MEASURED off the rendered slide, `rgb(165,206,255)` pale blue,
280
+ `rgb(255,207,183)` peach and `rgb(255,219,239)` pink. None is derived from anything the theme
281
+ sets, and on this deck's cream paper all three are very nearly invisible.
282
+
283
+ They carry the connection between a period and what happened in it, which is the same job every
284
+ other edge in the deck does, so they take the same accent. One colour rather than three: the
285
+ band above already says which period a line belongs to, and a second encoding of that in a tint
286
+ nobody can see is not an encoding.
287
+ */
288
+ `line[class*="node-line"]{stroke:var(--deck-accent);}`,
289
+ `.branch-label{font-size:${MERMAID_EDGE_LABEL_FONT_PX}px;}`,
290
+ /*
291
+ ★★ THE LABEL KNOCKS OUT THE LINE, SO ITS FILL MUST BE THE GROUND IT SITS ON — and mermaid's own
292
+ `edgeLabelBackground` cannot be trusted to be it. MEASURED across one page's renders: this theme
293
+ sets that variable to the page ground (#fff9f2 light), and mermaid honoured it on one diagram
294
+ and fell back to `mainBkg`/`tertiaryColor` on the next — which is --deck-surface, #f2ece5. A
295
+ grey box on cream paper, differing between two diagrams on the same slide. Same family of
296
+ defect as the dead `sequence.*FontSize` keys documented in mermaidTheme.
297
+
298
+ A custom property rather than a colour, because the ground is not always the page: a diagram
299
+ inside a named background has to knock out to THAT fill. The four backgrounds set this variable
300
+ in styles/base.css and it inherits into the SVG; the fallback is --deck-bg, which is already
301
+ per-register, so the dark register needs no second rule.
302
+
303
+ These are appended AFTER mermaid's own <style>, at the same specificity, so they win on order —
304
+ which is what `scope` and the append in applyRoomScale() are for.
305
+ */
306
+ /*
307
+ ★★ A COMPOSITE STATE DRAWS TWO RECTS, and `.cluster rect` in the stroke list gives BOTH the deck's
308
+ 4px border — so the container came out double-ruled along the bottom, two lines a few units apart.
309
+ mermaid nests an inner rect inside `rect.outer`; the outer IS the container and the inner is
310
+ scaffolding. The corner rule already singles out `rect.outer`, so this makes the stroke agree.
311
+ */
312
+ `.statediagram-cluster rect:not(.outer):not(.basic){stroke:none;fill:none;}`,
313
+ /*
314
+ ★★ A MILESTONE IS A DIAMOND, AND THE DECK'S OWN CORNER WAS ERASING IT. mermaid draws one as a
315
+ square rotated 45°, sized from `barHeight` — 56 here — and `rect.task` is in the corner rule
316
+ above at 28px. A 56x56 square with a 28px radius IS A CIRCLE, and a circle rotated 45° is the
317
+ same circle: the diamond that distinguishes a milestone from a task never appeared. Measured on
318
+ this slide before the rule, and it is the one place in the family where the deck's own treatment
319
+ destroys a notation rather than restyling it.
320
+
321
+ Squared off, because the corner is what the shape means here.
322
+ */
323
+ `rect.milestone,.milestone rect,rect.task.milestone{rx:0;ry:0;}`,
324
+ `.edgeLabel .label rect{fill:var(--deck-mermaid-label-bg,var(--deck-bg));}`,
325
+ /*
326
+ ⚠ A LABEL INSIDE A CONTAINER STILL KNOCKS OUT TO THE PAGE, and nesting cannot fix it. An edge
327
+ label drawn inside a `subgraph` or a composite sits on the container's surface, so the
328
+ page-coloured knockout above is the wrong ground there. A descendant rule was tried and is DEAD:
329
+ mermaid collects every edge label into one `g.edgeLabels` at the ROOT and positions it by
330
+ transform, so no label is ever a descendant of the cluster it appears to sit in. Reaching it
331
+ would mean matching on coordinates, which a stylesheet cannot do.
332
+ */
333
+ `.edgeLabel .label span{background:var(--deck-mermaid-label-bg,var(--deck-bg));}`,
334
+ /*
335
+ ★★ AND INSIDE A CONTAINER THE GROUND IS THE CONTAINER. groundLabelsOnContainers marks the labels
336
+ a `subgraph` or a composite state encloses; this is what the mark does. Re-pointing the VARIABLE
337
+ rather than painting the rect is what makes one declaration enough:
338
+
339
+ · the knockout `rect` and the `span` are two different label forms — mermaid ignores
340
+ `htmlLabels: false` for edge labels in 11.17.2, so a flowchart's are HTML in a foreignObject
341
+ and a state diagram's are SVG — and a custom property inherits into both;
342
+ · the ink is a MIX TOWARD ITS OWN GROUND, so the word mutes toward the surface it sits on
343
+ instead of toward a page it does not, which painting the rect alone would have got wrong.
344
+
345
+ ★ Its own variable, defaulting to the surface, so a named background can re-point the container
346
+ ground without touching the page ground beside it. `.marker.aggregation` reads
347
+ --deck-mermaid-label-bg too and must keep the PAGE value: it lives in <defs>, where a property
348
+ set on a group inside the drawing cannot reach it — which is exactly why this is scoped here and
349
+ not at the SVG root.
350
+ */
351
+ `.edgeLabel .label[data-on-container]{--deck-mermaid-label-bg:var(--deck-mermaid-container-bg,var(--deck-surface));}`,
352
+ /*
353
+ ── THE THREE FAMILIES THE TWELVE-SLOT CATEGORICAL WAS WRITTEN FOR ────────────────────────────
354
+ Each states weights and opacities mermaid writes as PRESENTATION ATTRIBUTES or as literals in
355
+ its own stylesheet, so a plain rule appended here beats them with no `!important` anywhere.
356
+ What is NOT here is as deliberate as what is — the exclusions are recorded on the slides.
357
+ */
358
+ /* The radar legend's swatch is a 12x12 presentation attribute sized for mermaid's own ~12px
359
+ legend. `width`/`height` are SVG2 geometry properties on a rect, so a rule reaches them. */
360
+ /*
361
+ ★★ THE GRATICULE IS A STACK OF FILLED DISCS, NOT RINGS, and no theme variable says so. mermaid
362
+ paints each tick circle with `graticuleColor` as BOTH fill and stroke, so `graticuleOpacity: 1`
363
+ — which is what a ring at the deck's border colour wants — turns the whole plot into one grey
364
+ disc with every tick invisible underneath it. Measured: four concentric circles, r 75 to 300,
365
+ every one of them fill-opacity 1.
366
+
367
+ Dropping the fill is the only way to get the scale back. The rings then read as what they are,
368
+ a measure, against the page — rather than as a coloured ground the data happens to sit on.
369
+ */
370
+ `.radarGraticule{fill:none;}`,
371
+ /*
372
+ ★★ AND THE WORD HANGS FROM ITS BASELINE, WHICH DEFEATS THE SEATING. mermaid's own stylesheet
373
+ carries `.radarLegendText{dominant-baseline:hanging}` — so the y a rule or a rewrite sets is the
374
+ text's TOP, not its baseline, and spaceRadarLegend's 0.35em cap-height nudge pushes it further
375
+ down instead of centring it. MEASURED: the word's box came out at y 4.27 for 36.54 units against
376
+ a chip spanning -11.5 to 11.5, i.e. 22.5 units low — the word sitting a row below the chip that
377
+ names it. Back to the alphabetic baseline, which is what the nudge is written against.
378
+
379
+ ★ AND THE CHIP IS A KEY, NOT A SAMPLE OF THE WASH. It inherits the curve's own fill-opacity, so
380
+ at the 0.16 that makes two overlapping areas readable it renders as an all-but-empty outline at
381
+ a quarter of the size. A legend states WHICH COLOUR, once, at the size of a word — so it states
382
+ it solidly, and the stroke it already carries is the same hue.
383
+ */
384
+ `.radarLegendText{dominant-baseline:auto;}`,
385
+ `[class^="radarLegendBox-"]{width:${RADAR_SWATCH_PX}px;height:${RADAR_SWATCH_PX}px;fill-opacity:1;}`,
386
+ /*
387
+ ★★ A TREEMAP CELL IS A CARD, so it takes the deck's edge — mermaid draws the leaf at
388
+ stroke-width 3 and the section at 2, both presentation attributes, and both a shade under the
389
+ weight every other box in this deck carries. The fill opacities come with them because the two
390
+ are one reading: at mermaid's 0.3 leaf over 0.6 section the nesting is a wash of one hue, and
391
+ the deck's own answer to "a group of Cards is a Card" is a quiet surface read by its border.
392
+
393
+ ⚠ AND THE CORNER RULE MUST NOT REACH THE CLIP RECTS. treemap nests a plain <rect> inside a
394
+ <clipPath> per cell; rounding those would crop every label to a rounded window it was never
395
+ laid out for. The corner rule below names `rect.treemapLeaf` and `rect.treemapSection` rather
396
+ than the family's rects in general, for that reason alone.
397
+ */
398
+ `rect.treemapLeaf{stroke-width:${DIAGRAM_STROKE_PX};fill-opacity:0.18;}`,
399
+ /*
400
+ ⚠ THE SECTION KEEPS A LIGHTER EDGE THAN THE LEAF, and it is the value in its corner that decides
401
+ it. mermaid anchors the section's total at `x = width - 10`, hard against the cell's right edge,
402
+ so a border at the deck's full 4 units runs UNDER the digits — measured by the audit's painted-
403
+ pixel pass, which scored three of them at 1.02:1 against the stroke rather than against the
404
+ fill. Nothing can move the text: the x is computed, not configured. So the border gives way.
405
+ */
406
+ `rect.treemapSection{stroke-width:${DIAGRAM_STROKE_PX / 2};stroke-opacity:1;fill-opacity:0.5;}`,
407
+ `rect.treemapLeaf,rect.treemapSection{rx:${POLYGON_RADIUS}px;ry:${POLYGON_RADIUS}px;}`,
408
+ /*
409
+ ★ THE SECTION HEADER'S TYPE IS AN INLINE STYLE at 12px, and `!important` is the only thing that
410
+ reaches it. Taken, unlike the treemap LEAF label below it, because a section header is not
411
+ fitted by a shrink loop — it is truncated against a width, so raising it costs an ellipsis at
412
+ worst rather than a culled label. Short section names keep it from ever truncating, which is
413
+ why the slide's are one word.
414
+ */
415
+ /*
416
+ ★★ THE SECTION HEADER TAKES THE INDEX RUNG, NOT THE TRANSITION ONE, AND THE BAND DECIDES THAT.
417
+ Its room is fixed at 35 units — mermaid's 25-unit header plus 10 of inner padding, which is what
418
+ d3 was handed as `paddingTop`, so growing it means moving every leaf the layout already placed.
419
+ MEASURED at the transition rung: the cap ink is 21 units, seated at 5 above and 9 below, i.e.
420
+ the word exactly fills its band and reads as jammed between the cell's top edge and the first
421
+ leaf. One rung down the ink is 18 and the air is even on both sides.
422
+
423
+ ★ A section name is subordinate to the leaf labels it groups — those sit at the body rung, 38 by
424
+ mermaid's own leaf-count ladder — so being quieter than them is right on its own terms too.
425
+ */
426
+ `text.treemapSectionLabel{font-size:${MERMAID_TAG_FONT_PX}px !important;}`,
427
+ `text.treemapSectionValue{font-size:${MERMAID_TAG_FONT_PX}px !important;font-style:normal;}`,
428
+ /*
429
+ ★★ MINDMAP TAPERS ITS EDGES BY DEPTH — 17 - 3n, so 11, 8, 5, 2 and then NEGATIVE, which renders
430
+ as nothing at all past the fourth rank. The taper is a literal in mermaid's generated stylesheet
431
+ with no key behind it. The deck does not taper: a line is a line at the weight every other line
432
+ in the deck carries, and a mindmap that fades out by depth is telling the room its fifth rank
433
+ matters less than its first, which is not what a hierarchy means.
434
+ */
435
+ `.edge,[class^="edge-depth-"]{stroke-width:${DIAGRAM_STROKE_PX};}`,
436
+ /*
437
+ ★★ A KANBAN COLUMN IS A CONTAINER, NOT A CATEGORY, and mermaid colours it as though it were.
438
+ The family reuses mindmap's `.section-N` machinery for its columns, so each one arrives filled
439
+ with a categorical slot — and past the slots the deck states, with a HUE ROTATION of them:
440
+ measured on this slide, three columns came out bright blue, violet and magenta, none of which
441
+ is in any Forest scale.
442
+
443
+ Colour is the wrong encoding here whatever the hue. A column is a STAGE — backlog, in progress,
444
+ shipped — and stages are ordered, not categorical; painting them as three unrelated colours says
445
+ they are three kinds of thing. The deck's own answer to a group of Cards is a Card: one surface,
446
+ one border, one corner, which is what a flowchart `subgraph` and a composite state already take.
447
+
448
+ ★ SCOPED TO `.cluster` SO MINDMAP IS UNTOUCHED. Both families draw `.section-N`, and on a
449
+ mindmap the colour IS the reading — a branch and everything under it share a slot, which is what
450
+ makes the picture legible by branch. Kanban's columns are the ones that are clusters.
451
+ */
452
+ `.cluster[class*="section-"] > rect{fill:var(--deck-surface);stroke:var(--deck-border);}`,
453
+ /*
454
+ ★ AND THE WORD ON THE LINE IS MUTED, because a transition NAME is subordinate to the states it
455
+ joins — at full ink it competes with the node labels for the same reading.
456
+
457
+ 75%, and that number is a floor rather than a taste. The mute is a mix toward the label's own
458
+ ground, so how far it can go is set by whichever family has the least headroom. MEASURED, ink
459
+ against its ground at each step:
460
+
461
+ ground 100% 80% 75% 74% 72% 70%
462
+ page 17.19 9.33 7.74 7.50 6.96 6.52
463
+ warm 10.26 6.03 5.23 5.08 4.85 4.57
464
+ accent 13.07 7.30 6.26 6.07 5.71 5.37
465
+ vivid 8.51 5.40 4.76 4.63 4.44 4.20
466
+ neutral 15.76 8.82 7.42 7.19 6.67 6.27
467
+
468
+ ★★ VIVID BINDS, AND IT BINDS AT 74. Below that it breaches the 4.5 body floor — 4.44 at 72 and
469
+ 4.20 at 70 — while every other family still has room. 75 is the last step with a margin rather
470
+ than a rounding error, so the mute stops there and not at whatever looked right on a cream slide.
471
+ One number for all five grounds, which is why the mute lives here and the per-ground INK lives in
472
+ base.css beside the fill.
473
+ */
474
+ `.edgeLabel .label text,.edgeLabel .label span{`
475
+ + `fill:color-mix(in oklab,var(--deck-mermaid-label-ink,var(--deck-fg)) 62%,var(--deck-mermaid-label-bg,var(--deck-bg)));`
476
+ + `color:color-mix(in oklab,var(--deck-mermaid-label-ink,var(--deck-fg)) 62%,var(--deck-mermaid-label-bg,var(--deck-bg)));}`,
477
+ /*
478
+ ★★ `!important`, AND IT IS THE ONLY THING THAT REACHES THIS TEXT. mermaid writes the fragment
479
+ keyword with an INLINE STYLE — `<text class="labelText" style="font-family: …; font-size: 36px">`,
480
+ taken from the top-level `fontSize` — and an inline style beats a stylesheet rule at any
481
+ specificity. Without the flag this rule was inert: MEASURED off the rendered markup, the keyword
482
+ stayed at the body rung's 36px through three separate reductions of the number below, while
483
+ `labelBoxWidth` — which comes from config at layout time and IS scaled from it — shrank each
484
+ time. The visible result was the word overflowing its own tab, and the cause looked like the tab
485
+ being too small rather than the text never having moved.
486
+
487
+ Everything else in this list wins on source order, because mermaid states it in its own
488
+ stylesheet. This one it states on the element.
489
+ */
490
+ `.labelText,.labelText>tspan{font-size:${TAG_FONT_PX}px!important;}`,
491
+ /*
492
+ ★★ AND THE KEYWORD SITS LOW IN ITS OWN TAB — BUT `dominant-baseline` CANNOT TAKE IT BACK. mermaid
493
+ writes `alignment-baseline="middle"` as a PRESENTATION ATTRIBUTE, and in Chrome that beats a
494
+ `dominant-baseline` declaration at any importance, so the `central` this line used to carry
495
+ never rendered at all (measured: em box top 306.5 with the attribute, 308.5 without — `central`
496
+ would have pushed the word two units LOWER still).
497
+
498
+ The drop is mermaid's own arithmetic, so cancel it directly: the word was landing half on the
499
+ tab's own hairline stroke, and the audit scored its ink against that band at 4.39:1 rather than
500
+ against the fill. Translated back, every keyword clears both hairlines — worst case "critical",
501
+ 3.1 units of daylight at the top — and every glyph pixel sits on the fill at 13.36:1. Same lever
502
+ `.sequenceNumber` below already uses, for the same class of bug.
503
+ */
504
+ `.labelText{alignment-baseline:middle!important;transform:translateY(-${TAG_BASELINE_DROP}px);}`,
505
+ /*
506
+ ★★ AUTONUMBER IS THREE HARD-CODED VALUES IN THE MARKUP, and no config key reaches any of them.
507
+ mermaid writes the index as
508
+ <text font-family="sans-serif" font-size="12px" class="sequenceNumber">1</text>
509
+ inside a `<circle r="6">` — 12px and a 6-unit disc, both sized for its own 16px era, beside this
510
+ deck's 36px type. Its own stylesheet sets only `.sequenceNumber { fill }`, so the size is not
511
+ something a theme variable can move.
512
+
513
+ They are PRESENTATION ATTRIBUTES, which any rule beats — the same lever the ER edge label above
514
+ needs. The number takes the fragment keyword's rung: it is an index on a line rather than part
515
+ of the diagram's prose, subordinate in exactly the same way. The disc follows the type at
516
+ mermaid's own ratio, r = half the font size, so a two-digit index still fits the way it does at
517
+ 12px.
518
+ */
519
+ `.sequenceNumber{font-family:${MERMAID_FONT_FAMILY};font-size:${MERMAID_INDEX_FONT_PX}px;`
520
+ /*
521
+ ★★ AND THE NUMBER HAS TO BE RE-CENTRED, because mermaid positions it as a BASELINE. The disc is
522
+ a marker on a zero-length line at y, and the digit is a separate <text> at y + 4 — four units
523
+ below the centre, which is half a cap height at the 12px it wrote the markup for. Enlarge the
524
+ font and the glyph hangs: measured on the specimen, the digit sat about five units high in its
525
+ own disc.
526
+
527
+ `dominant-baseline: central` centres the glyph on its own y instead of sitting it on one, and
528
+ the translate takes back mermaid's baseline offset so that y is the disc's centre rather than
529
+ four below it. Both are needed: either alone leaves it off by the other's amount.
530
+ */
531
+ + `dominant-baseline:central;transform:translateY(-${SEQUENCE_NUMBER_BASELINE_DROP}px);}`,
532
+ `[id$="-sequencenumber"] circle{r:${Math.round(MERMAID_INDEX_FONT_PX * SEQUENCE_NUMBER_DISC_RATIO)}px;}`,
533
+ /*
534
+ ★★ A HOLLOW DIAMOND IS GROUND-FILLED, NOT TRANSPARENT. UML's aggregation end is an OPEN diamond
535
+ against composition's solid one, and mermaid draws "open" as no fill at all — so the relationship
536
+ line, which is drawn along the whole gap between the two classes, runs straight through it. At
537
+ mermaid's own size that reads as a small lozenge on the line; at the size this theme draws it,
538
+ it reads as a diamond with a line through the middle, which is not the notation.
539
+
540
+ The ground rather than a colour, for the same reason the edge label knocks out to one: a diagram
541
+ inside a named background has to fill to THAT ground, and the variable already carries it.
542
+
543
+ Composition is deliberately untouched — its diamond is meant to be solid, and it takes the line
544
+ colour from mermaid's own `.marker` rule.
545
+ */
546
+ `.marker.aggregation{fill:var(--deck-mermaid-label-bg,var(--deck-bg));}`,
547
+ // Pie labels — mermaid's hard-coded trebuchet stack.
548
+ `.slice{font-family:${MERMAID_FONT_FAMILY};}`,
549
+ `.pieTitleText,.legend text{font-family:${MERMAID_FONT_FAMILY};}`,
550
+ // Every drawn edge, named by family: flowchart nodes and links, sequence actors and messages,
551
+ // state, class and ER. A blanket `* { vector-effect }` would also catch the arrowhead fills and
552
+ // the pie slices, which are shapes rather than lines and read correctly as they are.
553
+ // ⚠ `ellipse` is listed because mermaid's OWN node rule lists it — `.node rect,.node circle,
554
+ // .node ellipse,.node polygon,.node path`. A shape family missing from this side is not styled
555
+ // half-way, it is left entirely on mermaid's 1px, which the diagram's own scale then draws at
556
+ // half a pixel.
557
+ `.node rect,.node polygon,.node circle,.node ellipse,.node path,.cluster rect,`
558
+ + `.task-line,rect.task,rect.journey-section,rect.actor,circle[class^="actor-"],.face,.actor-line,.messageLine0,.messageLine1,.flowchart-link,.edgePath .path,`
559
+ /* ⚠ `.er.entityBox` AND `.er.relationshipLine` WERE BOTH DEAD, and censusing the rendered
560
+ classes is what found them. In 11.17.2 there is no `entityBox` class at all — an entity is a
561
+ `g.node.default` whose box rectifyOuterPaths turns into a <rect>, so `.node rect` above
562
+ already covers it — and the relationship line carries
563
+ `edge-thickness-normal edge-pattern-solid relationshipLine` with NO `er` beside it, so the
564
+ compound selector matched nothing and the line kept mermaid's own weight. */
565
+ + `.transition,.stateGroup rect,.relationshipLine,.relation`
566
+ + `{stroke-width:${DIAGRAM_STROKE_PX};}`,
567
+ // A round cap on the line so the end that meets the chevron is the same shape the chevron's own
568
+ // arms end in. A butt cap leaves a square nub inside the V at this weight.
569
+ `.flowchart-link,.edgePath .path,.messageLine0,.messageLine1,.relation,.transition`
570
+ + `{stroke-linecap:round;stroke-linejoin:round;}`,
571
+ /*
572
+ ★★ THE LINK'S WEIGHT NEEDS ONE MORE CLASS, and which one matters. Mermaid sets it through
573
+ `.edge-thickness-normal`, which ties the rule above on specificity, so the winner is whichever
574
+ the browser sees last — not something to depend on. Naming both classes settles it.
575
+
576
+ ⚠ `.edge-thickness-thick` is deliberately NOT matched. That class is what an author asks for with
577
+ a `==>` arrow, and a rule flattening it would remove the one weight distinction mermaid offers.
578
+ */
579
+ `.flowchart-link.edge-thickness-normal,.edgePath .path.edge-thickness-normal`
580
+ + `{stroke-width:${DIAGRAM_STROKE_PX};}`,
581
+ /*
582
+ ⚠ THE GANTT'S LAST TWO SIZES. `gantt.fontSize` moves the task and section text and the layout
583
+ with it, but the chart title and the axis ticks have no key at all — mermaid draws them at a
584
+ literal 18px and 10px, which on a 1920px canvas is 10 and 5 rendered pixels. Neither has a box
585
+ laid out around it, so unlike the task text they can be moved from CSS without the bars
586
+ disagreeing. The ticks take the 0.8 step a pie legend takes, because an axis is a caption.
587
+ */
588
+ `.titleText{font-size:${MERMAID_FONT_PX}px;}`,
589
+ `.grid .tick text{font-size:${Math.round(MERMAID_FONT_PX * 0.8)}px;}`,
590
+ /*
591
+ ★★ A DASH HAS TO CLEAR ITS OWN CAP. mermaid dashes a sequence reply at `stroke-dasharray: 2,2`
592
+ and re-states it inline as `3,3`; the deck then draws that line at 4px with a ROUND cap. A round
593
+ cap adds half the stroke width at each end, so a 3-unit dash is DRAWN 7 units long with 3 units
594
+ of gap after it — the dashes overlap and the line reads solid with a bumpy edge.
595
+
596
+ The pattern is therefore derived from the stroke rather than picked: dash 2x, gap 3x. That draws
597
+ a 12-unit dash with 8 units of daylight, which reads as dashed at any of the sizes this deck
598
+ renders a diagram at.
599
+
600
+ ⚠ `!important` IS LOAD-BEARING HERE, and it is the only one in this list. mermaid puts the
601
+ sequence value in an INLINE style, which a stylesheet rule cannot outrank on specificity or on
602
+ source order. Every other rule in this file wins by being appended after mermaid's own.
603
+ */
604
+ /* ★★ `.edge-pattern-dashed` BARE, NOT PER-FAMILY. It was named on the flowchart link and the
605
+ edgePath only, so a dashed CLASS relation (`..>`, `<|..`) and a dashed ER relationship (`..`)
606
+ took the 4px stroke and the round cap from the rules above and mermaid's own 3-unit dash —
607
+ which is the exact overlap this rule exists to prevent: half the stroke is added at each
608
+ end, so a 3-unit dash draws 7 long with 3 of gap and the line reads solid. Every family
609
+ spells the class the same way, so one bare selector is the honest scope. `.relation` is
610
+ named too: the class diagram sets its dash inline rather than through the pattern class. */
611
+ `.messageLine1,.loopLine,.task-line,.edge-pattern-dashed,.relation.edge-pattern-dashed`
612
+ + `{stroke-dasharray:${DIAGRAM_STROKE_PX * 2}px ${DIAGRAM_STROKE_PX * 3}px !important;}`,
613
+ /*
614
+ A dot is the same mechanism with no dash at all — the round cap IS the dot, so the length is a
615
+ hair above zero and the gap carries the rhythm.
616
+
617
+ ⚠ `-.->` EMITS `edge-pattern-DOTTED`, measured — not `dashed`, which is the name the arrow's
618
+ look suggests. So this rule is the one a flowchart actually exercises (the specimen draws it),
619
+ and `edge-pattern-dashed` above is matched but drawn by nothing here; the sequence reply is what
620
+ evidences that half.
621
+ */
622
+ `.flowchart-link.edge-pattern-dotted,.edgePath .path.edge-pattern-dotted`
623
+ + `{stroke-dasharray:0.01px ${DIAGRAM_STROKE_PX * 2.5}px !important;stroke-linecap:round;}`,
624
+ /*
625
+ ★★ A SEQUENCE MESSAGE LABEL IS CENTRED ON ITS EM BOX, AND SITS UNDER THE WRONG LINE BECAUSE OF
626
+ IT. Measured on the specimen: 24.1 units of air above the label and 40.8 below, in a 110-unit
627
+ band — so the label is nearly twice as far from the line it NAMES as from the previous message's.
628
+
629
+ The em box itself is close to centred; what is not is the INK. These labels carry no descenders,
630
+ so the descender space the em box reserves is empty and reads as a gap the type does not fill.
631
+ Shifting by a fraction of an em corrects the ink rather than the box, which is why it is stated
632
+ in `em` and holds at any size — `dy` would have to be recomputed per font size.
633
+ */
634
+ /*
635
+ An edge label knocks its line out rather than veiling it. mermaid gives the label's backing rect
636
+ `opacity: 0.5` — element opacity, not fill-opacity — so the edge runs through the word at half
637
+ strength and the label reads as printed on tracing paper. At this deck's weight, where that edge
638
+ is 4px rather than 1, the ghost line is thicker than the letterforms it crosses.
639
+
640
+ Only the opacity moves: the FILL is already right. mermaid takes it from `edgeLabelBackground`,
641
+ which this theme points at the page's own ground per register, so the rect is already the exact
642
+ colour of the paper behind it and going opaque simply finishes the knockout.
643
+ */
644
+ `.edgeLabel rect,.edgeLabel .label rect,.icon-shape .label rect,.image-shape .label rect`
645
+ + `{opacity:1;}`,
646
+ `.messageText{transform:translateY(${MESSAGE_INK_DROP}em);}`,
647
+ // Both placements, because both are laid out by the formula above — an outside label is moved
648
+ // sideways, not vertically, so it carries the same drop.
649
+ `.taskText,.taskTextOutsideLeft,.taskTextOutsideRight{transform:translateY(-${GANTT_LABEL_RISE}px);}`,
650
+ // A journey's actor dot is drawn at r=7 for 14px type. Beside the deck's rung it reads as a
651
+ // speck, so it takes a third of the label's size — `r` is a CSS geometry property, so no surgery
652
+ // is needed. Scoped to `circle` because a sequence diagram's `actor-line` shares the prefix.
653
+ `circle[class^="actor-"]{r:${Math.round(MERMAID_FONT_PX / 3)}px;}`,
654
+ // Rectangular nodes take the deck's corner.
655
+ `.node rect,.cluster rect,rect.actor,.stateGroup rect,`
656
+ + `rect.task,rect.journey-section`
657
+ + `{rx:${DIAGRAM_RADIUS}px;ry:${DIAGRAM_RADIUS}px;}`,
658
+ /*
659
+ ★★ THE STATE DIAGRAM NEEDS ITS OWN NAMES, and without them it is the one family in this deck
660
+ that draws square. mermaid states its corner as `.statediagram-state rect.basic` — (1,2,1)
661
+ against the (1,1,1) of `.node rect` above, so the generic rule loses and a state box keeps
662
+ mermaid's 5. Repeating mermaid's own selectors TIES on specificity, and these rules are appended
663
+ to the stylesheet it generated, so source order settles it.
664
+ */
665
+ `.statediagram-state rect.basic,.statediagram-state .title-state,`
666
+ + `.statediagram-cluster rect.outer,.statediagram-note rect`
667
+ + `{rx:${DIAGRAM_RADIUS}px;ry:${DIAGRAM_RADIUS}px;}`,
668
+ // The rounded rhombus is a <path> by the time it renders (see roundPolygons), and a path takes its
669
+ // corners from the geometry rather than from CSS. The join is still stated so the stroke follows
670
+ // the curve cleanly where two arcs meet a short edge.
671
+ `.node polygon,.node path{stroke-linejoin:round;}`,
672
+ ];
673
+
674
+ /*
675
+ ── THE ARROWHEAD ──────────────────────────────────────────────────────────────────────────────
676
+
677
+ Mermaid sizes its heads with `markerWidth`/`markerHeight` at `markerUnits="userSpaceOnUse"`, which
678
+ means two things: the head does NOT grow with stroke width the way a default marker would, and it
679
+ DOES shrink with the diagram, because user space is what fitToColumn scales. Measured on the
680
+ specimen's flowchart, an 8-unit head on a diagram drawn at 0.544 renders about 10 canvas px — a
681
+ tenth of the label beside it, against a line that is now three.
682
+
683
+ The box is scaled and the viewBox is not: the head's geometry is in viewBox units and is fitted
684
+ into the marker box, so multiplying the box enlarges the drawing.
685
+
686
+ ★★ AND `refX` HAS TO MOVE WITH IT, WHICH IS THE WHOLE DIFFICULTY. `refX` names the point on the
687
+ marker that sits on the line's endpoint, and mermaid puts it mid-head — so the tip already extends
688
+ PAST the endpoint by design, and the line is drawn short to match. Scaling the box multiplies that
689
+ overhang too, and the tip lands inside the node's border rather than against it.
690
+
691
+ So the anchor is moved to hold the tip where it was and let the head grow BACKWARD along the line.
692
+ Which end is the tip comes from the marker's own name — mermaid names them `…End` and `…Start` —
693
+ and the arithmetic is the same both ways: keep the tip's offset from the anchor constant in
694
+ rendered units while the box grows by `k`.
695
+
696
+ End (tip at the viewBox's right edge) refX' = vbW - (vbW - refX) / k
697
+ Start (tip at the left edge) refX' = refX / k
698
+
699
+ A head already anchored at its own tip — mermaid's `pointEnd-margin`, refX 11.5 of 11.5 — comes out
700
+ unchanged, which is the arithmetic agreeing with the intent rather than a special case.
701
+ */
702
+ export const ARROWHEAD_SCALE = 1.9;
703
+
704
+ /*
705
+ ★ ONLY THE ARROW MARKERS. A mermaid diagram's <defs> holds every marker the family can use, and
706
+ most are not arrowheads: `sequencenumber` is a numbered badge, `circleEnd` and `crossEnd` are
707
+ terminators with their own meaning. Scaling all of them turned the sequence diagram's number badge
708
+ from 60 units to 114. The name is the filter, because mermaid names them plainly.
709
+ */
710
+ /* ⚠ NO WORD BOUNDARIES AND NO /i. `\bpoint\b` does not match `pointEnd` — 't' and 'E' are both word
711
+ characters, so there is no boundary between them — and case matters: `crosshead` is the sequence
712
+ diagram's X terminator and must not be opened into a chevron, which a case-insensitive `head`
713
+ would catch. Each name is matched as mermaid spells it. */
714
+ /*
715
+ ★★ SHAPE AND SIZE ARE TWO DIFFERENT QUESTIONS, AND THE DIAMOND ANSWERS THEM DIFFERENTLY.
716
+
717
+ `aggregationEnd` is a hollow diamond and `compositionEnd` a filled one. Opening either into a
718
+ chevron would say "arrow" where UML says "part of", so they are absent from the SHAPE list — but
719
+ they were absent from the SIZE list too, and nothing justified that. Every arrow in the deck is
720
+ grown to the deck's type while the diamond kept mermaid's stock 17-unit path, so `Bay o-- Vehicle`
721
+ drew a marker a third the weight of the arrowhead on the line beside it.
722
+
723
+ ★★ AND THE DIAMOND CANNOT BORROW THE ARROWS' SIZING. viewBoxArrowheads reads markerWidth as the
724
+ size the content already had, which holds for an arrow — `pointEnd` declares 8 to 20 against a
725
+ 10-unit path. mermaid declares `aggregationStart` at markerWidth=190, markerHeight=240 for the
726
+ SAME 17-unit path it gives `aggregationEnd` at 20x28. Handing that to the arrow path would map 17
727
+ units into 190 and draw a diamond eleven times too big. sizeDiamondMarkers reads the extent off
728
+ the path's own `d` instead, which is the one measurement mermaid is consistent about.
729
+ */
730
+ /*
731
+ ⚠ THE ER CROW'S FEET ARE NOT IN HERE, AND MUST NOT BE — `markerUnits` is why. MEASURED off the
732
+ rendered ER diagram:
733
+
734
+ <marker class="marker onlyOne er" refX="0" refY="9" markerWidth="18" markerHeight="18"
735
+ orient="auto"><path d="M9,0 L9,18 M15,0 L15,18"/></marker>
736
+
737
+ No `markerUnits` attribute, so it takes the SVG default of `strokeWidth` — the marker is scaled by
738
+ the line it ends, and this deck's line is 4 units, so it already tracks the deck's weight with no
739
+ help. The class diagram's diamond declares `markerUnits="userSpaceOnUse"`, which opts out of that
740
+ scaling, and is the only reason it needed sizing by hand.
741
+
742
+ Adding the crow's feet to this list multiplied an already-scaled marker by ARROWHEAD_SCALE again:
743
+ they rendered several times the height of the entity they pointed at. A marker belongs here only
744
+ if it declares `userSpaceOnUse`; check before adding one.
745
+ */
746
+ const DIAMOND_MARKER = /(aggregationStart|aggregationEnd|compositionStart|compositionEnd)/;
747
+ /*
748
+ ★★ EVERY FAMILY NAMES ITS ARROWHEAD SOMETHING ELSE, and a family missing from this list keeps
749
+ mermaid's own — a filled triangle at its 16px-era size, next to the deck's chevrons. CENSUSED off
750
+ the rendered SVG of every diagram in the specimen rather than guessed:
751
+
752
+ flowchart pointEnd / pointStart
753
+ sequence arrowhead / filled-head
754
+ state barbEnd ← was missing, and it is the same plain arrow
755
+ class extensionStart / extensionEnd, dependencyStart / dependencyEnd
756
+
757
+ The rest of the census is shapes, not arrows, and is listed under NOTATION_MARKER below.
758
+ */
759
+ const ARROW_MARKER = /(pointEnd|pointStart|arrowhead|ArrowHead|filled-head|barbEnd|extensionStart|extensionEnd|dependencyStart|dependencyEnd)/;
760
+
761
+ /**
762
+ * Size a class diagram's diamond ends to the deck's type, keeping their shape.
763
+ *
764
+ * refX/refY are left where mermaid put them: with a viewBox they are read in the path's own
765
+ * coordinates, which is exactly what they already were.
766
+ */
767
+ /*
768
+ ── THE SHAPE MARKERS THAT SCALE WITH THE LINE, AND THEREFORE WITH THE DECK'S 4px ──────────────
769
+
770
+ A <marker> with no `markerUnits` takes the SVG default, `strokeWidth`: everything it draws is
771
+ multiplied by the width of the line it sits on. mermaid draws the ER cardinalities and the class
772
+ lollipop for its own 1px line, so at this deck's 4px they render FOUR TIMES the size they were
773
+ drawn at — a crow's foot taller than the entity it points at.
774
+
775
+ MEASURED: `<marker class="marker onlyOne er" markerWidth="18" markerHeight="18">` with no
776
+ markerUnits, on a 4-unit line, is 72 units of marker.
777
+
778
+ The cure is to take them out of that space — `userSpaceOnUse` plus a viewBox, so the box is the
779
+ size and the content maps into it — and to draw them at the same scale the aggregation diamond
780
+ ends up: its 17-unit path at ARROWHEAD_SCALE is about 32, so an 18-unit crow's foot lands at 34
781
+ and the two families' line ends finally match each other.
782
+
783
+ ⚠ THE viewBox IS NOT OPTIONAL. Without one, markerWidth sets the CLIP and not the scale, so the
784
+ marker would keep its 18 units inside a 34-unit window — the same trap viewBoxArrowheads exists
785
+ for on the arrowheads.
786
+ */
787
+ const STROKE_SCALED_MARKER = /(onlyOne|zeroOrOne|oneOrMore|zeroOrMore|lollipop|circleEnd|circleStart|crossEnd|crossStart)/;
788
+
789
+ export function unscaleStrokeMarkers(svg: string): string {
790
+ return svg.replace(/<marker\b[^>]*>/g, (tag) => {
791
+ if (!STROKE_SCALED_MARKER.test(tag) || /markerUnits="userSpaceOnUse"/.test(tag) || /viewBox=/.test(tag)) return tag;
792
+ const w = Number(/\bmarkerWidth="([\d.]+)"/.exec(tag)?.[1]);
793
+ const h = Number(/\bmarkerHeight="([\d.]+)"/.exec(tag)?.[1]);
794
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return tag;
795
+ return tag
796
+ .replace(/<marker\b/, `<marker viewBox="0 0 ${w} ${h}"`)
797
+ .replace(/\bmarkerUnits="[^"]*"/, '')
798
+ .replace(/<marker\b/, '<marker markerUnits="userSpaceOnUse"')
799
+ .replace(/\bmarkerWidth="[\d.]+"/, `markerWidth="${(w * ARROWHEAD_SCALE).toFixed(2)}"`)
800
+ .replace(/\bmarkerHeight="[\d.]+"/, `markerHeight="${(h * ARROWHEAD_SCALE).toFixed(2)}"`);
801
+ });
802
+ }
803
+
804
+ /*
805
+ ── THE TIMELINE'S BANDS CARRY THEIR CORNER IN THE PATH ────────────────────────────────────────
806
+
807
+ Every other node in this deck takes its 28px corner from CSS, because it is a `<rect>` and `rx`
808
+ reaches it. A timeline band is not: mermaid emits `<path class="node-bkg">` whose `d` already
809
+ contains the curve — `M0 H v-(H-5) q0,-5,5,-5 h W q5,0,5,5 v H H0 Z` — a 5-unit radius on the TOP
810
+ two corners only, square along the bottom. No rule can move it, so a timeline sat among rounded
811
+ cards with square ones.
812
+
813
+ Rewritten as a full rounded rect over the same box. Guarded on the exact template mermaid emits:
814
+ anything else keeps whatever it drew, which is the same rule roundPolygons follows for the shapes
815
+ whose outline is their meaning.
816
+ */
817
+ export function roundTimelineNodes(svg: string, radius: number): string {
818
+ return svg.replace(
819
+ /(<path\b[^>]*class="[^"]*\bnode-bkg\b[^"]*"[^>]*\bd=")M0 ([\d.]+) v-[\d.]+ q0,-5,5,-5 h([\d.]+) q5,0,5,5 v[\d.]+ H0 Z(")/g,
820
+ (whole, pre: string, hRaw: string, wRaw: string, post: string) => {
821
+ const h = Number(hRaw);
822
+ const w = Number(wRaw) + 10;
823
+ if (!Number.isFinite(h) || !Number.isFinite(w) || h <= 0 || w <= 0) return whole;
824
+ const r = Math.min(radius, w / 2, h / 2);
825
+ const f = (n: number) => Number(n.toFixed(2));
826
+ const d = `M ${f(r)},0 h ${f(w - r * 2)} a ${f(r)},${f(r)} 0 0 1 ${f(r)},${f(r)}`
827
+ + ` v ${f(h - r * 2)} a ${f(r)},${f(r)} 0 0 1 ${f(-r)},${f(r)}`
828
+ + ` h ${f(-(w - r * 2))} a ${f(r)},${f(r)} 0 0 1 ${f(-r)},${f(-r)}`
829
+ + ` v ${f(-(h - r * 2))} a ${f(r)},${f(r)} 0 0 1 ${f(r)},${f(-r)} Z`;
830
+ return `${pre}${d}${post}`;
831
+ },
832
+ );
833
+ }
834
+
835
+ export function sizeDiamondMarkers(svg: string): string {
836
+ return svg.replace(/<marker\b[^>]*>[\s\S]*?<\/marker>/g, (whole) => {
837
+ const tag = /^<marker\b[^>]*>/.exec(whole)?.[0] ?? '';
838
+ if (!DIAMOND_MARKER.test(tag) || /viewBox=/.test(tag)) return whole;
839
+ const d = /<path\b[^>]*\bd="([^"]+)"/.exec(whole)?.[1];
840
+ const nums = d?.match(/-?\d*\.?\d+/g)?.map(Number);
841
+ if (!nums || nums.length < 4 || nums.length % 2 || nums.some(Number.isNaN)) return whole;
842
+ const xs = nums.filter((_, i) => i % 2 === 0), ys = nums.filter((_, i) => i % 2 === 1);
843
+ const minX = Math.min(...xs), minY = Math.min(...ys);
844
+ /* ★ THE BOX IS THE EXTENT PLUS THE PATH'S OWN INSET, NOT THE EXTENT. A viewBox tight to the
845
+ path puts all four vertices exactly on the edge, and the stroke straddles it — measured on
846
+ an export, the diamond came out as an open chevron with its right and bottom points shaved
847
+ off. mermaid already draws the shape inset by minX/minY inside its box; mirroring that inset
848
+ on the far side is what gives the stroke somewhere to sit. */
849
+ const w = Math.max(...xs) + minX, h = Math.max(...ys) + minY;
850
+ if (!(w > 0 && h > 0)) return whole;
851
+ /* ★★ AND THE ANCHOR HAS TO MOVE WITH IT, or the shape grows the wrong way. mermaid anchors the
852
+ Start diamond at its FAR vertex (refX=18 on an 18-wide path), so the body hangs backward off
853
+ the line's first point — which is the owning class's own edge. At mermaid's size that fits in
854
+ the gap; at 1.9x it does not, and the export showed the left half of the diamond painted over
855
+ by the node, leaving a shape that reads as an arrowhead. Anchoring the NEAR vertex instead
856
+ grows it along the line, into the gap that is already empty. */
857
+ const anchor = /\bid="[^"]*Start[^"]*"/.test(tag) ? minX : Math.max(...xs);
858
+ const sized = tag
859
+ .replace(/<marker\b/, `<marker viewBox="0 0 ${w} ${h}"`)
860
+ .replace(/\brefX="[\d.]+"/, `refX="${anchor}"`)
861
+ .replace(/\bmarkerWidth="[\d.]+"/, `markerWidth="${(w * ARROWHEAD_SCALE).toFixed(2)}"`)
862
+ .replace(/\bmarkerHeight="[\d.]+"/, `markerHeight="${(h * ARROWHEAD_SCALE).toFixed(2)}"`);
863
+ return sized + whole.slice(tag.length);
864
+ });
865
+ }
866
+
867
+ /*
868
+ ── THE HEAD IS A CHEVRON ──────────────────────────────────────────────────────────────────────
869
+
870
+ Mermaid draws a filled triangle: `M 0 0 L 10 5 L 0 10 z`, closed and filled. The same three points
871
+ without the close, stroked rather than filled, are a chevron — an open V that reads as a direction
872
+ rather than a blob, and that carries the line's weight into its tip instead of ending in a mass.
873
+
874
+ ★★ AND THE HEAD IS STROKED IN UNITS LIKE THE LINE IS, which is the only way the two can agree.
875
+ A marker's contents live in viewBox units and the link's stroke now does too, so both are scaled
876
+ by the diagram together and the head is stroked at exactly the link's width, by construction
877
+ rather than by tuning. Measured when they disagreed: a 2.2-unit chevron rendered 1px against a
878
+ 3px line, and the ratio moved with every diagram's scale.
879
+ */
880
+
881
+ /*
882
+ ── THE CHEVRON'S ANGLE ────────────────────────────────────────────────────────────────────────
883
+
884
+ Mermaid's head is a long, narrow triangle — `M 0 0 L 10 5 L 0 10`, arms 10 units back for 5 across,
885
+ which is a 53-degree V. Opened into a chevron that reads as a dart rather than an arrow.
886
+
887
+ Widening it is done in the shape's own terms rather than by substituting a hand-written path, so it
888
+ survives mermaid drawing a different triangle: the tip is the vertex furthest from the midpoint of
889
+ the other two, and each arm is shortened along the AXIS (tip to base-midpoint) while its
890
+ perpendicular spread is left alone. Less axial length for the same spread is a wider angle, and the
891
+ head keeps its width, its anchor and its tip exactly where they were — so none of the arithmetic
892
+ that lands the tip on the node's border has to change.
893
+ */
894
+ const CHEVRON_AXIAL = 0.62;
895
+
896
+ /*
897
+ ★ A TRIANGLE IS NOT ALWAYS SPELLED AS SIX NUMBERS. The flowchart writes its head the long way,
898
+ `M 0 0 L 10 5 L 0 10 z`, but the class diagram's inheritance end writes `M 1,7 L18,13 V 1 Z` —
899
+ the third point's x is implied by `V`, so a naive number count sees five and gives up. Expanding
900
+ the shorthand first is what lets one chevron routine serve both.
901
+ */
902
+ function trianglePoints(d: string): [number, number][] | null {
903
+ const tokens = d.match(/[MLHVmlhv]|-?[\d.]+/g);
904
+ if (!tokens) return null;
905
+ const pts: [number, number][] = [];
906
+ let cmd = '';
907
+ let i = 0;
908
+ while (i < tokens.length) {
909
+ if (/[MLHVmlhv]/.test(tokens[i])) { cmd = tokens[i]; i += 1; continue; }
910
+ const last = pts[pts.length - 1] ?? [0, 0];
911
+ if (cmd === 'M' || cmd === 'L') {
912
+ pts.push([Number(tokens[i]), Number(tokens[i + 1])]); i += 2;
913
+ } else if (cmd === 'm' || cmd === 'l') {
914
+ pts.push([last[0] + Number(tokens[i]), last[1] + Number(tokens[i + 1])]); i += 2;
915
+ } else if (cmd === 'H') { pts.push([Number(tokens[i]), last[1]]); i += 1; }
916
+ else if (cmd === 'h') { pts.push([last[0] + Number(tokens[i]), last[1]]); i += 1; }
917
+ else if (cmd === 'V') { pts.push([last[0], Number(tokens[i])]); i += 1; }
918
+ else if (cmd === 'v') { pts.push([last[0], last[1] + Number(tokens[i])]); i += 1; }
919
+ else return null;
920
+ }
921
+ if (pts.length === 3) return pts;
922
+ /*
923
+ ★★ AND THE STATE DIAGRAM'S HEAD IS A TRIANGLE WITH A NOTCH IN IT. `barbEnd` is
924
+ `M 19,7 L9,13 L14,7 L9,1 Z` — four points, where (14,7) sits INSIDE the other three and cuts the
925
+ concave barb out of the base. Rejecting it for having four points left the state diagram with
926
+ mermaid's own filled head beside the flowchart's chevrons, and adding it to the size transforms
927
+ alone just drew that head 1.9x bigger. Dropping the interior vertex leaves the same triangle
928
+ every other family spells directly, and one chevron routine serves them all.
929
+ */
930
+ if (pts.length === 4) {
931
+ const inside = pts.findIndex((p, i) => {
932
+ const [a, b, c] = pts.filter((_, j) => j !== i);
933
+ const sign = (u: number[], v: number[], w: number[]) => (u[0] - w[0]) * (v[1] - w[1]) - (v[0] - w[0]) * (u[1] - w[1]);
934
+ const d1 = sign(p, a, b), d2 = sign(p, b, c), d3 = sign(p, c, a);
935
+ return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
936
+ });
937
+ if (inside >= 0) return pts.filter((_, i) => i !== inside) as [number, number][];
938
+ }
939
+ return null;
940
+ }
941
+
942
+ export function widenChevron(d: string): string {
943
+ const pts = trianglePoints(d);
944
+ if (!pts) return d;
945
+ /*
946
+ ★★ THE TIP IS THE APEX OF THE ISOSCELES, NOT THE VERTEX FURTHEST FROM THE OTHER TWO.
947
+
948
+ An arrowhead is symmetric about the line it sits on, so its tip is the one vertex whose two
949
+ edges are equal. That was originally written as "furthest from the midpoint of the other two",
950
+ which is the same vertex ONLY while the triangle is longer than it is wide — and mermaid does
951
+ not guarantee that. MEASURED on `stateDiagram-barbEnd`, whose triangle is 10 long and 12 across:
952
+
953
+ tip (19,7) -> midpoint of the base (9,7) distance 10
954
+ base (9,13) -> midpoint of the others distance 10.3 ← wins, wrongly
955
+
956
+ So the chevron was built around a base corner and the state diagram drew backward ticks that sat
957
+ beside the line instead of on it. By edge symmetry the same barb gives 11.66 / 11.66 at the tip
958
+ against 11.66 / 12 at either base corner, and the flowchart's own head still resolves the same
959
+ way it always did.
960
+ */
961
+ let tipIndex = 0, best = Infinity;
962
+ for (let i = 0; i < 3; i++) {
963
+ const [a, b] = [pts[(i + 1) % 3], pts[(i + 2) % 3]];
964
+ const skew = Math.abs(
965
+ Math.hypot(pts[i][0] - a[0], pts[i][1] - a[1]) - Math.hypot(pts[i][0] - b[0], pts[i][1] - b[1]),
966
+ );
967
+ if (skew < best) { best = skew; tipIndex = i; }
968
+ }
969
+ const tip = pts[tipIndex];
970
+ const arms = [pts[(tipIndex + 1) % 3], pts[(tipIndex + 2) % 3]];
971
+ const mid = [(arms[0][0] + arms[1][0]) / 2, (arms[0][1] + arms[1][1]) / 2];
972
+ const axisLen = Math.hypot(mid[0] - tip[0], mid[1] - tip[1]) || 1;
973
+ const axis = [(mid[0] - tip[0]) / axisLen, (mid[1] - tip[1]) / axisLen];
974
+ const moved = arms.map(([x, y]) => {
975
+ const v = [x - tip[0], y - tip[1]];
976
+ const along = v[0] * axis[0] + v[1] * axis[1];
977
+ const par = [axis[0] * along * CHEVRON_AXIAL, axis[1] * along * CHEVRON_AXIAL];
978
+ const perp = [v[0] - axis[0] * along, v[1] - axis[1] * along];
979
+ return [tip[0] + par[0] + perp[0], tip[1] + par[1] + perp[1]];
980
+ });
981
+ const f = (n: number) => n.toFixed(2);
982
+ // Drawn arm-tip-arm so the join at the point is a single stroke join rather than two ends meeting.
983
+ return `M ${f(moved[0][0])} ${f(moved[0][1])} L ${f(tip[0])} ${f(tip[1])} L ${f(moved[1][0])} ${f(moved[1][1])}`;
984
+ }
985
+
986
+ export function chevronArrowheads(svg: string, strokePx = DIAGRAM_STROKE_PX): string {
987
+ return svg.replace(/<marker\b([^>]*)>([\s\S]*?)<\/marker>/g, (whole, attrs, body) => {
988
+ if (!ARROW_MARKER.test(attrs)) return whole;
989
+ // Only a closed triangle is convertible; anything else keeps whatever mermaid drew.
990
+ const opened = body.replace(/\sd="([^"]*?)\s*[zZ]"/g, (_m: string, d: string) => ` d="${widenChevron(d.trim())}"`);
991
+ if (opened === body) return whole;
992
+ /*
993
+ ⚠ `overflow="visible"`, OR THE POINT IS CLIPPED FLAT. A <marker> clips to its own viewport by
994
+ default, and the tip sits ON the viewBox edge — mermaid's `M 0 0 L 10 5 L 0 10` in a 0..10 box.
995
+ A filled triangle is entirely inside that box, but a STROKED chevron is not: the stroke extends
996
+ half its width past the geometry, and at a non-scaling 4px that is far outside a box only 10
997
+ units wide. The join at the tip is cut off square, which reads as a chopped arrow.
998
+ */
999
+ const opened2 = attrs.includes('overflow=') ? attrs : `${attrs} overflow="visible"`;
1000
+ /*
1001
+ ★★ `fill` GOES IN THE STYLE, NOT IN AN ATTRIBUTE, AND THAT IS THE WHOLE BUG.
1002
+
1003
+ A presentation attribute is the LOWEST-priority way to set an SVG property — any stylesheet
1004
+ rule beats it. mermaid ships `.marker { fill: <lineColor>; stroke: <lineColor> }`, so
1005
+ `fill="none"` on the path was overridden and the chevron was filled back in: the arms and
1006
+ their round joins closed over the gap and the head read as a solid triangle again. Measured on
1007
+ the dark sequence slide at 260dpi — the V is there under the fill, which is why the geometry
1008
+ looked right in every unit test of widenChevron().
1009
+
1010
+ An inline style beats a plain rule, which is what this needs. Nothing here uses `!important`;
1011
+ if mermaid ever sets fill with one, the value it forces is `none` anyway.
1012
+ */
1013
+ /*
1014
+ ★★ AND THE HEAD'S WEIGHT IS DIVIDED BY THE MARKER'S OWN MAGNIFICATION. growArrowheads sets
1015
+ markerWidth to ARROWHEAD_SCALE times the viewBox, so everything the marker draws — the stroke
1016
+ included — is drawn that much larger. Under `non-scaling-stroke` that did not matter, because
1017
+ the width was pinned to canvas pixels whatever the marker did. In units it matters exactly:
1018
+ the head came out 1.9x the weight of the line it ends. Dividing here lands it back on the
1019
+ line's own width once the marker has scaled it.
1020
+ */
1021
+ const stroked = opened.replace(/<path\b/g,
1022
+ '<path style="fill:none;stroke-width:'
1023
+ + `${Number((strokePx / ARROWHEAD_SCALE).toFixed(3))}" stroke-linecap="round" stroke-linejoin="round"`);
1024
+ return `<marker${opened2}>${stroked}</marker>`;
1025
+ });
1026
+ }
1027
+
1028
+ /*
1029
+ ── EVERY ARROW MARKER GETS A viewBox, WHICH IS WHAT MAKES THEM ONE ARROWHEAD ──────────────────
1030
+
1031
+ A <marker> WITHOUT a viewBox does not scale its content: markerWidth/Height set the clip and
1032
+ nothing else, so growing the box grows the window and leaves the path the size it was. WITH one,
1033
+ the content is mapped into the box and grows with it.
1034
+
1035
+ Mermaid is inconsistent about this, and that inconsistency is the whole defect. MEASURED in a
1036
+ rendered deck:
1037
+
1038
+ flowchart pointEnd viewBox="0 0 10 10" markerWidth=8 -> 10 units drawn into 8px
1039
+ sequence arrowhead (none) markerWidth=12 -> 10 units drawn at 10px
1040
+ class extensionEnd (none) markerWidth=20 -> 10 units drawn at 10px
1041
+
1042
+ All three then take the same non-scaling 4px stroke. At the flowchart's scale that is a clean V;
1043
+ at 10px, with CHEVRON_AXIAL shortening the arms first, the two round caps meet at the point and
1044
+ the head reads as a blob rather than an arrow. Giving the bare ones a viewBox equal to the size
1045
+ they already had puts every family on the flowchart's footing before anything is grown, so one
1046
+ arrowhead is drawn deck-wide.
1047
+ */
1048
+ export function viewBoxArrowheads(svg: string): string {
1049
+ return svg.replace(/<marker\b[^>]*>/g, (tag) => {
1050
+ if (!ARROW_MARKER.test(tag) || /viewBox=/.test(tag)) return tag;
1051
+ const w = Number(/\bmarkerWidth="([\d.]+)"/.exec(tag)?.[1]);
1052
+ const h = Number(/\bmarkerHeight="([\d.]+)"/.exec(tag)?.[1]);
1053
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return tag;
1054
+ return tag.replace(/<marker\b/, `<marker viewBox="0 0 ${w} ${h}"`);
1055
+ });
1056
+ }
1057
+
1058
+ export function growArrowheads(svg: string): string {
1059
+ return svg.replace(/<marker\b[^>]*>/g, (tag) => {
1060
+ if (!ARROW_MARKER.test(tag)) return tag;
1061
+ const vb = /viewBox="([^"]+)"/.exec(tag)?.[1]?.trim().split(/[\s,]+/).map(Number);
1062
+ const vbW = vb?.length === 4 ? vb[2] : Number.NaN;
1063
+ const grown = tag.replace(/\b(markerWidth|markerHeight)="([\d.]+)"/g,
1064
+ (_m, attr, value) => `${attr}="${(Number(value) * ARROWHEAD_SCALE).toFixed(2)}"`);
1065
+ // Without a viewBox there is no coordinate space to move the anchor in, and leaving it put is
1066
+ // the honest fallback: a bigger head in the same place beats one placed by guesswork.
1067
+ if (!Number.isFinite(vbW) || vbW <= 0) return grown;
1068
+ const pointsBackward = /\bid="[^"]*Start[^"]*"/.test(tag);
1069
+ return grown.replace(/\brefX="([\d.]+)"/, (_m, value) => {
1070
+ const refX = Number(value);
1071
+ const moved = pointsBackward ? refX / ARROWHEAD_SCALE : vbW - (vbW - refX) / ARROWHEAD_SCALE;
1072
+ return `refX="${moved.toFixed(3)}"`;
1073
+ });
1074
+ });
1075
+ }
1076
+
1077
+ /*
1078
+ ── ROUNDING A POLYGON ─────────────────────────────────────────────────────────────────────────
1079
+
1080
+ SVG gives `rx` to <rect> and to nothing else, so mermaid's decision node — a rhombus, drawn as a
1081
+ <polygon> — has no corner radius available to it. `stroke-linejoin: round` rounds the OUTLINE's
1082
+ joins, and the radius that buys is half the stroke width: under 2px here, which is not a rounded
1083
+ shape, it is a slightly soft point.
1084
+
1085
+ So the points are rewritten as a path. At each vertex the two edges are trimmed by `r` and joined
1086
+ with a quadratic whose control point is the original corner — the standard construction, and the
1087
+ one whose curvature matches a rect's `rx` closely enough that a diamond and a box on the same slide
1088
+ read as the same family.
1089
+
1090
+ ★ `r` IS CLAMPED PER CORNER to half the shorter adjoining edge. A rhombus node sized to a short
1091
+ label has edges shorter than the radius, and an untrimmed corner would overshoot into the next one
1092
+ and turn the shape inside out.
1093
+
1094
+ Every <polygon> mermaid emits is a node shape — rhombus, hexagon, parallelogram, trapezoid — so
1095
+ this applies to all of them rather than singling out the diamond. Pie slices and arrowheads are
1096
+ <path> already and are not touched.
1097
+ */
1098
+ /*
1099
+ ── THE EDGE LABEL ────────────────────────────────────────────────────────────────────────────
1100
+
1101
+ ★★ AN EDGE LABEL IS CENTRED FROM A HEIGHT MEASURED AT THE WRONG FONT SIZE, and the deck is what
1102
+ makes it visible. mermaid nests the label three deep:
1103
+
1104
+ g.edgeLabel translate(x, y) the path's own midpoint — correct
1105
+ g.label translate(0, -h/2) mermaid's centring
1106
+ rect.background + text the label as drawn
1107
+
1108
+ `h` there is the height mermaid RECORDED for the label, and it records it against its own
1109
+ hard-coded `.edgeLabel .label { font-size: 14px }` — the exact rule ROOM_SCALE_RULES overrides,
1110
+ after the layout has run. So the translate is half a 14px line box while the glyphs and their
1111
+ background are a 36px one, and the label hangs below the line it belongs to.
1112
+
1113
+ MEASURED on the specimen's ER diagram: `g.label` translate(0, -8.75) against a background rect of
1114
+ y -2, height 49 — 13.75 user units, 7.48 canvas px, on a line the label is supposed to bisect.
1115
+
1116
+ The repair takes the height from the rect mermaid ACTUALLY EMITTED rather than from a font size
1117
+ this file would then have to keep in step: `createFormattedText` sizes that rect from the text's
1118
+ own getBBox plus 2 units of padding, so it is the drawn box by construction, at any size.
1119
+
1120
+ ★ BOTH AXES, and x is deliberately included even though it is usually a no-op. A centred label's
1121
+ bbox is already symmetric about 0, so `-(x + w/2)` returns the translate unchanged — and where it
1122
+ is NOT symmetric (a start/end terminal label, which mermaid anchors differently) the same
1123
+ arithmetic centres it for the same reason. A rule that is inert exactly where mermaid is right is
1124
+ cheaper to trust than a second rule that decides when to fire.
1125
+ */
1126
+ /*
1127
+ ── THE PIE LEGEND ────────────────────────────────────────────────────────────────────────────
1128
+
1129
+ ★★ THE SECOND INSTANCE OF THE SAME DEFECT AS THE EDGE LABEL, and worth naming as one rather than
1130
+ as two fixes. mermaid stacks legend rows on a pitch it hard-codes for its OWN text size — the rows
1131
+ come out at `translate(x, -55 | -33 | -11 | 11 | 33)`, a 22-unit step. `pieLegendTextSize` is the
1132
+ deck's body-sm rung times 0.8, i.e. 29, so five 29-unit labels are stacked 22 units apart and every
1133
+ row overlaps the one below it.
1134
+
1135
+ The pitch is READ from the rows mermaid emitted rather than assumed to be 22: two consecutive rows
1136
+ give it, whatever mermaid changes it to.
1137
+
1138
+ ★ THE BLOCK'S CENTRE IS HELD, which is why the deviations are scaled and not the coordinates.
1139
+ mermaid centres this stack on the pie, and multiplying each `y` outright would drag the whole
1140
+ legend down by however much the spacing opened. Scaling each row's distance FROM THE MEAN opens
1141
+ the gaps symmetrically and leaves the block where it was placed.
1142
+
1143
+ A legend of one row has no pitch to read and no overlap to fix, so it is left alone.
1144
+ */
1145
+ /*
1146
+ ── THE JOURNEY'S SATISFACTION AXIS ───────────────────────────────────────────────────────────
1147
+
1148
+ ★★ THE FACE'S POSITION IS ARITHMETIC MERMAID HARD-CODES, and it is the reason this family cannot
1149
+ simply be given bigger type. From its own renderer:
1150
+
1151
+ const maxHeight = 300 + 5 * 30;
1152
+ line ... .attr("y2", maxHeight)
1153
+ drawFace(g, { cy: 300 + (5 - task.score) * 30 })
1154
+
1155
+ A score is drawn 30 units below the one above it, the column starts at 300 and ends at 450, and no
1156
+ config key reaches any of those numbers. They are sized for mermaid's default 50-unit task box,
1157
+ whose bottom lands at 298 — just above the first face.
1158
+
1159
+ A box that holds 36px type is 112 units, so its bottom sits at 360, INSIDE that column: scores 5, 4
1160
+ and 3 are drawn behind the box they belong to. And a face big enough to read across a room is
1161
+ wider than the 30-unit step between two scores, so it collides on the other side too. Both are the
1162
+ same conflict — a fixed lattice under type it was never sized for.
1163
+
1164
+ So the lattice is rebuilt from the box that actually rendered: the first face clears the box by a
1165
+ fraction of its own radius, the step is the face's own size, and the column ends below the last
1166
+ one. The score is recovered from mermaid's formula rather than re-parsed from the diagram.
1167
+
1168
+ ★ ONE TRANSFORM, NOT TWO PASSES. The face and its features are siblings, and the eyes carry
1169
+ absolute coordinates while the mouth carries its own translate — so moving the assembly by
1170
+ rewriting children would mean three different edits. Both elements instead take the SAME transform
1171
+ about the face's own centre, which moves and scales them together and cannot drift apart.
1172
+ */
1173
+ export function placeJourneyFaces(svg: string, faceDiameter: number): string {
1174
+ const FACE = /<circle\b[^>]*\bclass="face"[^>]*>(?:<\/circle>)?(\s*)<g>/g;
1175
+ if (!FACE.test(svg)) return svg;
1176
+ FACE.lastIndex = 0;
1177
+
1178
+ const attr = (tag: string, name: string) => {
1179
+ const hit = new RegExp(`\\b${name}="([-\\d.]+)"`).exec(tag);
1180
+ return hit ? Number(hit[1]) : NaN;
1181
+ };
1182
+ const box = /<rect\b[^>]*\bclass="task task-type-\d+"[^>]*>/.exec(svg)?.[0];
1183
+ if (!box) return svg;
1184
+ const boxBottom = attr(box, 'y') + attr(box, 'height');
1185
+ if (!Number.isFinite(boxBottom)) return svg;
1186
+
1187
+ const radius = faceDiameter / 2;
1188
+ const gap = radius * 0.4;
1189
+ // mermaid's own lattice, named so the arithmetic below reads against it rather than against
1190
+ // three unexplained numbers.
1191
+ const MERMAID_TOP = 300;
1192
+ const MERMAID_STEP = 30;
1193
+ const MERMAID_SCORES = 5;
1194
+ const top = boxBottom + gap + radius;
1195
+ const step = faceDiameter * 0.9;
1196
+ const bottom = top + (MERMAID_SCORES - 1) * step + radius + gap;
1197
+ const remap = (cy: number) => top + ((cy - MERMAID_TOP) / MERMAID_STEP) * step;
1198
+
1199
+ let out = svg.replace(FACE, (whole, space: string) => {
1200
+ const face = whole.slice(0, whole.indexOf('>') + 1);
1201
+ const cx = attr(face, 'cx');
1202
+ const cy = attr(face, 'cy');
1203
+ const r = attr(face, 'r');
1204
+ if (![cx, cy, r].every(Number.isFinite) || r <= 0) return whole;
1205
+ const k = faceDiameter / (r * 2);
1206
+ const dy = remap(cy) - cy;
1207
+ const t = `transform="translate(0 ${dy}) translate(${cx} ${cy}) scale(${k}) translate(${-cx} ${-cy})"`;
1208
+ return `${face.replace(/>$/, ` ${t}>`)}</circle>${space}<g ${t}>`;
1209
+ });
1210
+
1211
+ // The dashed guide each face slides along, and the rule the column stands on — an unclassed,
1212
+ // full-width line, which is what `y1 === y2` identifies it by.
1213
+ out = out.replace(/<line\b[^>]*\bclass="task-line"[^>]*>/g, (line) =>
1214
+ line.replace(/\by2="[-\d.]+"/, `y2="${bottom}"`));
1215
+ out = out.replace(/<line\b(?![^>]*\bclass=)[^>]*>/g, (line) => {
1216
+ const y1 = attr(line, 'y1');
1217
+ const y2 = attr(line, 'y2');
1218
+ if (!Number.isFinite(y1) || y1 !== y2) return line;
1219
+ return line.replace(/\by1="[-\d.]+"/, `y1="${bottom - 2}"`).replace(/\by2="[-\d.]+"/, `y2="${bottom - 2}"`);
1220
+ });
1221
+
1222
+ // The column is now taller than the box mermaid measured, so the viewBox has to follow or the
1223
+ // lowest score is simply cropped off the diagram.
1224
+ return out.replace(/viewBox="([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+)"/, (vb, x, y, w, h) => {
1225
+ const minY = Number(y);
1226
+ const wanted = bottom - minY + radius + gap;
1227
+ return Number(h) >= wanted ? vb : `viewBox="${x} ${y} ${w} ${wanted}"`;
1228
+ });
1229
+ }
1230
+
1231
+ /*
1232
+ ── THE RADAR LEGEND, WHICH MERMAID GIVES NO HANDLE AT ALL ─────────────────────────────────────
1233
+
1234
+ ★★ THE ROW IS AN UNCLASSED GROUP AND ITS PITCH IS A LITERAL 20. mermaid appends each legend entry
1235
+ as `g.append("g").attr("transform", translate(legendX, legendY + index * 20))` — no class, no id,
1236
+ no config key — so there is nothing for a stylesheet to select and nothing for `mermaidConfig` to
1237
+ set. `legendFontSize` is therefore capped at about 18 by a number no theme can reach, which is
1238
+ why the legend read as a footnote beside a diagram sized for a room.
1239
+
1240
+ A string rewrite CAN reach it, and the same pass fixes the two things the pitch was hiding.
1241
+ MEASURED off the render, in the plot group's own units:
1242
+
1243
+ row 0 translate(352.5, -292.5) rect 12x12 at (0,0) text x=16 y=0
1244
+ row 1 translate(352.5, -272.5) " "
1245
+
1246
+ · THE PITCH is 20 against a line box the deck's legend rung wants at 36.
1247
+ · THE SWATCH IS NOT CENTRED ON ITS WORD. The chip spans y 0..12 while the text sits on a BASELINE
1248
+ at y=0, so the word rides above the chip that names it — invisible at mermaid's size, plain at
1249
+ the deck's.
1250
+ · THE BLOCK IS NOT CENTRED ON ANYTHING. Both rows sit at the top of the right margin because the
1251
+ first is placed at a fixed `legendY` and the rest step down from it, so a two-curve legend hangs
1252
+ high and a six-curve one runs past the bottom of the frame.
1253
+
1254
+ All three come out of one rewrite: rows pitched at the deck's line box, stepped either side of the
1255
+ plot's own centre line, with the chip seated on the baseline it labels.
1256
+ */
1257
+ /*
1258
+ ── THE TREEMAP SECTION HEADER, SEATED FOR THE TYPE IT NOW CARRIES ─────────────────────────────
1259
+
1260
+ ★★ THE BASELINE IS A LITERAL 12.5 AND THE THEME MOVED THE TYPE OUT FROM UNDER IT. mermaid writes
1261
+ a section's name and its total on one line at `y = SECTION_HEADER_HEIGHT / 2`, a number chosen for
1262
+ its own 12px label; this deck lifts that label to the transition rung and nothing re-seats it.
1263
+ MEASURED on the render: `Camden` at 29px bold sits on a baseline of 12.5 with its box reaching to
1264
+ y = -9 — nine units ABOVE the cell's own top edge, so the word crosses the border it is inside.
1265
+
1266
+ The same literal puts the name 6 units from the left edge and the total 10 from the right, both of
1267
+ which are inside the 4-unit border at the deck's weight rather than clear of it.
1268
+
1269
+ ★ THERE IS ROOM WITHOUT MOVING ANYTHING ELSE. d3 is handed a `paddingTop` of 35 — the 25-unit
1270
+ header plus 10 of inner padding — so the leaves already start well below the band. Measured:
1271
+ a section 395 tall holding a 350-tall leaf, i.e. 35 above and 10 below. The seating below places
1272
+ the ink inside that reserve and leaves the layout exactly where mermaid computed it.
1273
+ */
1274
+ /*
1275
+ The baseline that centres the header's ink in the band it has. Cap ink measures 0.72 of the type
1276
+ on this face (21 units at 29px, read off the render), so the ink is centred by putting half the
1277
+ slack above it and the rest below — derived rather than picked, because the rung above it moved
1278
+ once already and the seating has to move with it.
1279
+ */
1280
+ const TREEMAP_HEADER_INK = MERMAID_TAG_FONT_PX * 0.72;
1281
+ const TREEMAP_HEADER_INSET = Math.round(MERMAID_EDGE_LABEL_FONT_PX * 0.48);
1282
+ /*
1283
+ The room a header actually has: mermaid hands d3 a `paddingTop` of the 25-unit header plus 10 of
1284
+ inner padding, so nothing is laid out above this line. Confirmed against the render rather than
1285
+ taken from the source — a section 395 tall holding a 350-tall leaf leaves 35 above and 10 below.
1286
+ */
1287
+ const TREEMAP_HEADER_BAND = 35;
1288
+ /*
1289
+ ★★ THE BAND IS SMALLER THAN IT LOOKS, because two borders eat into it. The section's own 4-unit
1290
+ edge is drawn on its top line, so half of it is inside; the first leaf carries the same edge, and
1291
+ half of THAT is above its top. The room the ink actually has is 2 to 33, not 0 to 35 — and
1292
+ centring against the nominal band is what left the word visibly nearer the leaf than the border.
1293
+ Measured before this: 9.5 clear above the cap, 7.7 below the ink, and the visible halves 7.5
1294
+ against 5.7.
1295
+ */
1296
+ const TREEMAP_HEADER_EDGE = DIAGRAM_STROKE_PX / 2;
1297
+ const TREEMAP_HEADER_BASELINE = Math.round(
1298
+ TREEMAP_HEADER_EDGE + (TREEMAP_HEADER_BAND - 2 * TREEMAP_HEADER_EDGE - TREEMAP_HEADER_INK) / 2 + TREEMAP_HEADER_INK,
1299
+ );
1300
+
1301
+ export function seatTreemapHeaders(svg: string): string {
1302
+ /*
1303
+ ★★ AND THE BAND CLIPS AT 25, WHICH IS WHAT ACTUALLY CUT THE WORD. Seating the baseline alone
1304
+ made it worse, not better: mermaid gives every header its own `<clipPath>` sized to the 25-unit
1305
+ band, so a label seated for the deck's type was cropped through the middle — measured, ink from
1306
+ 4.5 to 40.7 against a clip ending at 25. The clip has a NAME rather than a number to match on:
1307
+ the header's is `clip-section-…` where a leaf's is `clip-…`, so this reaches the four that hold
1308
+ a title and none of the ten that hold a cell.
1309
+ */
1310
+ const opened = svg.replace(/(<clipPath id="clip-section-[^"]*"><rect\b[^>]*)\sheight="[^"]*"/g,
1311
+ (whole: string, head: string) => `${head} height="${TREEMAP_HEADER_BAND}"`);
1312
+ return opened.replace(/<text\b([^>]*)>/g, (whole: string, attrs: string) => {
1313
+ const label = /\bclass="[^"]*\btreemapSectionLabel\b[^"]*"/.test(attrs);
1314
+ const value = /\bclass="[^"]*\btreemapSectionValue\b[^"]*"/.test(attrs);
1315
+ if (!label && !value) return whole;
1316
+ const held = /\sx="([-\d.eE+]+)"/.exec(attrs);
1317
+ if (!held) return whole;
1318
+ /*
1319
+ ★ THE NAME IS ANCHORED LEFT AND THE TOTAL RIGHT, so one is set and the other is NUDGED. The
1320
+ total's x is `width - 10`, and the width is not in the tag — moving it in by the difference is
1321
+ the only way to inset it without re-deriving a number mermaid already computed.
1322
+ */
1323
+ const x = label ? TREEMAP_HEADER_INSET : Number(held[1]) - (TREEMAP_HEADER_INSET - 10);
1324
+ if (!Number.isFinite(x)) return whole;
1325
+ const seated = attrs
1326
+ .replace(/\sx="[^"]*"/, ` x="${round4(x)}"`)
1327
+ .replace(/\sy="[^"]*"/, ` y="${TREEMAP_HEADER_BASELINE}"`);
1328
+ return `<text${seated}>`;
1329
+ });
1330
+ }
1331
+
1332
+ export function spaceRadarLegend(svg: string, textPx: number): string {
1333
+ /*
1334
+ ★★ A DEPTH SCAN, AND THE NON-GREEDY VERSION OF THIS WAS WRITTEN FIRST AND WAS WRONG — the same
1335
+ trap recentreEdgeLabels documents two screens up, walked into anyway. `<g …>([\s\S]*?)</g>` on
1336
+ the PLOT group matches from its opening tag to the first `</g>` in the file, which is the end of
1337
+ legend row 0. That match is correctly rejected (its body contains a nested `<g`), but `replace`
1338
+ has already consumed the span — so row 0 never got a match of its own and only row 1 moved.
1339
+ Measured on the render: one row re-seated, one left exactly as mermaid drew it.
1340
+
1341
+ The scanner below counts opens and closes, so the group it edits is the group it matched.
1342
+ */
1343
+ const OPEN = /<g transform="translate\(\s*([-\d.eE+]+)[\s,]+([-\d.eE+]+)\s*\)"\s*>/g;
1344
+ type Row = { open: number; bodyStart: number; end: number; x: string; body: string };
1345
+ const rows: Row[] = [];
1346
+ for (let m = OPEN.exec(svg); m; m = OPEN.exec(svg)) {
1347
+ const bodyStart = m.index + m[0].length;
1348
+ const end = groupEnd(svg, bodyStart);
1349
+ if (end < 0) continue;
1350
+ const body = svg.slice(bodyStart, end - 4);
1351
+ // A legend row is an unclassed group holding one swatch and one word, and nothing else.
1352
+ if (!/<rect\b[^>]*radarLegendBox-/.test(body) || /<g[\s>]/.test(body)) continue;
1353
+ rows.push({ open: m.index, bodyStart, end, x: m[1], body });
1354
+ }
1355
+ if (!rows.length) return svg;
1356
+
1357
+ // A line box, not the glyph height — the same 1.25 spaceLegendRows gives the pie's.
1358
+ const pitch = textPx * 1.25;
1359
+ const top = -((rows.length - 1) / 2) * pitch;
1360
+
1361
+ let out = '';
1362
+ let cursor = 0;
1363
+ rows.forEach((row, i) => {
1364
+ /*
1365
+ ★★ THE CHIP IS SEATED ON THE BASELINE, NOT HUNG FROM IT. Its own y is half its height above
1366
+ the row's origin, which puts its centre on the line the word sits on — and the word then takes
1367
+ the deck's own answer to centring text in a box: an alphabetic baseline plus 0.35em, half a cap
1368
+ height for this face, rather than `dominant-baseline: central`, which centres the em box with
1369
+ its descender space included and leaves a word with no descenders riding high. The same
1370
+ correction recentreEdgeLabels makes, for the same reason.
1371
+ */
1372
+ const seated = row.body
1373
+ .replace(/<rect\b([^>]*)>/, (rect: string, attrs: string) => (
1374
+ /radarLegendBox-/.test(attrs)
1375
+ ? `<rect${attrs.replace(/\s(?:x|y)="[^"]*"/g, '')} x="0" y="${round4(-RADAR_SWATCH_PX / 2)}">`
1376
+ : rect
1377
+ ))
1378
+ .replace(/<text\b([^>]*)>/, (whole2: string, attrs: string) => (
1379
+ `<text${attrs.replace(/\s(?:x|y|dy)="[^"]*"/g, '')} x="${RADAR_SWATCH_PX + RADAR_SWATCH_GAP_PX}" y="0" dy="0.35em">`
1380
+ ));
1381
+ out += svg.slice(cursor, row.open)
1382
+ + `<g transform="translate(${row.x}, ${round4(top + i * pitch)})">`
1383
+ + seated
1384
+ + '</g>';
1385
+ cursor = row.end;
1386
+ });
1387
+ return out + svg.slice(cursor);
1388
+ }
1389
+
1390
+ export function spaceLegendRows(svg: string, textPx: number): string {
1391
+ const ROW = /<g class="legend" transform="translate\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)"/g;
1392
+ const rows = [...svg.matchAll(ROW)];
1393
+ if (rows.length < 2) return svg;
1394
+ const ys = rows.map((m) => Number(m[2]));
1395
+ if (!ys.every(Number.isFinite)) return svg;
1396
+ const pitch = Math.abs(ys[1] - ys[0]);
1397
+ // A line box, not the glyph height — the same 1.25 the deck gives a label elsewhere.
1398
+ const wanted = textPx * 1.25;
1399
+ if (!(pitch > 0) || pitch >= wanted) return svg;
1400
+ const k = wanted / pitch;
1401
+ const mean = ys.reduce((a, b) => a + b, 0) / ys.length;
1402
+ let i = 0;
1403
+ return svg.replace(ROW, (whole, x: string) => {
1404
+ const y = mean + (ys[i++] - mean) * k;
1405
+ return whole.replace(/translate\([^)]*\)/, `translate(${x}, ${y})`);
1406
+ });
1407
+ }
1408
+
1409
+ /*
1410
+ ── THE JOURNEY'S ACTOR LEGEND ────────────────────────────────────────────────────────────────
1411
+
1412
+ The same defect as the pie legend, in different markup, which is why it needs its own pass rather
1413
+ than a wider regex. A pie stacks `<g class="legend" transform="translate(x,y)">`; a journey emits
1414
+ a BARE `<circle class="actor-N" cy>` followed by a `<text class="legend" y>`, both children of the
1415
+ root, on a hard-coded 20-unit pitch. Under 36px type the two rows print on top of each other.
1416
+
1417
+ ★ THE DOT MOVES WITH ITS LABEL. They are separate elements with separate coordinates, so a pass
1418
+ that opened only the text spacing would leave every dot behind on the old pitch, beside the wrong
1419
+ name. The delta is computed once per row and applied to both.
1420
+ */
1421
+ export function spaceJourneyLegend(svg: string, textPx: number): string {
1422
+ const PAIR = /(<circle\b[^>]*\bclass="actor-\d+"[^>]*>(?:<\/circle>)?)(\s*)(<text\b[^>]*\bclass="legend"[^>]*>)/g;
1423
+ const rows = [...svg.matchAll(PAIR)];
1424
+ if (rows.length < 2) return svg;
1425
+ const yOf = (tag: string, attr: string) => {
1426
+ const hit = new RegExp(`\\b${attr}="([-\\d.]+)"`).exec(tag);
1427
+ return hit ? Number(hit[1]) : NaN;
1428
+ };
1429
+ const ys = rows.map((m) => yOf(m[3], 'y'));
1430
+ if (!ys.every(Number.isFinite)) return svg;
1431
+ const pitch = Math.abs(ys[1] - ys[0]);
1432
+ const wanted = textPx * 1.25;
1433
+ if (!(pitch > 0) || pitch >= wanted) return svg;
1434
+ const k = wanted / pitch;
1435
+ const mean = ys.reduce((a, b) => a + b, 0) / ys.length;
1436
+ let i = 0;
1437
+ return svg.replace(PAIR, (whole, dot: string, gap: string, label: string) => {
1438
+ const was = ys[i++];
1439
+ const delta = mean + (was - mean) * k - was;
1440
+ const cy = yOf(dot, 'cy');
1441
+ const movedDot = Number.isFinite(cy)
1442
+ ? dot.replace(/\bcy="[-\d.]+"/, `cy="${cy + delta}"`)
1443
+ : dot;
1444
+ const movedLabel = label.replace(/\by="[-\d.]+"/, `y="${was + delta}"`);
1445
+ return `${movedDot}${gap}${movedLabel}`;
1446
+ });
1447
+ }
1448
+
1449
+ /*
1450
+ One canvas, reused: creating a 2D context per label on a diagram with a dozen edges is the kind of
1451
+ cost that shows up as a slow slide rather than as a wrong one. Returns 0 when there is no canvas to
1452
+ measure with — under a test runner, say — and the caller keeps mermaid's own box in that case.
1453
+ */
1454
+ let labelMetrics: CanvasRenderingContext2D | null | undefined;
1455
+ function measureLabel(text: string): number {
1456
+ if (labelMetrics === undefined) {
1457
+ labelMetrics = typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
1458
+ if (labelMetrics) labelMetrics.font = `${MERMAID_EDGE_LABEL_FONT_PX}px ${MERMAID_FONT_FAMILY}`;
1459
+ }
1460
+ if (!labelMetrics || !text.trim()) return 0;
1461
+ return labelMetrics.measureText(text.trim()).width;
1462
+ }
1463
+
1464
+ /*
1465
+ ⚠ AN EDGE LABEL ALWAYS KNOCKS OUT TO THE PAGE, EVEN INSIDE A CONTAINER — a known miss, recorded
1466
+ because two different attempts at it have now failed and the next reader deserves the map.
1467
+
1468
+ A label knocks the line out behind itself, so its fill should BE the ground it sits on: the page on
1469
+ a plain slide, the container's surface inside a `subgraph` or a composite. Getting it wrong paints
1470
+ a page-coloured card onto a surface-coloured box, which is what happens today.
1471
+
1472
+ · A DESCENDANT RULE CANNOT REACH IT. mermaid collects every edge label into one `g.edgeLabels` at
1473
+ the ROOT and positions it by transform, so a label is never a descendant of the cluster it
1474
+ appears to sit in.
1475
+ · A POINT-IN-RECT TEST HERE DID NOT WORK EITHER. The geometry is all in the markup — clusters
1476
+ carry absolute `<rect x y width height>`, each `g.edgeLabel` an absolute `translate` — but the
1477
+ two are not in the same coordinate space by the time this runs, and a test built on them fired
1478
+ on the flowchart's `cluster` and never on the state diagram's `statediagram-cluster`.
1479
+
1480
+ Whoever picks this up: compare the two families' transform chains first. The answer is probably a
1481
+ parent transform this pass is not accounting for, not the test itself.
1482
+ */
1483
+ export function recentreEdgeLabels(svg: string): string {
1484
+ /*
1485
+ ★ A DEPTH SCAN, NOT A REGEX, and the difference is not stylistic. `g.label` wraps ANOTHER `<g>`
1486
+ around the rect and the text, so a non-greedy `([\s\S]*?)</g>` stops at the INNER close tag and
1487
+ a rewrite built on it emits markup with one `</g>` too few. The scanner below counts `<g` and
1488
+ `</g>` so the group it edits is the group it matched.
1489
+ */
1490
+ const OPEN = /<g class="label"([^>]*)>/g;
1491
+ let out = '';
1492
+ let cursor = 0;
1493
+ for (let m = OPEN.exec(svg); m; m = OPEN.exec(svg)) {
1494
+ /*
1495
+ ★★ A MATCH INSIDE A GROUP ALREADY EMITTED IS SKIPPED, or the splice eats itself. This loop now
1496
+ emits the body it rewrote and moves the cursor PAST the whole group, where it used to leave the
1497
+ body to the final copy. `OPEN.lastIndex` keeps scanning from inside that consumed region, so a
1498
+ nested or adjacent `g.label` matched at an index BEHIND the cursor: `svg.slice(cursor, m.index)`
1499
+ then returns empty and the group is written twice, with one `</g>` too many. Found on the class
1500
+ diagram the moment cardinality labels added a second label group — the whole diagram failed to
1501
+ render, which is what invalid markup looks like from the outside.
1502
+ */
1503
+ if (m.index < cursor) continue;
1504
+ const bodyStart = m.index + m[0].length;
1505
+ let depth = 1;
1506
+ let i = bodyStart;
1507
+ while (depth > 0 && i < svg.length) {
1508
+ const nextOpen = svg.indexOf('<g', i);
1509
+ const nextClose = svg.indexOf('</g>', i);
1510
+ if (nextClose < 0) break;
1511
+ if (nextOpen >= 0 && nextOpen < nextClose) { depth += 1; i = nextOpen + 2; } else { depth -= 1; i = nextClose + 4; }
1512
+ }
1513
+ if (depth !== 0) continue;
1514
+ const body = svg.slice(bodyStart, i - 4);
1515
+ const rect = /<rect[^>]*class="background"[^>]*>/.exec(body)?.[0];
1516
+ // Only an edge label carries a background rect. A NODE label is the same `g.label` with a
1517
+ // foreignObject in it, and mermaid positions that one correctly — so it is left alone.
1518
+ if (!rect) continue;
1519
+ const num = (attr: string) => {
1520
+ const hit = new RegExp(`\\b${attr}="([-\\d.eE+]+)"`).exec(rect);
1521
+ return hit ? Number(hit[1]) : NaN;
1522
+ };
1523
+ const [x, y, w, h] = ['x', 'y', 'width', 'height'].map(num);
1524
+ // A rect mermaid has not sized yet leaves its label where it is rather than centred on NaN.
1525
+ if (![x, y, w, h].every(Number.isFinite)) continue;
1526
+ const attrs = m[1].replace(/\stransform="[^"]*"/, '');
1527
+ /*
1528
+ ★★ AND THE TEXT INSIDE IS CENTRED ON THAT RECT, not left on mermaid's baseline. The transform
1529
+ above centres the BOX; the `<text>` in it keeps a `y` mermaid worked out for the size it laid
1530
+ the label out at, which is its own hard 14px. Re-point the size and the glyph hangs — the same
1531
+ defect the fragment keyword and the autonumber index each had, and the same cure: put the text
1532
+ at the rect's own vertical centre and let `central` centre the glyph box on it rather than
1533
+ sitting it on a baseline.
1534
+
1535
+ The rect's centre in this group's space is y + h/2, which is already in hand.
1536
+ */
1537
+ /*
1538
+ ★★ THE KNOCKOUT BOX IS MEASURED, NOT SCALED, AND ITS PADDING IS EQUAL ON BOTH AXES.
1539
+
1540
+ mermaid sizes this rect around the text at the size IT laid out — its own hard 14px — and this
1541
+ theme re-points the font in CSS afterwards, so the box keeps mermaid's proportions and its
1542
+ padding is whatever falls out of them: wider at the sides than above and below, and both wrong
1543
+ once the type has moved. Scaling the box by the type ratio fixed the size and kept the
1544
+ asymmetry.
1545
+
1546
+ This runs in the browser, during the render, so the text can simply be MEASURED at the size it
1547
+ will actually be drawn at — the same canvas trick the audit uses — and the box built around it
1548
+ with one padding in both directions.
1549
+ */
1550
+ /*
1551
+ ★ THE TWO PADDINGS ARE NOT THE SAME NUMBER, AND THAT IS THE POINT RATHER THAN AN OVERSIGHT.
1552
+ They started equal, which fixed mermaid's own lopsided box — but a knockout is not a card: it
1553
+ exists to break the line behind a word, so it wants enough air at the SIDES to separate the
1554
+ word from the line it interrupts, and only enough above and below to clear the glyph. Equal on
1555
+ both axes reads as a label with a box around it; tighter vertically reads as a gap in the line
1556
+ with a word in it, which is what an edge label is.
1557
+ */
1558
+ const padX = Math.round(MERMAID_EDGE_LABEL_FONT_PX * 0.34);
1559
+ const padY = Math.round(MERMAID_EDGE_LABEL_FONT_PX * 0.16);
1560
+ const lineH = MERMAID_EDGE_LABEL_FONT_PX * 1.2;
1561
+ const words = /<text\b[^>]*>([\s\S]*?)<\/text>/.exec(body)?.[1]?.replace(/<[^>]*>/g, '') ?? '';
1562
+ const measured = measureLabel(words);
1563
+ const cx = x + w / 2, cy = y + h / 2;
1564
+ // A measurement that fails falls back to mermaid's own box rather than to a guess of zero.
1565
+ const sw = measured > 0 ? measured + padX * 2 : w;
1566
+ const sh = lineH + padY * 2;
1567
+ const setAttr = (attrs: string, name: string, value: number) => {
1568
+ const re = new RegExp(`\\b${name}="[-\\d.eE+]*"`);
1569
+ return re.test(attrs) ? attrs.replace(re, `${name}="${value}"`) : `${attrs} ${name}="${value}"`;
1570
+ };
1571
+ const scaledBody = body.replace(/<rect\b([^>]*)>/, (whole2: string, rectAttrs: string) => {
1572
+ if (!/class="background"/.test(rectAttrs)) return whole2;
1573
+ let next = rectAttrs;
1574
+ next = setAttr(next, 'x', cx - sw / 2);
1575
+ next = setAttr(next, 'y', cy - sh / 2);
1576
+ next = setAttr(next, 'width', sw);
1577
+ next = setAttr(next, 'height', sh);
1578
+ return `<rect${next}>`;
1579
+ });
1580
+ /*
1581
+ ★★ AND THE TSPAN'S OWN `y`/`dy` HAVE TO GO, or none of the centring reaches the glyph. mermaid
1582
+ writes `<text y="-10.1"><tspan x="0" y="-0.1em" dy="1.1em">`, and a tspan's own `y` is ABSOLUTE
1583
+ in the text's coordinate space — it does not offset the parent's, it replaces it. So setting
1584
+ the <text> y centred the element and the tspan put the glyph back a line below it. Dropping
1585
+ both makes the tspan inherit the position and the baseline set on its parent.
1586
+ */
1587
+ const strippedBody = scaledBody.replace(/<tspan\b[^>]*>/g, (tag: string) => (
1588
+ /text-outer-tspan|text-inner-tspan/.test(tag)
1589
+ ? tag.replace(/\s(?:y|dy)="[^"]*"/g, '')
1590
+ : tag
1591
+ ));
1592
+ /*
1593
+ ★★ CENTRED ON CAP HEIGHT, NOT ON THE EM BOX. `dominant-baseline: central` centres the glyph box
1594
+ with its descender space included, so a word that has no descenders — `unlocked`, `returned` —
1595
+ rides visibly high in its own knockout. The typographic answer is the alphabetic baseline placed
1596
+ at the box's centre plus a 0.35em nudge, half a cap height for this face, which lands the visual
1597
+ middle of the word on the middle of the box whatever letters it happens to contain.
1598
+ */
1599
+ const centredBody = strippedBody.replace(/<text\b([^>]*)>/, (_t: string, tagAttrs: string) => {
1600
+ const cleaned = tagAttrs
1601
+ .replace(/\s(?:y|dy)="[^"]*"/g, '')
1602
+ .replace(/\s(?:dominant|alignment)-baseline="[^"]*"/g, '');
1603
+ return `<text${cleaned} y="${cy}" dy="0.35em">`;
1604
+ });
1605
+ out += svg.slice(cursor, m.index)
1606
+ + `<g class="label"${attrs} transform="translate(${-(x + w / 2)}, ${-(y + h / 2)})">`
1607
+ + centredBody
1608
+ + '</g>';
1609
+ cursor = i;
1610
+ }
1611
+ return out + svg.slice(cursor);
1612
+ }
1613
+
1614
+ /*
1615
+ ── WHAT A CONTAINER OWNS ──────────────────────────────────────────────────────────────────────
1616
+
1617
+ Two defects, one piece of geometry, so one scanner serves both:
1618
+
1619
+ · an edge label INSIDE a `subgraph` or a composite state knocked out to the PAGE, painting a
1620
+ cream card onto the container's surface;
1621
+ · a container's TITLE sat on its own top border and, in the state family, underneath the first
1622
+ label inside it.
1623
+
1624
+ ★★ AND THE ANSWER TO THE ONE THAT HAD FAILED TWICE IS THAT CONTAINMENT IS STRUCTURAL, NOT
1625
+ GEOMETRIC. The note that used to sit here said a point-in-rect test "fired on the flowchart's
1626
+ `cluster` and never on the state diagram's `statediagram-cluster`", and guessed at an unaccounted
1627
+ parent transform. There is none. MEASURED off the live render of the specimen's nested-states
1628
+ slide, reading each element's nearest ancestor `g.root`:
1629
+
1630
+ label "released" / "below 20%" / "swapped" root: (the outer one, no transform)
1631
+ label "unlocked" / "returned" root: translate(448.875, 0)
1632
+ rect.outer (the Deployed container) root: translate(448.875, 0)
1633
+
1634
+ mermaid lays a composite state out as its OWN nested graph, and emits a nested `g.root` for it
1635
+ holding that graph's `g.clusters`, `g.edgeLabels` and `g.nodes` as siblings. So a label drawn
1636
+ inside a composite IS in the same coordinate space as the container's rect — they share a root —
1637
+ and a label from the outer graph is in a DIFFERENT one. The old test compared across those two
1638
+ spaces, where the numbers are meaningless but happen to overlap: "released" sits at x 351.7 in the
1639
+ OUTER root, and the Deployed rect spans x 8–641.9 in the INNER one, so a naive test called it
1640
+ contained. It is not, and no transform arithmetic was ever going to say so.
1641
+
1642
+ A flowchart has no nesting — one graph, one root, every subgraph a cluster in it — so scoping the
1643
+ test to a root is exactly right there too, and both families fall out of one rule:
1644
+
1645
+ A LABEL IS INSIDE A CONTAINER IFF THEY SHARE A ROOT AND THE LABEL'S ORIGIN IS IN ITS RECT.
1646
+
1647
+ Nothing here is a stylesheet's job, which is the other half of the old note that was true: mermaid
1648
+ puts every one of a graph's edge labels in ONE `g.edgeLabels`, so no label is a DESCENDANT of the
1649
+ cluster it sits in and no descendant selector can reach it. A string rewrite can.
1650
+ */
1651
+
1652
+ /** End index (past `</g>`) of the group whose open tag ends at `bodyStart`, or -1 if unbalanced. */
1653
+ function groupEnd(svg: string, bodyStart: number): number {
1654
+ let depth = 1;
1655
+ let i = bodyStart;
1656
+ while (depth > 0 && i < svg.length) {
1657
+ const nextOpen = svg.indexOf('<g', i);
1658
+ const nextClose = svg.indexOf('</g>', i);
1659
+ if (nextClose < 0) return -1;
1660
+ if (nextOpen >= 0 && nextOpen < nextClose) { depth += 1; i = nextOpen + 2; } else { depth -= 1; i = nextClose + 4; }
1661
+ }
1662
+ return depth === 0 ? i : -1;
1663
+ }
1664
+
1665
+ type Span = { open: number; bodyStart: number; bodyEnd: number; end: number };
1666
+
1667
+ /**
1668
+ * Every `<g>` in `[from, to)` carrying `cls` as one of its class tokens, with its span.
1669
+ *
1670
+ * ★ A DEPTH SCAN RATHER THAN A NON-GREEDY MATCH, for the reason recentreEdgeLabels already carries:
1671
+ * these groups nest, and `([\s\S]*?)</g>` stops at the first inner close tag.
1672
+ */
1673
+ function groupsWithClass(svg: string, cls: string, from = 0, to = svg.length): Span[] {
1674
+ const OPEN = /<g\b([^>]*)>/g;
1675
+ OPEN.lastIndex = from;
1676
+ const out: Span[] = [];
1677
+ for (let m = OPEN.exec(svg); m && m.index < to; m = OPEN.exec(svg)) {
1678
+ const classes = /\bclass="([^"]*)"/.exec(m[1])?.[1]?.split(/\s+/) ?? [];
1679
+ if (!classes.includes(cls)) continue;
1680
+ const bodyStart = m.index + m[0].length;
1681
+ const end = groupEnd(svg, bodyStart);
1682
+ if (end < 0) continue;
1683
+ out.push({ open: m.index, bodyStart, bodyEnd: end - 4, end });
1684
+ }
1685
+ return out;
1686
+ }
1687
+
1688
+ const attrNum = (tag: string, name: string): number => {
1689
+ const hit = new RegExp(`\\b${name}="([-\\d.eE+]+)"`).exec(tag);
1690
+ return hit ? Number(hit[1]) : Number.NaN;
1691
+ };
1692
+
1693
+ type Box = { x: number; y: number; w: number; h: number };
1694
+
1695
+ /** The container rects in one cluster group — the biggest wins, which is the one that is drawn. */
1696
+ function clusterBox(svg: string, cluster: Span): { tag: string; box: Box } | null {
1697
+ let best: { tag: string; box: Box } | null = null;
1698
+ const RECT = /<rect\b[^>]*>/g;
1699
+ RECT.lastIndex = cluster.bodyStart;
1700
+ for (let m = RECT.exec(svg); m && m.index < cluster.bodyEnd; m = RECT.exec(svg)) {
1701
+ const classes = /\bclass="([^"]*)"/.exec(m[0])?.[1]?.split(/\s+/) ?? [];
1702
+ // `inner` is scaffolding the deck already hides; `background` is the title's own knockout and
1703
+ // carries no geometry at all.
1704
+ if (classes.includes('inner') || classes.includes('background')) continue;
1705
+ const box = { x: attrNum(m[0], 'x'), y: attrNum(m[0], 'y'), w: attrNum(m[0], 'width'), h: attrNum(m[0], 'height') };
1706
+ if (!Object.values(box).every(Number.isFinite) || box.w <= 0 || box.h <= 0) continue;
1707
+ if (!best || box.w * box.h > best.box.w * best.box.h) best = { tag: m[0], box };
1708
+ }
1709
+ return best;
1710
+ }
1711
+
1712
+ /** The `translate(x, y)` on a tag, or null. */
1713
+ function translateOf(tag: string): { x: number; y: number } | null {
1714
+ const m = /\btransform="translate\(\s*([-\d.eE+]+)[\s,]+([-\d.eE+]+)\s*\)"/.exec(tag);
1715
+ return m ? { x: Number(m[1]), y: Number(m[2]) } : null;
1716
+ }
1717
+
1718
+ /**
1719
+ * One graph: a `g.root` and the `g.clusters` / `g.edgeLabels` that are ITS OWN, not a nested
1720
+ * graph's. Nested roots live inside `g.nodes`, so rejecting anything inside one is the whole test.
1721
+ */
1722
+ type Graph = { root: Span; clusters: Span[]; edgeLabels: Span[] };
1723
+
1724
+ function graphsOf(svg: string): Graph[] {
1725
+ const roots = groupsWithClass(svg, 'root');
1726
+ return roots.map((root) => {
1727
+ const nested = roots.filter((r) => r !== root && r.open > root.bodyStart && r.end <= root.end);
1728
+ const own = (s: Span) => !nested.some((n) => s.open > n.open && s.end <= n.end);
1729
+ const holders = groupsWithClass(svg, 'clusters', root.bodyStart, root.bodyEnd).filter(own);
1730
+ const clusters = holders.flatMap((h) => {
1731
+ // The direct children of `g.clusters` ARE the clusters — `g.cluster` on a flowchart,
1732
+ // `g.statediagram-state.statediagram-cluster` on a state diagram. Take every group that
1733
+ // starts at the holder's own depth.
1734
+ const out: Span[] = [];
1735
+ let i = h.bodyStart;
1736
+ while (i < h.bodyEnd) {
1737
+ const open = svg.indexOf('<g', i);
1738
+ if (open < 0 || open >= h.bodyEnd) break;
1739
+ const close = svg.indexOf('>', open);
1740
+ if (close < 0) break;
1741
+ const end = groupEnd(svg, close + 1);
1742
+ if (end < 0) break;
1743
+ out.push({ open, bodyStart: close + 1, bodyEnd: end - 4, end });
1744
+ i = end;
1745
+ }
1746
+ return out;
1747
+ });
1748
+ return {
1749
+ root,
1750
+ clusters,
1751
+ edgeLabels: groupsWithClass(svg, 'edgeLabels', root.bodyStart, root.bodyEnd).filter(own),
1752
+ };
1753
+ });
1754
+ }
1755
+
1756
+ /** Every edge label in `graph`, with the origin mermaid positioned it at. */
1757
+ function labelsOf(svg: string, graph: Graph): Array<Span & { at: { x: number; y: number } }> {
1758
+ return graph.edgeLabels.flatMap((holder) =>
1759
+ groupsWithClass(svg, 'edgeLabel', holder.bodyStart, holder.bodyEnd)
1760
+ .map((span) => {
1761
+ const at = translateOf(svg.slice(span.open, span.bodyStart));
1762
+ return at ? { ...span, at } : null;
1763
+ })
1764
+ .filter((v): v is Span & { at: { x: number; y: number } } => v !== null));
1765
+ }
1766
+
1767
+ const inside = (at: { x: number; y: number }, b: Box) =>
1768
+ at.x >= b.x && at.x <= b.x + b.w && at.y >= b.y && at.y <= b.y + b.h;
1769
+
1770
+ /*
1771
+ A LABEL KNOCKS THE LINE OUT BEHIND ITSELF, SO ITS FILL HAS TO BE THE GROUND IT SITS ON — and
1772
+ inside a container that ground is the container's surface, not the page.
1773
+
1774
+ Marked with an ATTRIBUTE rather than a class, and the difference is load-bearing:
1775
+ recentreEdgeLabels matches `<g class="label"` exactly, so adding a second class to that attribute
1776
+ would silently take every marked label out of the pass that centres it. The rule this pairs with
1777
+ re-points `--deck-mermaid-label-bg` on the group, which INHERITS — so the knockout fill and the
1778
+ text's own mix-toward-the-ground both follow from one declaration.
1779
+ */
1780
+ export const CONTAINER_GROUND_ATTR = 'data-on-container';
1781
+
1782
+ export function groundLabelsOnContainers(svg: string): string {
1783
+ const marks: number[] = [];
1784
+ for (const graph of graphsOf(svg)) {
1785
+ const boxes = graph.clusters.map((c) => clusterBox(svg, c)?.box).filter((b): b is Box => !!b);
1786
+ if (!boxes.length) continue;
1787
+ for (const label of labelsOf(svg, graph)) {
1788
+ if (!boxes.some((b) => inside(label.at, b))) continue;
1789
+ const inner = groupsWithClass(svg, 'label', label.bodyStart, label.bodyEnd)[0];
1790
+ if (inner) marks.push(inner.open);
1791
+ }
1792
+ }
1793
+ if (!marks.length) return svg;
1794
+ // Rewritten back to front so an earlier splice cannot move a later index.
1795
+ let out = svg;
1796
+ for (const at of marks.sort((a, b) => b - a)) {
1797
+ const close = out.indexOf('>', at);
1798
+ if (close < 0) continue;
1799
+ out = `${out.slice(0, close)} ${CONTAINER_GROUND_ATTR}=""${out.slice(close)}`;
1800
+ }
1801
+ return out;
1802
+ }
1803
+
1804
+ /*
1805
+ ── SEATING A CONTAINER'S TITLE ────────────────────────────────────────────────────────────────
1806
+
1807
+ ★★ THE TITLE BAND IS SIZED AT LAYOUT TIME AND THE TYPE IS RE-POINTED AFTERWARDS, which is the same
1808
+ defect this file has already met on the fragment keyword, the autonumber index and the edge label:
1809
+ mermaid measures, this theme changes the size, and nothing reflows. MEASURED on the nested-states
1810
+ slide, in the container's own units:
1811
+
1812
+ rect.outer y 8 -> 196.5
1813
+ g.cluster-label y 5.81 -> 51.05 (2.19 units ABOVE the box it titles)
1814
+ "unlocked" text top 50.35 (the first thing inside)
1815
+
1816
+ So the word sat ON its own 4-unit border — which is what "cut off" looks like — and the gap to the
1817
+ first label was NEGATIVE. Both halves are one sum: the band a title needs is its own height plus a
1818
+ padding at each end, and the band it HAS is from the box's top edge to the first thing inside.
1819
+
1820
+ ★ THE CLEARANCE IS MEASURED TO THE TEXT, NOT TO THE KNOCKOUT. An edge label's box is deliberately
1821
+ taller than its word (recentreEdgeLabels pads it), and now that the box paints the container's own
1822
+ surface it is invisible — so seating the title against the BOX leaves an optical hole above the
1823
+ title that reads as worse than the collision it fixed. Measured both ways on the live render.
1824
+
1825
+ ★★ AND GROWING THE BOX MEANS GROWING THE viewBox. The container's top edge can rise above the
1826
+ diagram's own bounds, and the outermost <svg> clips there. fitToColumn reads the viewBox for the
1827
+ natural size it caps against and runs LAST, so extending it here is picked up for free — but only
1828
+ because of that ordering.
1829
+ */
1830
+ /*
1831
+ ★ PAD IS THE GAP FROM THE BOX'S TOP EDGE TO THE TITLE'S ORIGIN, and mermaid's own value for it is
1832
+ ZERO — `translate(x, clusterY - 2)` against a band that starts at clusterY. A sixth of the body
1833
+ rung is the smallest step that reads as deliberate air rather than a rounding error at room scale.
1834
+ */
1835
+ const CLUSTER_TITLE_PAD = Math.round(MERMAID_FONT_PX * 0.17);
1836
+
1837
+ /*
1838
+ ★★ THE TITLE'S HEIGHT IS ALREADY IN THE MARKUP, so nothing here has to measure anything. mermaid
1839
+ writes `rect.inner.y = clusterY + bbox.height + 2` (clusters.js, roundedWithTitle) where bbox is a
1840
+ LIVE measurement of the title text taken at layout time. Read backwards, the inner rect states the
1841
+ height mermaid measured — 45 on the nested-states slide, against 45.24 for the group's painted box
1842
+ — so this pass is pure arithmetic on the string and provable red in vitest without a canvas.
1843
+
1844
+ It is also the reason the pass is scoped to clusters that HAVE an inner rect. That is the state
1845
+ family's composite, which is where the collision is; a flowchart subgraph draws one rect and no
1846
+ content box, so there is no stated title height to reason from and its band is left alone.
1847
+ */
1848
+ const TITLE_BAND_SLACK = 2;
1849
+
1850
+ export function seatClusterTitles(svg: string): string {
1851
+ const edits: Array<{ from: string; to: string }> = [];
1852
+ let rise = 0; // how far past the diagram's own top edge the tallest container now reaches
1853
+
1854
+ for (const graph of graphsOf(svg)) {
1855
+ const labels = labelsOf(svg, graph);
1856
+ for (const cluster of graph.clusters) {
1857
+ const found = clusterBox(svg, cluster);
1858
+ const title = groupsWithClass(svg, 'cluster-label', cluster.bodyStart, cluster.bodyEnd)[0];
1859
+ if (!found || !title) continue;
1860
+ const titleTag = svg.slice(title.open, title.bodyStart);
1861
+ const seat = translateOf(titleTag);
1862
+ if (!seat) continue;
1863
+ const { box } = found;
1864
+
1865
+ const innerRect = /<rect\b[^>]*\bclass="[^"]*\binner\b[^"]*"[^>]*>/.exec(svg.slice(cluster.bodyStart, cluster.bodyEnd));
1866
+ if (!innerRect) continue;
1867
+ const innerY = attrNum(innerRect[0], 'y');
1868
+ const titleHeight = innerY - box.y - TITLE_BAND_SLACK;
1869
+ if (!Number.isFinite(titleHeight) || titleHeight <= 0) continue;
1870
+
1871
+ /*
1872
+ ★ THE CLEARANCE IS MEASURED TO THE INK, NOT TO THE KNOCKOUT. recentreEdgeLabels pads an edge
1873
+ label's box well past its word, and now that the box paints the container's own surface that
1874
+ padding is invisible — so seating the title against the BOX opens an optical hole above the
1875
+ title that reads worse than the collision it fixes. Measured both ways on the live render.
1876
+ */
1877
+ const lineH = MERMAID_EDGE_LABEL_FONT_PX * 1.2;
1878
+ const contentTop = Math.min(
1879
+ innerY,
1880
+ ...labels.filter((l) => inside(l.at, box)).map((l) => l.at.y - lineH / 2),
1881
+ );
1882
+
1883
+ const grow = Math.max(0, titleHeight + CLUSTER_TITLE_PAD * 2 - (contentTop - box.y));
1884
+ const top = box.y - grow;
1885
+
1886
+ if (grow > 0) {
1887
+ edits.push({
1888
+ from: found.tag,
1889
+ to: found.tag
1890
+ .replace(/\by="[-\d.eE+]*"/, `y="${round4(top)}"`)
1891
+ .replace(/\bheight="[-\d.eE+]*"/, `height="${round4(box.h + grow)}"`),
1892
+ });
1893
+ rise = Math.max(rise, grow - (box.y - viewBoxTop(svg)));
1894
+ }
1895
+ const seated = top + CLUSTER_TITLE_PAD;
1896
+ if (Math.abs(seated - seat.y) > 0.01) {
1897
+ edits.push({
1898
+ from: titleTag,
1899
+ to: titleTag.replace(/\btransform="translate\([^)]*\)"/, `transform="translate(${round4(seat.x)}, ${round4(seated)})"`),
1900
+ });
1901
+ }
1902
+ }
1903
+ }
1904
+ if (!edits.length) return svg;
1905
+
1906
+ let out = svg;
1907
+ for (const e of edits) {
1908
+ const at = out.indexOf(e.from);
1909
+ if (at < 0) continue;
1910
+ out = out.slice(0, at) + e.to + out.slice(at + e.from.length);
1911
+ }
1912
+ return rise > 0 ? growViewBoxTop(out, rise) : out;
1913
+ }
1914
+
1915
+ const round4 = (n: number) => Number(n.toFixed(4));
1916
+
1917
+ function viewBoxTop(svg: string): number {
1918
+ const vb = /viewBox="([^"]+)"/.exec(svg)?.[1]?.trim().split(/\s+/);
1919
+ return vb?.length === 4 ? Number(vb[1]) : Number.NaN;
1920
+ }
1921
+
1922
+ /*
1923
+ ★★ GROWING THE BOX MEANS GROWING THE viewBox, or the outermost <svg> clips the top edge off the
1924
+ container it was just given. fitToColumn reads the viewBox for the natural size it caps against
1925
+ and runs LAST in the pipeline, so the new numbers are picked up for free — but only because of
1926
+ that ordering.
1927
+ */
1928
+ function growViewBoxTop(svg: string, by: number): string {
1929
+ return svg.replace(/viewBox="([^"]+)"/, (whole, v: string) => {
1930
+ const n = v.trim().split(/\s+/).map(Number);
1931
+ if (n.length !== 4 || n.some(Number.isNaN)) return whole;
1932
+ return `viewBox="${n[0]} ${round4(n[1] - by)} ${n[2]} ${round4(n[3] + by)}"`;
1933
+ });
1934
+ }
1935
+
1936
+ /*
1937
+ ── THE BOX THAT IS NOT A RECT ─────────────────────────────────────────────────────────────────
1938
+ mermaid draws a class box, a state NOTE and an ER entity through its unified shape registry, which
1939
+ hands them to rough.js. rough returns a <g> of two <path>s — a solid fill path and a double-stroked
1940
+ outline of eight open beziers — so the element is `g.basic.label-container.outer-path` and there is
1941
+ no <rect> anywhere in it. Every corner rule in DIAGRAM_RULES names `rect`, including the one that
1942
+ repeats mermaid's own `.statediagram-note rect`, so all of them match nothing and the boxes draw
1943
+ square. mermaid's own note rule is dead upstream for the same reason.
1944
+
1945
+ roundPolygons cannot reach these: it reads the `points` ATTRIBUTE of a <polygon> and walks one
1946
+ closed ring of straight segments. rough emits neither.
1947
+
1948
+ So restore the rect. THE RECTANGLE TEST IS THE SCOPING — `outer-path` is shared with the cylinder,
1949
+ the stadium and every other rough shape, and on those the outline IS the meaning. Their fill path
1950
+ opens with a curve; only a real box gives four axis-aligned corners. Colours travel as PRESENTATION
1951
+ attributes, exactly as rough wrote them, so the tone rules still override them.
1952
+ */
1953
+ function axisAlignedRect(d: string): { x: number; y: number; w: number; h: number } | null {
1954
+ const tokens = d.trim().match(/[A-Za-z]|-?\d*\.?\d+/g);
1955
+ if (!tokens) return null;
1956
+ const letters = tokens.filter((t) => /[A-Za-z]/.test(t));
1957
+ /* ★ THE COMMAND LIST IS CHECKED BEFORE THE COORDINATE COUNT, and both are load-bearing. A curve
1958
+ is rejected here even when it happens to carry eight numbers — `M x y C x y, x y, x y` does,
1959
+ and a coordinate-count test alone would read it as a box. */
1960
+ if (letters.length < 4 || letters[0] !== 'M' || !letters.slice(1, 4).every((c) => c === 'L')) return null;
1961
+ if (letters.slice(4).some((c) => c !== 'Z' && c !== 'z')) return null;
1962
+ const nums = tokens.filter((t) => !/[A-Za-z]/.test(t)).map(Number);
1963
+ if (nums.length !== 8 || nums.some(Number.isNaN)) return null;
1964
+ const [x0, y0, x1, y1, x2, y2, x3, y3] = nums;
1965
+ if (y0 !== y1 || x1 !== x2 || y2 !== y3 || x3 !== x0) return null;
1966
+ const w = x1 - x0, h = y3 - y0;
1967
+ if (!(w > 0 && h > 0)) return null;
1968
+ return { x: x0, y: y0, w, h };
1969
+ }
1970
+
1971
+ export function rectifyOuterPaths(svg: string): string {
1972
+ return svg.replace(/<g class="([^"]*\bouter-path\b[^"]*)">([\s\S]*?)<\/g>/g, (whole, cls: string, body: string) => {
1973
+ // A nested group would end the non-greedy match early and the replacement would eat live markup.
1974
+ if (body.includes('<g')) return whole;
1975
+ const paths = [...body.matchAll(/<path\b([^>]*?)\/?>/g)].map((m) => m[1]);
1976
+ if (!paths.length) return whole;
1977
+ const attr = (s: string, name: string) => (s.match(new RegExp(`\\b${name}="([^"]*)"`)) || [])[1];
1978
+ const box = axisAlignedRect(attr(paths[0], 'd') ?? '');
1979
+ if (!box) return whole;
1980
+ const stroke = paths[1] ?? '';
1981
+ const dash = attr(stroke, 'stroke-dasharray');
1982
+ const out = [
1983
+ `class="${cls.replace(/\bouter-path\b/, '').trim()}"`,
1984
+ `x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"`,
1985
+ `fill="${attr(paths[0], 'fill') ?? 'none'}"`,
1986
+ `stroke="${attr(stroke, 'stroke') ?? 'none'}"`,
1987
+ `stroke-width="${attr(stroke, 'stroke-width') ?? '0'}"`,
1988
+ dash && dash !== '0 0' ? `stroke-dasharray="${dash}"` : '',
1989
+ ].filter(Boolean).join(' ');
1990
+ return `<rect ${out}/>`;
1991
+ });
1992
+ }
1993
+
1994
+ export function roundPolygons(svg: string, radius: number): string {
1995
+ /* ⚠ THE CLOSING TAG IS PART OF THE MATCH. Replacing only `<polygon …>` leaves a `</polygon>` with
1996
+ no opening tag, and the parser then nests the node's LABEL inside the new <path> — the shape
1997
+ rounds correctly and the text disappears, which looks like a labelling bug rather than a markup
1998
+ one. Both the self-closing and the paired form are consumed. */
1999
+ return svg.replace(/<polygon([^>]*?)points="([^"]+)"([^>]*?)\/?>(?:<\/polygon>)?/g, (whole, pre, points, post) => {
2000
+ /*
2001
+ ⚠ NOT EVERY POLYGON IS A NODE. The sequence fragment's tab is one — a five-point shape with a
2002
+ clipped bottom-right corner, `<polygon class="labelBox" points="351,390 407,390 407,406
2003
+ 398.6,413 351,413">` — and that clip is the notation: it is what makes the tab read as a tab
2004
+ rather than a small box sitting on the frame. Rounded at the node radius it came out as a
2005
+ lozenge, and the clipped corner disappeared into the curve. mermaid's own shape is right here
2006
+ and the deck leaves it alone.
2007
+ */
2008
+ if (/\blabelBox\b/.test(pre) || /\blabelBox\b/.test(post)) return whole;
2009
+ const pts = points.trim().split(/\s+/).map((pair: string) => pair.split(',').map(Number));
2010
+ if (pts.length < 3 || pts.some((q: number[]) => q.length !== 2 || q.some(Number.isNaN))) return whole;
2011
+ const n = pts.length;
2012
+ let d = '';
2013
+ for (let i = 0; i < n; i++) {
2014
+ const prev = pts[(i - 1 + n) % n], cur = pts[i], next = pts[(i + 1) % n];
2015
+ const seg = (a: number[], b: number[]) => {
2016
+ const dx = b[0] - a[0], dy = b[1] - a[1];
2017
+ const len = Math.hypot(dx, dy) || 1;
2018
+ return { ux: dx / len, uy: dy / len, len };
2019
+ };
2020
+ const from = seg(cur, prev), to = seg(cur, next);
2021
+ const r = Math.min(radius, from.len / 2, to.len / 2);
2022
+ const a = [cur[0] + from.ux * r, cur[1] + from.uy * r];
2023
+ const b = [cur[0] + to.ux * r, cur[1] + to.uy * r];
2024
+ d += `${i === 0 ? 'M' : 'L'}${a[0].toFixed(2)},${a[1].toFixed(2)} Q${cur[0].toFixed(2)},${cur[1].toFixed(2)} ${b[0].toFixed(2)},${b[1].toFixed(2)} `;
2025
+ }
2026
+ return `<path${pre}d="${d.trim()}Z"${post.replace(/\/$/, '')}/>`;
2027
+ });
2028
+ }
2029
+
2030
+ /**
2031
+ * Append the floor to mermaid's own stylesheet, scoped to this diagram's id.
2032
+ *
2033
+ * Scoped rather than global for the same reason mermaid scopes its own: two diagrams on one slide
2034
+ * are two shadow roots today, but the id prefix is what makes that an implementation detail rather
2035
+ * than something these rules depend on.
2036
+ */
2037
+ export function applyRoomScale(svg: string, id: string, register: Register = 'light'): string {
2038
+ /*
2039
+ ★★ EVERY SELECTOR IN THE LIST IS SCOPED, NOT JUST THE FIRST. `#${id} ${rule}` prefixes the string,
2040
+ which reaches the first selector of a comma list and no other — so a rule written for five shapes
2041
+ arrived as one scoped selector at (1,1,1) and four unscoped ones at (0,1,1), and mermaid's own
2042
+ `#id .node …` beat every one of the four. The visible symptom was a rounded rhombus keeping a 1px
2043
+ border while the rectangle beside it took 3.
2044
+ */
2045
+ const scope = (rule: string) => {
2046
+ const open = rule.indexOf('{');
2047
+ if (open < 0) return `#${id} ${rule}`;
2048
+ const selectors = rule.slice(0, open).split(',').map((sel) => `#${id} ${sel.trim()}`).join(',');
2049
+ return selectors + rule.slice(open);
2050
+ };
2051
+ // The background rules carry literal hex and so are the one part of this list that differs by
2052
+ // register — appended rather than interleaved, so a background always wins the tie against the
2053
+ // untoned rule above it.
2054
+ const rules = [...ROOM_SCALE_RULES, ...mermaidInkRules(register), ...mermaidBackgroundRules(register)].map(scope).join('');
2055
+ // If mermaid ever stops emitting a <style>, silently dropping the floor would look like the
2056
+ // fonts simply being wrong again — so the rules go in either way.
2057
+ return svg.includes('</style>')
2058
+ ? svg.replace('</style>', `${rules}</style>`)
2059
+ : svg.replace(/(<svg[^>]*>)/, `$1<style>${rules}</style>`);
2060
+ }
2061
+
2062
+ /*
2063
+ ★★ RENDERS ARE SERIALISED, AND THIS IS THE ONE THING IN THIS FILE THAT ONLY EXPORT COULD FIND.
2064
+
2065
+ `mermaid.initialize()` does not configure a render — it mutates ONE piece of module-global state
2066
+ that the next `mermaid.render()` reads. This renderer therefore does initialize-then-render as a
2067
+ pair, and that pair has to be atomic.
2068
+
2069
+ In the browser it looked atomic for free: Slidev mounts one slide at a time, so no two diagrams
2070
+ are ever in flight together. `slidev export` does not work that way — it renders the WHOLE deck
2071
+ into one print container, so every diagram mounts at once and four `initialize` calls interleave
2072
+ with four `render` calls. The last configuration written wins for all of them.
2073
+
2074
+ MEASURED, on the exported PDF of this very deck: the specimen's four diagram slides are three
2075
+ light and one dark, and in the PDF all four came out in the DARK palette — near-black nodes with
2076
+ white labels, sitting on the deck's cream ground. The sequence diagram's message labels were
2077
+ near-white text on cream, i.e. invisible. Every one of them renders correctly in a browser, and
2078
+ the audit (which drives a browser) passed them, because the audit visits one slide at a time.
2079
+
2080
+ The queue is a plain promise chain: each call waits for the previous pair to finish before writing
2081
+ its own config. It costs nothing in the browser, where the queue is never more than one deep.
2082
+ */
2083
+ let renderQueue: Promise<unknown> = Promise.resolve();
2084
+
2085
+ export default function setupMermaidRenderer(): MermaidRenderFn {
2086
+ return async (code, options) => {
2087
+ const register = options?.theme === 'dark' ? 'dark' : 'light';
2088
+
2089
+ // `theme` is consumed here — it names a REGISTER in this deck, not one of mermaid's own themes,
2090
+ // and passing it through would select mermaid's built-in 'dark' palette and discard every
2091
+ // themeVariable below it. `scale` belongs to Slidev's own component, not to mermaid.
2092
+ const { theme: _theme, scale: _scale, ...rest } = options ?? {};
2093
+
2094
+ const run = renderQueue.then(async () => {
2095
+ const mermaid = await loadMermaid();
2096
+ mermaid.initialize({ ...mermaidConfig(register), ...rest });
2097
+
2098
+ // Same container @slidev/client's own fallback path uses — mermaid measures text in a live
2099
+ // DOM node, and without one every label is laid out against a zero-width box.
2100
+ const container = document.getElementById('mermaid-rendering-container') ?? undefined;
2101
+ const id = `forest-mermaid-${register}-${counter++}`;
2102
+ const { svg } = await mermaid.render(id, code, container as Element);
2103
+ const shaped = recentreEdgeLabels(
2104
+ chevronArrowheads(growArrowheads(viewBoxArrowheads(unscaleStrokeMarkers(sizeDiamondMarkers(roundTimelineNodes(roundPolygons(rectifyOuterPaths(svg), POLYGON_RADIUS), DIAGRAM_RADIUS)))))),
2105
+ );
2106
+ /*
2107
+ ★ BOTH CONTAINER PASSES RUN AFTER recentreEdgeLabels, AND FOR DIFFERENT REASONS. The ground
2108
+ pass adds an ATTRIBUTE to `g.label`, which recentre matches as `<g class="label"` and would
2109
+ still find — but seating reads where the labels ENDED UP, and recentre is what moves them.
2110
+ */
2111
+ const contained = seatClusterTitles(groundLabelsOnContainers(shaped));
2112
+ const spaced = seatTreemapHeaders(spaceRadarLegend(spaceJourneyLegend(spaceLegendRows(contained, PIE_LEGEND_PX), PIE_LEGEND_PX), MERMAID_LEGEND_FONT_PX));
2113
+ const placed = placeJourneyFaces(spaced, MERMAID_FONT_PX * FACE_DIAMETER_EM);
2114
+ return fitToColumn(applyRoomScale(placed, id, register));
2115
+ });
2116
+ // The queue must survive a failed render, or one bad diagram wedges every one after it.
2117
+ renderQueue = run.catch(() => {});
2118
+ return run;
2119
+ };
2120
+ }