@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,972 @@
1
+ // Mermaid, in Forest colours and at room scale.
2
+ //
3
+ // ★★ WHY THIS IS CONFIG AND NOT CSS, which is the fact that shapes everything below. Slidev renders
4
+ // a mermaid diagram into a SHADOW ROOT (@slidev/client/builtin/Mermaid.vue mounts the SVG through
5
+ // internals/ShadowRoot.vue). A stylesheet in this theme cannot reach inside one. Custom properties
6
+ // DO pierce the boundary, but that does not help either: mermaid writes literal colours into a
7
+ // <style> block it generates itself, and derives about a hundred secondary values from the ones it
8
+ // is handed using real colour arithmetic (khroma) — hand `var(--deck-accent)` to that and you get
9
+ // NaN, not a green. So the only way in is mermaid's own `themeVariables`, in literal hex, per
10
+ // register. components/deckPalette.ts is what makes that safe: the hex is derived from the same
11
+ // generated tokens the stylesheet is, never typed.
12
+ //
13
+ // Out of the box the diagrams were unusable on this deck — lavender nodes, 10px labels on a 1920px
14
+ // canvas, and on a dark slide an edge stroke a shade off the ground it was drawn on. Probed before
15
+ // any of this was written.
16
+ //
17
+ // ── THE COLOUR ASSIGNMENT ──────────────────────────────────────────────────────────────────────
18
+ //
19
+ // A diagram is not a chart. Its job is to show a STRUCTURE, so the default node is a quiet surface
20
+ // — the same `--deck-surface` a Card sits on — and colour is spent only where it means something:
21
+ // the accent on the edges and arrowheads that carry the reading order, and the categorical series
22
+ // only where a diagram genuinely encodes categories (pie slices, git branches). A flowchart whose
23
+ // every node is a different colour has spent its whole palette on nothing.
24
+ //
25
+ // FIVE, AND THE DECK'S CEILING IS SIX — so this is a deliberate stop short of it, not a mirror of
26
+ // it. The sixth vivid slot is `red`, and chartData.ts records it sitting dE00 3 from
27
+ // --deck-status-danger-mark on the dark ground. In a chart that is a warning; here it is worse,
28
+ // because the crit bar below is already that hue, so a six-colour pie would put a category and an
29
+ // alarm side by side in the same red. pie6..pie12 cycle back through the five rather than inventing
30
+ // hues nobody validated: mermaid will happily draw a twelve-slice pie, and it will do it in five
31
+ // colours, which is the honest picture of a chart that has run out of palette.
32
+ //
33
+ // ── ROOM SCALE ────────────────────────────────────────────────────────────────────────────────
34
+ //
35
+ // `fontSize` is a NUMBER, in the SVG's own coordinate space, and mermaid lays every box out around
36
+ // it — so it is the one lever that decides whether a diagram is legible. It is set from the deck's
37
+ // own body-sm rung rather than from a taste-picked number, which means a diagram's labels and the
38
+ // paragraph beside it are the same size by construction.
39
+ import { DATAVIZ_CATEGORICAL } from '@humanforest/tokens/colourEngine';
40
+ import { BACKGROUND_NAMES, derivedMix, mixOklab, palette, background, ramp, type Register } from './deckPalette';
41
+
42
+ /**
43
+ * The categorical series, resolved — the PUBLISHED vivid set, sliced to five.
44
+ *
45
+ * ★★ THIS USED TO BE A BAND OF BRAND RAMPS (forest/river/amber/maple/acid at 600/400) under a
46
+ * comment claiming it mirrored deck.css's `--deck-series-N` aliases. It shared ZERO hex values with
47
+ * them, in either register, and nothing anywhere recorded a reason — the file has one commit and its
48
+ * message repeats the same false claim. So it was a transcription, not a decision.
49
+ *
50
+ * ★★ AND THE BRAND BAND WAS BROKEN AT THE ONE JOB THESE KEYS HAVE. Measured with colourEngine's own
51
+ * dE00cvd: brand amber against brand acid is worst-case-CVD dE00 3 on light and 1 on DARK — two pie
52
+ * slices that are the same colour to a dichromat, forever, with nothing on screen to say so. The
53
+ * published five hold 9 and 8. A categorical slot whose categories collapse is not a palette choice,
54
+ * it is a defect.
55
+ *
56
+ * Taken from DATAVIZ_CATEGORICAL directly, which is the same source scripts/dataviz.ts generates
57
+ * deck.css's aliases from, so there is no second copy to drift.
58
+ *
59
+ * ★ THE GAP THIS CANNOT CLOSE, stated because it is real: a `.deck-pairs` slide re-points
60
+ * --deck-series-* to the brand pairs at render time, and mermaid is baked per REGISTER at build
61
+ * time. A diagram on such a slide keeps the vivid five while the charts beside it turn to pairs.
62
+ * Mermaid takes hexes, not var() references; following a slide class would mean re-theming mermaid
63
+ * per slide, which is a bigger machine than the mismatch is worth.
64
+ */
65
+ export const SERIES: Record<Register, string[]> = {
66
+ light: DATAVIZ_CATEGORICAL.light.slice(0, 5),
67
+ dark: DATAVIZ_CATEGORICAL.dark.slice(0, 5),
68
+ };
69
+
70
+ /** The label size every box is laid out around — the deck's body-sm rung, in px. */
71
+ export const MERMAID_FONT_PX = 36;
72
+
73
+ /*
74
+ THE DECK'S STRONG EDGE, in SVG USER UNITS so it scales with the drawing rather than standing still
75
+ while everything around it shrinks — see the long note in setup/mermaid-renderer.ts, which
76
+ re-exports this and is where most of the rules that read it live. It is declared HERE because
77
+ radar states its weights as theme variables, and theme variables are built in this file.
78
+ */
79
+ export const DIAGRAM_STROKE_PX = 4;
80
+
81
+ /*
82
+ A LEGEND ENTRY — the key beside a chart, on any family that draws one. Subordinate to the diagram's
83
+ own prose and read at a glance rather than across the room, which is what four fifths of the body
84
+ rung says. The pie's legend has taken this size since it was written; radar joins it, and the
85
+ renderer's swatch and row-spacing pass read the same number so the three cannot drift apart.
86
+ */
87
+ export const MERMAID_LEGEND_FONT_PX = Math.round(MERMAID_FONT_PX * 0.8);
88
+
89
+ /*
90
+ Mermaid's own numeric defaults are drawn for its default 16px type. Any of them that is a BOX and
91
+ not a font has to be scaled by hand when the font moves, because mermaid sizes the box from the
92
+ config and the text from `fontSize`, and never compares the two.
93
+ */
94
+ const MERMAID_DEFAULT_FONT_PX = 16;
95
+
96
+ /*
97
+ The sequence fragment's keyword — `opt`, `alt`, `loop` — is a structural marker rather than part
98
+ of the diagram's prose, and takes a step down from the body rung every label around it uses. The
99
+ renderer sets the TEXT from this same number — with `!important`, because mermaid writes the
100
+ keyword's size as an INLINE STYLE and no plain rule reaches it. The tab is the BOX it sits in, so both move
101
+ together or the word stops fitting again.
102
+ */
103
+ export const MERMAID_TAG_FONT_PX = Math.round(MERMAID_FONT_PX * 0.5);
104
+
105
+ /*
106
+ The autonumber index, which USED to share the tag rung and no longer does. Both are subordinate
107
+ to the diagram's prose, but they are subordinate to different things: a fragment keyword labels a
108
+ block and can afford to recede, while an index is read against the message it numbers and has
109
+ just enough glyph to be read at all. Tying them meant every step down for one was a step down for
110
+ the other.
111
+ */
112
+ export const MERMAID_INDEX_FONT_PX = Math.round(MERMAID_FONT_PX * 0.7);
113
+
114
+ /*
115
+ A transition name — the word ON an edge. Subordinate to the node labels it joins, which is the
116
+ argument that also mutes it to 75%, but it is PROSE and not a mark: it has to be read across a
117
+ room, where an index only has to be counted. So it steps down one rung and no further, where the
118
+ fragment keyword steps down two and the index sits between them. Three roles, three rungs, none
119
+ of them borrowing another's — tying any two means a change for one is a change for the other.
120
+ */
121
+ export const MERMAID_EDGE_LABEL_FONT_PX = Math.round(MERMAID_FONT_PX * 0.8);
122
+
123
+ /** A quadrant point's dot. Its label's clearance is derived from it — see `pointTextPadding`. */
124
+ const QUADRANT_POINT_RADIUS = 12;
125
+
126
+ const toTagScale = (mermaidDefault: number) =>
127
+ Math.round((mermaidDefault * MERMAID_TAG_FONT_PX) / MERMAID_DEFAULT_FONT_PX);
128
+
129
+ /** The deck's sans, as a family string. @font-face is DOCUMENT-scoped, so the face a shadow root
130
+ * names is the same one the slide loaded — no second copy, no second request. */
131
+ export const MERMAID_FONT_FAMILY = "'GT Haptik', system-ui, sans-serif";
132
+
133
+ /**
134
+ * Mermaid's `themeVariables`, for one register.
135
+ *
136
+ * Written as one flat table on purpose. Mermaid's variables are a per-diagram-type vocabulary with
137
+ * no shared spine — `mainBkg` is a flowchart node, `actorBkg` the same idea in a sequence diagram,
138
+ * `nodeBorder` and `actorBorder` likewise — and leaving any of them unset means mermaid DERIVES it
139
+ * from `primaryColor` with its own colour maths, which is exactly how a Forest-green flowchart ends
140
+ * up sitting next to a lavender sequence diagram. So every family is stated, and the grouping
141
+ * comments below say which diagram each block is actually steering.
142
+ */
143
+ export function mermaidThemeVariables(register: Register): Record<string, string> {
144
+ const p = palette(register);
145
+ const series = SERIES[register];
146
+ const [s1, s2, s3, s4, s5] = series;
147
+
148
+ return {
149
+ // ★★ `darkMode` IS DELIBERATELY NOT SET, and setting it is a trap worth naming. Mermaid's base
150
+ // theme uses that flag to DERIVE a dark palette from a light one — it runs `invert()` over
151
+ // primaryColor and its relatives. Handed an already-dark `primaryColor` it inverts it back to
152
+ // light: measured, the dark register rendered cream nodes with near-black labels on a dark
153
+ // slide, which reads as the light theme leaking through rather than as a bug. Every value here
154
+ // is stated for both registers, so there is nothing left for mermaid to derive.
155
+ background: p.bg,
156
+ fontFamily: MERMAID_FONT_FAMILY,
157
+ fontSize: `${MERMAID_FONT_PX}px`,
158
+
159
+ // ── The spine every diagram type derives from ─────────────────────────────────────────────
160
+ // A node is a Card: the quiet surface one step off the page, with the deck's own border.
161
+ primaryColor: p.surface,
162
+ primaryTextColor: p.fg,
163
+ primaryBorderColor: p.borderStrong,
164
+ secondaryColor: p.surface,
165
+ secondaryTextColor: p.fg,
166
+ secondaryBorderColor: p.border,
167
+ /*
168
+ ⚠ THREE SLOTS ON THE DECK SURFACE, AND TWO OF THEM DO NOT DRAW. tertiary, labelBox and
169
+ activation all pointed at --deck-accent-soft, a role that has since been retired because
170
+ nothing in the theme rendered it: drawn on both registers, mermaid v11 leaves the opt/alt label
171
+ box and the activation bar OUTLINED, whatever these two variables say, and no diagram in the
172
+ deck reaches tertiary. They point at the derived surface now — a box on the page, in either
173
+ register — because a slot that does not render today is exactly where not to plant
174
+ --deck-bg-accent, which on the dark register IS the page.
175
+ */
176
+ tertiaryColor: p.surface,
177
+ tertiaryTextColor: p.fg,
178
+ tertiaryBorderColor: p.border,
179
+ // ★ THE EDGE CARRIES THE READING. It is the accent, at full strength, because the line between
180
+ // two boxes is the only part of a flowchart that says what the diagram MEANS — and on a dark
181
+ // slide the shipped default drew it a shade off the ground.
182
+ lineColor: p.accent,
183
+ textColor: p.fg,
184
+ mainBkg: p.surface,
185
+ nodeBorder: p.borderStrong,
186
+ nodeTextColor: p.fg,
187
+ titleColor: p.fg,
188
+
189
+ // ── Flowchart ─────────────────────────────────────────────────────────────────────────────
190
+ clusterBkg: p.surface,
191
+ clusterBorder: p.border,
192
+ // The label sitting ON an edge needs the PAGE behind it, not the node fill — it interrupts a
193
+ // line, it does not sit in a box.
194
+ edgeLabelBackground: p.bg,
195
+ defaultLinkColor: p.accent,
196
+ arrowheadColor: p.accent,
197
+
198
+ // ── Sequence ──────────────────────────────────────────────────────────────────────────────
199
+ actorBkg: p.surface,
200
+ actorBorder: p.borderStrong,
201
+ actorTextColor: p.fg,
202
+ actorLineColor: p.borderStrong,
203
+ signalColor: p.accent,
204
+ signalTextColor: p.fg,
205
+ labelBoxBkgColor: p.surface,
206
+ labelBoxBorderColor: p.accent,
207
+ labelTextColor: p.fg,
208
+ loopTextColor: p.fg,
209
+ /*
210
+ ★★ THE NOTE IS WARM WHERE THE FLOW IS ACCENT, and that is a semantic split rather than a
211
+ preference. Everything else in a sequence belongs TO the sequence — the signals, the actor
212
+ boxes, the activation bars, the opt/alt label boxes — and they are all the accent family. A
213
+ note is the one element that comments ON it from outside, so it takes the other family the
214
+ deck publishes. Give the annotation the same green as the thing it annotates and the reader
215
+ has to parse the shape to find the aside.
216
+
217
+ It is the warm PAIR, not a warm fill: --deck-bg-warm with --deck-on-bg-warm as both the ink and
218
+ the edge, so the box can never assemble an ink that was not measured against its own ground
219
+ (10.26:1 on light, 6.81:1 on dark). The border was `p.accent` — a green edge on a peach fill,
220
+ which is what borrowing a fill from one family and an edge from another looks like.
221
+
222
+ ★ IT ALSO SEPARATES BETTER FROM THE PAGE, which is the part I did not expect. Against the
223
+ deck's own grounds, warm measures 1.30:1 on cream and 2.21:1 on near-black, where accent-soft
224
+ manages 1.13 and 1.36. A note box is a filled box whose job is to be visibly a box.
225
+ */
226
+ noteBkgColor: background('warm', register).fill,
227
+ noteTextColor: background('warm', register).ink,
228
+ noteBorderColor: background('warm', register).ink,
229
+ activationBkgColor: p.surface,
230
+ activationBorderColor: p.accent,
231
+ sequenceNumberColor: p.onAccent,
232
+
233
+ // ── State ─────────────────────────────────────────────────────────────────────────────────
234
+ labelColor: p.fg,
235
+ transitionColor: p.accent,
236
+ transitionLabelColor: p.fg,
237
+ stateLabelColor: p.fg,
238
+ stateBkg: p.surface,
239
+ /*
240
+ ★★ ONE GROUND FOR THE WHOLE COMPOSITE, TITLE BAND INCLUDED. `compositeTitleBackground` was the
241
+ page and `compositeBackground` the surface, which drew the container as two stacked grounds
242
+ with a seam across it — a title strip in one colour over a body in another, inside a border
243
+ meant to hold ONE object. mermaid offers the pair so a title can be picked out; this deck
244
+ already picks a container's title out by its position and its weight, and a second fill is the
245
+ kind of decoration the diagram doctrine here spends colour to avoid.
246
+
247
+ Both the surface now, so a composite reads as what it is: a Card with a machine in it, the
248
+ same treatment `subgraph` gets in the flowchart.
249
+ */
250
+ altBackground: p.surface,
251
+ compositeBackground: p.surface,
252
+ compositeBorder: p.border,
253
+ compositeTitleBackground: p.surface,
254
+ /* ⚠ DEAD IN 11.17.2, KEPT FOR THE NAME. stateEnd fills the inner dot from `stateBorder ?? nodeBorder`
255
+ and never reads this; mermaid's own `.node circle.state-end { fill: innerEndBackground }` cannot
256
+ match either, because the group it targets is classed `outer`. Recorded rather than removed, so
257
+ the next reader does not re-derive it. */
258
+ /*
259
+ ★★ THE GANTT'S EXCLUDED BAND IS A LITERAL GREY WITH NO DERIVATION. The base theme defaults it
260
+ `this.excludeBkgColor = this.excludeBkgColor || "#eeeeee"` — a fixed light grey, not a step off
261
+ anything the deck sets, so `excludes weekends` painted #eeeeee on warm paper in the light
262
+ register and the same #eeeeee on near-black in the dark one. It takes the deck's own surface,
263
+ which is what every other quiet band in the family already uses.
264
+ */
265
+ excludeBkgColor: p.surface,
266
+ innerEndBackground: p.fg,
267
+ specialStateColor: p.fg,
268
+
269
+ // ── Class / ER ────────────────────────────────────────────────────────────────────────────
270
+ classText: p.fg,
271
+ /* Odd/even alternated surface against the 8% bg-subtle — 1.08:1, a stripe in name only. Now
272
+ surface against the page, which is the same alternation every other striped thing here uses
273
+ (gantt sections, quadrants) and one the eye can actually follow.
274
+
275
+ ⚠ AND BOTH OF THESE ARE DEAD IN 11.17.2, kept only because they cost nothing and name the
276
+ intent. `attributeBox` appears nowhere in mermaid's dist, and the ER stylesheet has no
277
+ `.attributeBoxOdd`/`.attributeBoxEven` rule to read them. The rows are drawn from `rowOdd`
278
+ and `rowEven` instead — see below. */
279
+ attributeBackgroundColorOdd: p.surface,
280
+ attributeBackgroundColorEven: p.bg,
281
+ /*
282
+ ★★ THE KEYS THE ATTRIBUTE TABLE ACTUALLY READS, and leaving them unset painted it WHITE on
283
+ both registers. shapes/erBox reads `rowEven`/`rowOdd` off themeVariables for every attribute
284
+ row; unset, theme-base derives them — and its light branch is `lighten(mainBkg, 75)`, which on
285
+ this deck's surface CLAMPS TO #ffffff. Worse on the dark register than the light one, because
286
+ `darkMode` is deliberately never set here (see above), so the light branch runs there too and
287
+ a near-white band lands on a near-black slide.
288
+
289
+ Same alternation the dead pair above names, now on the keys that read it.
290
+ */
291
+ rowOdd: p.surface,
292
+ rowEven: p.bg,
293
+
294
+ // ── Pie — CATEGORICAL, and this file stops at five though the deck allows six (see above) ──
295
+ // pie6..pie12 cycle the same five rather than inventing hues. A twelve-slice pie will repeat
296
+ // colours, which is the honest picture of a chart past its palette.
297
+ ...Object.fromEntries(
298
+ Array.from({ length: 12 }, (_, i) => [`pie${i + 1}`, series[i % series.length]]),
299
+ ),
300
+ /* ★ MERMAID DEFAULTS PIE SLICES TO 0.7 OPACITY, which is why the series came out washed. At 0.7
301
+ over paper they are five colours nobody measured; at full strength they are the five that
302
+ were. The separation between slices comes from the page-coloured stroke below instead.
303
+
304
+ ★★ THE OLD VERSION OF THIS NOTE WAS WRONG TWICE and is worth correcting rather than deleting.
305
+ It said the hex handed in "was the published categorical palette" — it was not, it was a band
306
+ of brand ramps (see SERIES above), which is the drift this file has now had fixed. And it
307
+ credited the gate to scripts/dataviz.ts, which contains no contrast code at all: colourEngine
308
+ exports validateDataViz and nothing in the repo calls it. Two claims of provenance, neither
309
+ true, sitting above a line whose actual behaviour was correct.
310
+
311
+ ★ THE REAL FLOOR, measured. pieSectionTextSize is the deck's body-sm rung x 0.8 = 29px, which
312
+ is large text, so the in-slice label is held to 3:1 rather than 4.5:1. White ink on the five
313
+ clears it on light at 3.20-6.46, and the dark register's onAccent ink clears it at 5.00-9.04.
314
+ The ceiling: mermaid exposes ONE pieSectionTextColor for every slice, so the ink cannot follow
315
+ the fill — and if a pie is ever shrunk below the large-text threshold the floor becomes 4.5:1,
316
+ where green, amber and sky fall under. A pie that small is the thing to fix, not the ink. */
317
+ pieOpacity: '1',
318
+ pieTitleTextColor: p.fg,
319
+ pieSectionTextColor: p.onAccent,
320
+ pieLegendTextColor: p.fg,
321
+ pieStrokeColor: p.bg,
322
+ pieOuterStrokeColor: p.bg,
323
+ pieTitleTextSize: `${MERMAID_FONT_PX}px`,
324
+ pieSectionTextSize: `${Math.round(MERMAID_FONT_PX * 0.8)}px`,
325
+ pieLegendTextSize: `${Math.round(MERMAID_FONT_PX * 0.8)}px`,
326
+
327
+ /* ── Git graph ─────────────────────────────────────────────────────────────────────────────
328
+ ★★ THESE ARE NOT PAINTED AS HANDED IN, and the comment here used to say they were ("categorical
329
+ again, same five"). mermaid darkens every git slot by 25 HSL points before it draws
330
+ (mermaid.js, `if (this.darkMode) { lighten(git0,25) } else { darken(git0,25) }`), and darkMode
331
+ is deliberately never set here, so the ELSE branch runs in BOTH registers — including the dark
332
+ one, where darkening is the wrong direction. The five below are therefore a starting point
333
+ mermaid transforms, not the palette that reaches the screen.
334
+ Left as the series anyway: it is still the right input, and the alternative is pre-lightening
335
+ by 25 to cancel a library behaviour, which breaks the moment mermaid changes it. Recorded
336
+ rather than worked around. */
337
+ ...Object.fromEntries(
338
+ Array.from({ length: 8 }, (_, i) => [`git${i}`, series[i % series.length]]),
339
+ ),
340
+ gitInv0: p.onAccent,
341
+ gitBranchLabel0: p.onAccent,
342
+
343
+ /*
344
+ ── THE TWELVE-SLOT CATEGORICAL, WHICH THE THEME HAD NEVER STATED ─────────────────────────
345
+
346
+ ★★ UNSET, THESE COLLAPSE TO ONE COLOUR. Every family that colours itself categorically —
347
+ timeline, mindmap, kanban, radar, treemap — reads `cScale0..11`, and mermaid's base theme
348
+ DERIVES them when they are absent: cScale0 = primaryColor, cScale1 = secondaryColor,
349
+ cScale2 = tertiaryColor, then 3..11 as hue rotations of primaryColor. This theme points all
350
+ three of those at `p.surface`, deliberately, because a NODE is a Card — so the first three
351
+ slots were byte-identical and the rest were hue rotations of one near-neutral. A timeline
352
+ drawn today would have been a row of barely separable greys.
353
+
354
+ Stated from the deck's own five, cycled, the way git0..git7 already are. Twelve slots against
355
+ five colours means a family that needs more than five repeats one rather than inventing a
356
+ sixth — which is the deck's published ceiling and the same rule the pie takes.
357
+
358
+ ⚠ AND EACH IS DARKENED BY 25 BEFORE IT REACHES THE PAGE, in BOTH registers, because mermaid
359
+ only lightens when `darkMode` is set and this theme deliberately never sets it. Same
360
+ transform the git slots take; the input is still right, and pre-lightening to cancel a
361
+ library behaviour breaks the moment that behaviour changes.
362
+ */
363
+ ...Object.fromEntries(
364
+ Array.from({ length: 12 }, (_, i) => [`cScale${i}`, series[i % series.length]]),
365
+ ),
366
+ /*
367
+ ★★ THE INK ON THOSE FILLS IS THE DARK ONE, AND THAT IS THE OPPOSITE OF THE DECK'S USUAL RULE.
368
+ A categorical fill normally takes `onAccent` — the deck pairs a saturated ground with light
369
+ ink. It is wrong here for one reason: mermaid DARKENS every cScale slot by 25 before painting
370
+ it, so the colour on the page is not the colour handed over, and the pairing has to be made
371
+ against what actually lands. MEASURED off the rendered timeline, white against dark ink on the
372
+ three bands a three-period timeline draws:
373
+
374
+ rgb(0,164,71) green white 3.28 dark 5.48
375
+ rgb(218,118,0) orange white 3.20 dark 5.62
376
+ rgb(0,132,200) blue white 4.09 dark 4.39
377
+
378
+ Dark wins on every one, and white only just clears the 3:1 large-text floor on two of them.
379
+
380
+ ⚠ THE LIMITATION IS WORTH NAMING: this is one ink for twelve slots, chosen from three measured
381
+ fills. It is right for the deck's five as mermaid darkens them, and it would need revisiting if
382
+ the categorical ever gained a genuinely dark member — there the pairing would flip back. The
383
+ honest fix is a per-slot choice made against the DARKENED value, which needs mermaid's own
384
+ darken() rather than the palette's.
385
+ */
386
+ ...Object.fromEntries(
387
+ Array.from({ length: 12 }, (_, i) => [`cScaleLabel${i}`, p.fg]),
388
+ ),
389
+ commitLabelColor: p.fg,
390
+ commitLabelBackground: p.bg,
391
+ /*
392
+ ★★ THE COMMIT ID IS SIZED HERE, NOT IN CSS. mermaid inserts its <style> BEFORE
393
+ renderer.draw(), so this variable is live for every getBBox() the renderer takes, and
394
+ setupGraphViewbox then sets the viewBox from that bbox plus padding. Re-pointed AFTERWARDS by
395
+ a stylesheet rule, the box was measured on 10px glyphs: rotateCommitLabel swings a label 2.9x
396
+ longer than the one measured up-and-right of its dot, and the two `main` commits reached
397
+ y -48.12 against a viewBox top of -34.5, where the svg viewport cut them — the audit saw
398
+ "fleet v1"/"fleet v2" 12% hidden, 11-12px off the top. Stated here, the bbox contains them.
399
+ This changes no type size — 29px either way — only when mermaid learns it. Commit spacing does
400
+ not move: COMMIT_STEP is a fixed 40.
401
+ */
402
+ commitLabelFontSize: `${MERMAID_EDGE_LABEL_FONT_PX}px`,
403
+
404
+ // ── Gantt ─────────────────────────────────────────────────────────────────────────────────
405
+ taskBkgColor: p.surface,
406
+ taskTextColor: p.fg,
407
+ taskTextOutsideColor: p.fg,
408
+ taskTextLightColor: p.onAccent,
409
+ taskBorderColor: p.borderStrong,
410
+ activeTaskBkgColor: p.accent,
411
+ activeTaskBorderColor: p.accent,
412
+ doneTaskBkgColor: p.surface,
413
+ doneTaskBorderColor: p.border,
414
+ /* ★★ A STATE, NOT A CATEGORY, and it only looked right by accident. This read `s4` — the fifth
415
+ series — which under the old brand band happened to be maple, the deck's DANGER family, so a
416
+ critical task came out red for the wrong reason. Re-pointing SERIES to the published palette
417
+ turns s4 into INDIGO, which would have made "critical" a calm blue and nothing would have
418
+ failed. Named against the semantic role instead, so the meaning is the source rather than the
419
+ ordering. Measured: on light this also lifts the task label from 3.42:1 to 4.60:1, because the
420
+ role is maple-500 where the old band took maple-600. */
421
+ critBkgColor: p.dangerMark,
422
+ critBorderColor: p.dangerMark,
423
+ gridColor: p.border,
424
+ todayLineColor: p.accent,
425
+ /* ★★ A SECTION IS A LANE, AND IT HAD NO GROUND TO SAY SO. mermaid bands the rows behind each
426
+ section and cycles THREE variables across four indices: section0 takes `sectionBkgColor`,
427
+ section2 takes `sectionBkgColor2`, and sections 1 and 3 take `altSectionBkgColor`. With the
428
+ page colour in the first and `altSectionBkgColor` left unset — mermaid's own white — both
429
+ bands on a two-section chart were invisible, so the section titles read as labels floating
430
+ beside the bars rather than as names for the rows they own.
431
+
432
+ Set so the cycle ALTERNATES: the tint on 0 and 2, the page on 1 and 3. The band is still the
433
+ quietest surface on the chart; it only has to be visible enough to bound a lane. */
434
+ sectionBkgColor: p.surface,
435
+ sectionBkgColor2: p.surface,
436
+ altSectionBkgColor: p.bg,
437
+
438
+ // ── Quadrant / journey / the odd ones out ─────────────────────────────────────────────────
439
+ quadrant1Fill: p.surface,
440
+ quadrant2Fill: p.bg,
441
+ quadrant3Fill: p.surface,
442
+ quadrant4Fill: p.bg,
443
+ quadrantPointFill: s1,
444
+ quadrantTitleFill: p.fg,
445
+
446
+ /*
447
+ ── RADAR ──────────────────────────────────────────────────────────────────────────────────
448
+ Every one of these is READ by the radar renderer; none of them is shared with another family,
449
+ which is the usual shape (see the note above about mermaid's per-family variables having no
450
+ spine).
451
+
452
+ ★★ THE LEGEND'S ROW PITCH IS A LITERAL 20 UNITS with no key and no selector behind it — the
453
+ row group is appended with no class at all — so nothing in this file can raise it and the size
454
+ below would be CAPPED at about 18 by a pitch it cannot move. It is not, because the renderer
455
+ re-pitches those rows after the fact: see spaceRadarLegend, which is also what centres the
456
+ block and seats each swatch against its own word. Stated here because the number below only
457
+ makes sense with that pass in place — set it larger with the pass removed and the rows collide.
458
+
459
+ ★ AND THE AXIS LABEL IS ANCHORED, NOT MEASURED: it is placed at `radius * axisLabelFactor` and
460
+ grows OUTWARD from there, so raising the size without raising the factor walks the words off
461
+ the viewBox. The two move together, and the margins below are what pays for it.
462
+ */
463
+ /*
464
+ ★★ AND IT IS A NESTED OBJECT OF NUMBERS, not the flat px strings the rest of this file uses.
465
+ Written flat first and every one of them was inert — mermaid reads `this.radar?.axisColor`
466
+ against a `radar` block it builds in theme-base, so a top-level `axisColor` is not a wrong
467
+ value, it is not a value at all. The sixth instance of this family of defect in this file,
468
+ and the only one whose tell was that NOTHING changed rather than that something changed wrong.
469
+ */
470
+ radar: {
471
+ axisColor: p.border,
472
+ axisStrokeWidth: DIAGRAM_STROKE_PX / 2,
473
+ axisLabelFontSize: MERMAID_EDGE_LABEL_FONT_PX,
474
+ graticuleColor: p.border,
475
+ graticuleOpacity: 1,
476
+ graticuleStrokeWidth: DIAGRAM_STROKE_PX / 2,
477
+ /*
478
+ ★ A WASH, NOT A FILL. The polygon's STROKE is what states the shape at the deck's full edge
479
+ weight, so the area under it only has to say which curve owns which region — and the less
480
+ it says, the better the graticule underneath it reads. mermaid's 0.5 mixes the deck's green
481
+ and its orange into an olive that is in neither series; 0.28 kept the hues but still buried
482
+ the scale rings. At this value both curves keep their colour, every ring stays legible
483
+ through them, and the overlap still reads as an overlap rather than as a third colour.
484
+ */
485
+ curveOpacity: 0.16,
486
+ curveStrokeWidth: DIAGRAM_STROKE_PX,
487
+ legendFontSize: MERMAID_LEGEND_FONT_PX,
488
+ legendBoxSize: Math.round(MERMAID_LEGEND_FONT_PX * 0.8),
489
+ },
490
+ fillType0: s1,
491
+ fillType1: s2,
492
+ fillType2: s3,
493
+ fillType3: s4,
494
+ fillType4: s5,
495
+ fillType5: s1,
496
+ fillType6: s2,
497
+ fillType7: s3,
498
+ };
499
+ }
500
+
501
+ /*
502
+ ── THE FILLED NODE ────────────────────────────────────────────────────────────────────────────
503
+
504
+ The deck's four panel grounds, published to mermaid as node classes. An author writes
505
+
506
+ A[Rider opens app]:::vivid
507
+
508
+ and the node takes the same fill and ink a `background: vivid` panel takes on that register.
509
+
510
+ ★ WHY CLASSES AND NOT `classDef`. mermaid's own `classDef` is authored per diagram, in literal
511
+ colour, inside the slide — four grounds x two registers x every diagram that wants one, restated
512
+ by hand. `:::name` only ATTACHES the class; what it means is decided here, once, from deck.json's
513
+ own panel table. So a node cannot drift from the panel it is named after, and a fifth ground
514
+ reaches every diagram without touching a slide.
515
+
516
+ ★★ AND THE PAIR IS WHY THIS IS SAFE. deck.json stores a ground and the ink measured against it
517
+ TOGETHER, and background() hands back both — so a filled node cannot end up with a light ink on a
518
+ dark ground the way it could if a slide named two colours by hand. That is the whole argument for
519
+ spending a class on this rather than letting authors reach for `style A fill:#...`.
520
+
521
+ The border is the fill carried toward its own ink by deck.json's own `border-strong` step — the
522
+ same mix `nodeBorder` takes for the default node, READ from the table rather than copied, so the
523
+ two cannot separate. That keeps a filled node's edge at the presence of a plain one; the alternative,
524
+ leaving the default border in place, puts a grey ring on a saturated green.
525
+
526
+ ★ `.node` IS TWO FAMILIES, measured. A state diagram's boxes are `g.node …statediagram-state`, so
527
+ `class Charged vivid` fills one exactly as `:::vivid` fills a flowchart node — the specimen draws
528
+ that on its dark slide. What is NOT matched is a subgraph (`g.cluster`), and the families that
529
+ have no node at all: a pie slice, a gantt bar and a quadrant point each carry their own meaning
530
+ and their own variables.
531
+ */
532
+ export const NODE_BORDER_MIX = derivedMix('border-strong');
533
+
534
+ export const MERMAID_BACKGROUNDS: readonly string[] = BACKGROUND_NAMES;
535
+
536
+ export function mermaidBackgroundRules(register: Register): string[] {
537
+ return MERMAID_BACKGROUNDS.flatMap((name) => {
538
+ const { fill, ink } = background(name, register);
539
+ const stroke = mixOklab(fill, ink, NODE_BORDER_MIX);
540
+ return [
541
+ /* ★★ DESCENDANT, NOT `>`, and the shape vocabulary is why. Measured across the flowchart's
542
+ own shapes: `rect`, `round`, `subroutine`, `cylinder`, `circle`, `rhombus`, `hexagon`,
543
+ `parallelogram` and `trapezoid` hang their outline directly under `g.node` — but
544
+ `stadium`, the asymmetric flag and `doublecircle` wrap theirs in an inner
545
+ `g.label-container`, so a child combinator paints six of the nine and silently skips
546
+ three. This is the same selector shape mermaid's own node rule uses, deliberately: an
547
+ override that matches less than the rule it overrides is a background that works on most shapes.
548
+ The cost is the 0x0 measuring rect inside `g.label`, which mermaid's rule already fills
549
+ and which paints nothing at either size. */
550
+ `.node.${name} rect,.node.${name} polygon,.node.${name} path,`
551
+ + `.node.${name} circle,.node.${name} ellipse{fill:${fill};stroke:${stroke};}`,
552
+ // Both label forms — see the `htmlLabels` note in mermaidConfig. `color` carries to the <p>
553
+ // mermaid nests inside the span; `fill` is what an SVG <text> answers to.
554
+ `.node.${name} span,.node.${name} text{color:${ink};fill:${ink};}`,
555
+ ];
556
+ });
557
+ }
558
+
559
+ /*
560
+ ── REGISTER-DEPENDENT INK THAT IS NOT A BACKGROUND ─────────────────────────────────────────────────
561
+
562
+ ★★ A LABEL ON A CATEGORICAL FILL TAKES `onAccent`, NEVER `fg`, and the ramp level is the reason.
563
+ The published categorical is TW v4's 600 step on light (#00a447, #da7600, #0084c8 …) and its 400
564
+ step on dark. The house rule: at 500 and above a fill carries WHITE (or a -100/-50 tint); at 400
565
+ and below it can carry a dark ink. `p.onAccent` is exactly that pair already — #ffffff on light,
566
+ the deep green on dark — so naming it satisfies the rule in both registers by construction rather
567
+ than by two hand-picked colours that have to be re-checked whenever the palette moves.
568
+
569
+ A journey is the one family that got this wrong: its section and task labels inherited `fg`, so a
570
+ near-black sat on a 600-level green. The pie has always taken `pieSectionTextColor: p.onAccent`,
571
+ which is the same decision made in the one place mermaid gave it a variable.
572
+
573
+ These live apart from `mermaidBackgroundRules` because they are not backgrounds — no panel ground is involved,
574
+ and they apply whether or not an author names a class.
575
+ */
576
+ export function mermaidInkRules(register: Register): string[] {
577
+ const p = palette(register);
578
+ return [
579
+ /*
580
+ The journey's own label hook: mermaid wraps the foreignObject text in a div carrying the
581
+ section/task class, so this reaches the label WITHOUT touching a flowchart's node labels,
582
+ which are the same foreignObject shape one family over.
583
+
584
+ ★ THE INNER `.label` MUST BE NAMED TOO. mermaid sets `#id .label { color: … }` directly on the
585
+ div that holds the text, and a colour set ON an element beats one inherited from its parent no
586
+ matter what the specificity of the parent's rule is. Naming only the section here loses to it
587
+ every time.
588
+ */
589
+ `.journey-section,.task,.journey-section .label,.task .label{color:${p.onAccent};}`,
590
+ // The satisfaction face is mermaid's own cornsilk on a mid grey, which is the one thing on this
591
+ // chart that belongs to no palette at all.
592
+ `.face{fill:${p.bg};stroke:${p.fg};}`,
593
+ // The guide the face slides along. mermaid writes its colour and dash INLINE, hence the hammer.
594
+ `.task-line{stroke:${p.fgSubtle} !important;}`,
595
+ /*
596
+ ★★ A GANTT LABEL INSIDE A DARK BAR NEEDS THE OTHER INK, AND NO THEME VARIABLE CAN SAY SO.
597
+ mermaid paints `.activeText*` and `.doneText*` from ONE variable — `taskTextDarkColor` — and
598
+ uses that same variable for `.taskTextOutsideLeft/Right`, which sit on the PAGE. One value for
599
+ a label on a dark bar and a label on cream: whatever it is set to, one of the two is wrong.
600
+
601
+ Measured before this rule: `#191617` on the active bar's `#046c4c` scored 2.60:1, under even
602
+ the 3:1 large-text floor, on a slide the audit cannot see because a diagram renders into a
603
+ shadow root.
604
+
605
+ So the ink is split by SELECTOR instead. An inside label carries `taskText`; an outside one
606
+ carries `taskTextOutsideLeft` and never `taskText`, so naming the former reaches exactly the
607
+ labels that sit on a fill and leaves the page ones alone. `!important` is required because
608
+ mermaid's own `.activeText*` rule carries it.
609
+
610
+ Both saturated bars take `onAccent`, the same pair the pie and the journey take: the active
611
+ bar is the accent and the critical one the danger mark, and on light both are 500-or-above
612
+ fills, which carry white. ⚠ Only the ACTIVE case is drawn inside today — the specimen's
613
+ critical bar has a label too long for it, so mermaid puts that one outside. The crit rule is
614
+ the same mechanism stated once rather than a second thing to remember later.
615
+ */
616
+ ...[0, 1, 2, 3].flatMap((i) => [
617
+ `.taskText.activeText${i}{fill:${p.onAccent} !important;}`,
618
+ `.taskText.critText${i}{fill:${p.onAccent} !important;}`,
619
+ ]),
620
+ // The lane band, at the strength it was given rather than at mermaid's blanket 0.2 — which
621
+ // washed every section ground back to the page it sits on.
622
+ `.section{opacity:1;}`,
623
+ /*
624
+ ★★ THE ACTOR DOT IS PAINTED FROM CSS BECAUSE ITS CONFIG KEY CANNOT WORK. `journey.actorColours`
625
+ is a real key on a real path (`getConfig().journey.actorColours`), and setting it changes
626
+ nothing — mermaid merges config with `assignWithDepth`, whose array branch is
627
+
628
+ if (Array.isArray(src) && Array.isArray(dst)) { src.forEach(s => { if (!dst.includes(s)) dst.push(s) }) }
629
+
630
+ i.e. it APPENDS to the default array rather than replacing it. Index 0 stays mermaid's
631
+ DarkSeaGreen and index 1 its LawnChartreuse, whatever is passed. Every other journey key here
632
+ is a scalar and lands normally, which is why this one looked like it had worked.
633
+
634
+ The dot's colour is a presentation ATTRIBUTE, and CSS outranks those, so the same five the pie
635
+ spends reach it from here instead. The dead key is not left in the config beside the live ones:
636
+ a setting that reads as configuration and does nothing is the more expensive kind of wrong.
637
+ */
638
+ ...Array.from({ length: 8 }, (_, i) => {
639
+ const fill = SERIES[register][i % SERIES[register].length];
640
+ return `circle.actor-${i}{fill:${fill};stroke:${mixOklab(fill, p.fg, NODE_BORDER_MIX)};}`;
641
+ }),
642
+ /*
643
+ The band's own edge, per categorical slot. It is derived the way a filled node's is — the fill
644
+ carried 26% toward an ink — but toward the PAGE's ink rather than the label's. A band sits ON
645
+ the page, so that is the relationship its edge has to hold; carried toward `onAccent` it would
646
+ go LIGHTER than the fill on light, and a band whose edge is paler than the band reads as a
647
+ glow rather than a border. Both registers come out right: darker on cream, lighter on the
648
+ dark ground, definition either way.
649
+
650
+ Indexed 0-7 because mermaid indexes the slots and cycles the five past the fifth, exactly as
651
+ the pie does.
652
+ */
653
+ ...Array.from({ length: 8 }, (_, i) => {
654
+ const fill = SERIES[register][i % SERIES[register].length];
655
+ return `.task-type-${i},.section-type-${i}{stroke:${mixOklab(fill, p.fg, NODE_BORDER_MIX)};}`;
656
+ }),
657
+ /*
658
+ ★★ A MINDMAP LABEL SITS DIRECTLY ON A CATEGORICAL FILL, so it takes `onAccent` — the same
659
+ answer the journey's does, and for the same reason. It is the one family that paints its slot
660
+ SOLID: everywhere else the categorical is a tint, a stroke or a band this theme has repainted,
661
+ and there the shared `cScaleLabel` (the page ink) is correct.
662
+
663
+ MEASURED against the five as they actually land — mermaid does not darken a slot the theme
664
+ states, so these are the deck's own colours:
665
+
666
+ #00a447 green dark 5.48 white 3.28
667
+ #da7600 orange dark 5.62 white 3.20
668
+ #0084c8 blue dark 4.39 white 4.09
669
+ #4f39f6 indigo dark 2.78 white 6.46
670
+ #e30076 magenta dark 3.87 white 4.64
671
+
672
+ ★★ AND THIS IS THE CASE THE cScaleLabel NOTE PREDICTED. It said one ink for twelve slots was
673
+ right for the deck's five and "would need revisiting if the categorical ever gained a genuinely
674
+ dark member — there the pairing would flip back". Indigo is that member: at 2.78 the page ink
675
+ is under the 3:1 floor a 36px label is held to, and it was the audit on this slide that caught
676
+ it. White is over the floor on every one of the five, so the family takes one ink rather than
677
+ a per-slot mixture that would put two colours of label in one picture.
678
+
679
+ ⚠ SCOPED TO `.mindmap-node`, because `.section-N` is shared with kanban — whose columns this
680
+ theme paints the surface, where white would be invisible. Twelve, matching the slots: a mindmap
681
+ has as many sections as the author writes branches.
682
+ */
683
+ /* ★ THE ROOT IS `section--1`, NOT A SLOT — mermaid indexes the branches from 0 and gives the
684
+ node they hang off minus one. Left out, it was the one dark label in a picture of white ones,
685
+ which is the mixture this rule exists to avoid; it draws on the same green the journey and the
686
+ timeline already put white on. */
687
+ ...['-1', ...Array.from({ length: 12 }, (_, i) => String(i))].map((slot) => (
688
+ `.mindmap-node.section-${slot} text,.mindmap-node.section-${slot} span{fill:${p.onAccent};color:${p.onAccent};}`
689
+ )),
690
+ ];
691
+ }
692
+
693
+ /**
694
+ * The full mermaid config, ready for `mermaid.initialize()`.
695
+ *
696
+ * `theme: 'base'` is not a default — it is the ONLY theme that reads `themeVariables` at all.
697
+ * 'default', 'dark', 'neutral' and 'forest' (mermaid's own, unrelated to this brand) each ship a
698
+ * fixed palette and ignore the table above entirely, so naming any of them silently reverts every
699
+ * colour on this page.
700
+ */
701
+ /*
702
+ ── WHY THE SPACING IS MERMAID'S OWN ───────────────────────────────────────────────────────────
703
+
704
+ `fitToColumn` does not refuse a diagram that outgrows its slide, it SHRINKS it, so a unit of gap
705
+ is paid for twice: once in width, and again in the type it costs when the drawing is scaled to
706
+ fit. That makes tightening the gaps look like free legibility. MEASURED on the specimen's
707
+ flowchart in an 872px column:
708
+
709
+ 20/60/70 mermaid's own, shipped natural 1603 units shrink 0.544 labels at 19.6px
710
+ 16/44/52 a third off natural 1459 units shrink 0.598 labels at 21.5px
711
+ 10/24/28 packed natural 1255 units shrink 0.695 labels at 25.0px
712
+
713
+ ★★ AND THE DECK SHIPS THE TOP ROW ANYWAY. The gain is real and it is not what a diagram is for:
714
+ packed nodes read as a denser object than the Card grid with the same content beside them, and a
715
+ deck whose diagrams are tuned for maximum ink stops looking like one deck. A diagram that will not
716
+ fit at mermaid's own spacing is a diagram that wants to be two slides.
717
+
718
+ ★ THE TYPE IS NOT THE LEVER EITHER, which is the trap under all of this. Dropping fontSize 36 to
719
+ 30 measured 18.0px rendered — WORSE than leaving it alone. mermaid lays a box out around the text
720
+ it measures, but the gaps between boxes are config numbers that do not follow the font, so the cut
721
+ bought 9.4% of width for 16.7% of type.
722
+
723
+ ⚠ The fragment tab keys below are TYPE, not spacing. `labelBoxWidth`, `labelBoxHeight` and
724
+ `boxTextMargin` are scaled to the deck's rung because mermaid never measures the keyword against
725
+ the box it draws for it.
726
+ */
727
+
728
+ export function mermaidConfig(register: Register) {
729
+ return {
730
+ startOnLoad: false,
731
+ theme: 'base' as const,
732
+ themeVariables: mermaidThemeVariables(register),
733
+ fontFamily: MERMAID_FONT_FAMILY,
734
+ /*
735
+ ★★ TOP-LEVEL `fontSize` IS THE ONE THAT WORKS, and mermaid's own documentation points the
736
+ other way. `sequence.actorFontSize`, `.messageFontSize` and `.noteFontSize` are all declared
737
+ in mermaid 11's config schema (dist/config.type.d.ts) and all THREE are ignored by the
738
+ renderer. Measured against mermaid 11.17.2 in a live page, same diagram, one variable changed:
739
+
740
+ sequence: { actorFontSize: 36, … } -> style="… font-size: 16px …"
741
+ fontSize: 36 -> style="… font-size: 36px …"
742
+
743
+ The visible symptom was not a wrong number, it was a SMALL DIAGRAM: mermaid lays every box out
744
+ around the text it measures, so at 16px the whole sequence diagram came out about a third of
745
+ the size of the flowchart beside it, and the fit-to-column step below faithfully preserved
746
+ that. It reads as "mermaid just draws sequence diagrams small", which is why the per-family
747
+ keys are not left in place as harmless belt-and-braces — they are a false explanation sitting
748
+ next to the real one.
749
+ */
750
+ fontSize: MERMAID_FONT_PX,
751
+ /*
752
+ ⚠ `htmlLabels: false` DOES NOT TAKE EFFECT, measured. Read in the rendered shadow root of the
753
+ specimen's flowchart against mermaid 11.17.2, a node label's ancestor chain is
754
+ `span.nodeLabel < div < foreignObject < g.label` — an HTML label, which is what this key asks
755
+ mermaid not to emit. Every node carries `data-look="classic"`, i.e. mermaid 11's unified
756
+ renderer, which reads the label mode from its own place and not from this section.
757
+
758
+ It is left set rather than deleted because it is the documented request and costs nothing, but
759
+ nothing downstream may assume SVG <text>: mermaidBackgroundRules() names `span` alongside `text` for
760
+ exactly this reason, and any rule written for one form has to cover the other.
761
+ */
762
+ flowchart: { htmlLabels: false, curve: 'basis', padding: 20, nodeSpacing: 60, rankSpacing: 70 },
763
+ /*
764
+ Spacing only. Every FONT key this section accepts is dead (see `fontSize` above), so listing
765
+ one here would be decoration that reads like configuration.
766
+
767
+ ★★ THE LAST THREE ARE THE FRAGMENT TAB, AND THEY ARE NOT OPTIONAL AT THIS TYPE SIZE. An
768
+ `alt`/`opt`/`loop` fragment hangs its keyword in a tab mermaid draws at a FIXED
769
+ `labelBoxWidth` x `labelBoxHeight` — 50x20 by default, with 5px of text margin, all sized for
770
+ its own 16px type. The deck runs at 36, and mermaid never measures the word against the box:
771
+ it draws a 50-wide tab and then paints a word wider than it. MEASURED in the exported PDF
772
+ before this: "opt" broke three of the tab's four edges, the descender falling 6px clear of the
773
+ bottom. Same failure class the gantt block below exists for, and the fix is the same — scale
774
+ the box with the type rather than reach for a stylesheet, which would only make the text in a
775
+ box drawn for smaller text larger still.
776
+ */
777
+ sequence: {
778
+ useMaxWidth: true,
779
+ diagramMarginX: 40,
780
+ diagramMarginY: 20,
781
+ boxMargin: 20,
782
+ actorMargin: 80,
783
+ labelBoxWidth: toTagScale(50),
784
+ labelBoxHeight: toTagScale(20),
785
+ boxTextMargin: toTagScale(5),
786
+ /*
787
+ ★★ AN `actor` LABEL IS DRAWN BELOW THE BAND THE LAYOUT RESERVES FOR IT. drawActorTypeActor
788
+ centres the name at actorY + 35 + figureBBox/2 = actorY + 67.5, while the bounds only ever
789
+ advance by the stick figure's own 65 — so the room under the last row is diagramMarginY +
790
+ bottomMarginAdj = 21. mermaid's own 14px label needs 8.5 of it; the deck's 36px needs 22.54,
791
+ and "Rider" fell through the floor of the viewBox (2px cut, 9% hidden). 6 buys 5 units back.
792
+ `sequence.height` is NOT the lever — an actor's label y comes from the figure bbox — and
793
+ `diagramMarginY` is worse, because viewBox y is -diagramMarginY, so raising it pads the TOP
794
+ by the same amount. Applied only when mirrorActors is set, which is the default and the only
795
+ case with a bottom row at all.
796
+ */
797
+ bottomMarginAdj: 6,
798
+ },
799
+ /*
800
+ ★★ THE GANTT KEYS ARE LIVE, WHICH IS THE OPPOSITE OF `sequence` ABOVE — so they are set here
801
+ rather than left to the top-level `fontSize`, which this family ignores. Measured, same
802
+ diagram, one key changed: task and section text went 11px -> 36px with `gantt.fontSize`, and
803
+ the bars re-laid out around it. That is the whole reason to reach for a per-family key: it
804
+ moves the LAYOUT, where a stylesheet rule would only enlarge text inside boxes drawn for a
805
+ smaller one.
806
+
807
+ `barHeight` and the paddings follow the type rather than being picked: a 36px label needs a
808
+ bar it fits inside, and the two left paddings are what stop a section title and an outside
809
+ task label from colliding at that size.
810
+
811
+ ⚠ TWO SIZES THIS SECTION CANNOT REACH — the chart title (18px) and the axis ticks (10px).
812
+ Neither has a config key, and both are pure text with no box laid out around them, so
813
+ ROOM_SCALE_RULES takes them instead. See the gantt rules there.
814
+ */
815
+ gantt: {
816
+ useMaxWidth: true,
817
+ fontSize: MERMAID_FONT_PX,
818
+ sectionFontSize: MERMAID_FONT_PX,
819
+ barHeight: 56,
820
+ barGap: 14,
821
+ topPadding: 60,
822
+ leftPadding: 220,
823
+ gridLineStartPadding: 40,
824
+ titleTopMargin: 40,
825
+ },
826
+ /*
827
+ ★ THE QUADRANT'S SIZES ARE ALL KEYS, unlike the gantt's title and ticks — every piece of text
828
+ on this chart has one, so none of it is left to a stylesheet. The point labels take the 0.8
829
+ step a caption takes; everything else is the body-sm rung the rest of the deck sets at.
830
+ */
831
+ quadrantChart: {
832
+ useMaxWidth: true,
833
+ chartWidth: 900,
834
+ chartHeight: 600,
835
+ titleFontSize: MERMAID_FONT_PX,
836
+ quadrantLabelFontSize: MERMAID_FONT_PX,
837
+ xAxisLabelFontSize: Math.round(MERMAID_FONT_PX * 0.8),
838
+ yAxisLabelFontSize: Math.round(MERMAID_FONT_PX * 0.8),
839
+ pointLabelFontSize: Math.round(MERMAID_FONT_PX * 0.8),
840
+ pointRadius: QUADRANT_POINT_RADIUS,
841
+ /*
842
+ ★★ THE LABEL'S GAP IS MEASURED FROM THE POINT'S CENTRE, NOT ITS EDGE, so a padding smaller
843
+ than the radius puts the word INSIDE the dot. mermaid ships `pointRadius: 5` beside
844
+ `pointTextPadding: 5` — equal, which is what keeps its own default just clear. This theme
845
+ raised the radius to ${QUADRANT_POINT_RADIUS} and left the padding at mermaid's 5, so the
846
+ label's hanging baseline landed 7px inside the dot and "Lambeth" sat across it.
847
+
848
+ Derived from the radius so the two cannot drift apart again, plus a quarter of the label's
849
+ own rung as the gap — the type is ${Math.round(MERMAID_FONT_PX * 0.8)}px here against the 16px
850
+ mermaid's 5 was drawn for, so a fixed number would be too tight the moment the deck's scale moved.
851
+ */
852
+ pointTextPadding: QUADRANT_POINT_RADIUS + Math.round(MERMAID_FONT_PX * 0.25),
853
+ quadrantPadding: 12,
854
+ },
855
+ /*
856
+ ★★ THE JOURNEY'S BOX IS THE CONFIG, NOT THE TYPE. `taskFontSize` is live — `byTspan` sets it
857
+ straight onto the text — but on its own it makes things worse, because the label is drawn into
858
+ a foreignObject of a FIXED `width` x `height` (150 x 50 by default). Raise the type without
859
+ raising the box and every label wraps and then clips at the box's own edge, which is what a
860
+ first pass at this family looks like: truncated words in boxes that look deliberate.
861
+
862
+ So the box is stated with the type it has to hold. Everything else here is the air around it.
863
+ */
864
+ journey: {
865
+ useMaxWidth: true,
866
+ taskFontSize: MERMAID_FONT_PX,
867
+ taskFontFamily: MERMAID_FONT_FAMILY,
868
+ width: 340,
869
+ height: 112,
870
+ boxMargin: 16,
871
+ boxTextMargin: 12,
872
+ taskMargin: 64,
873
+ leftMargin: 200,
874
+ diagramMarginX: 40,
875
+ diagramMarginY: 24,
876
+ },
877
+ er: { useMaxWidth: true },
878
+ state: { useMaxWidth: true, nodeSpacing: 60, rankSpacing: 70 },
879
+ /*
880
+ ⚠ THE COMMIT LABEL STAYS ROTATED, AND THAT IS mermaid's COLLISION AVOIDANCE rather than a
881
+ style. It tilts every commit id 45° so long ids can sit close together, and the spacing between
882
+ commits is laid out on the assumption that they do. Set straight — tried, and measured — the
883
+ ids at this deck's rung overlapped each other outright: `bay picker` and `battery check` ran
884
+ into one word. The layout is computed from mermaid's own 10px and does not re-run when the
885
+ theme re-points the size in CSS, which is the same seam the ER edge label and the sequence
886
+ fragment tab each sit on.
887
+
888
+ So the size moves and the angle does not.
889
+ */
890
+ /*
891
+ ── RADAR ──────────────────────────────────────────────────────────────────────────────────
892
+ ★★ THE MARGINS ARE NOT DECORATION HERE, THEY ARE THE ONLY ROOM THE LABELS HAVE. Radar's plot
893
+ box is fixed at `width` x `height` and everything outside it — the axis names and the legend —
894
+ is placed against the MARGIN rather than measured. mermaid's 50 was sized for its own ~12px
895
+ label; at the deck's 29px rung a five-letter axis name is more than twice as long, and the
896
+ legend's text starts at three quarters of the way into the right margin, so each unit of
897
+ `marginRight` buys the legend only a quarter unit of clear room. 170 is what the longest axis
898
+ name needs beside a plot of 600, and the left margin matches it so the plot stays centred.
899
+
900
+ ★ THE TOP AND BOTTOM ARE SMALLER ON PURPOSE, not by oversight. Nothing sits in them but the
901
+ diagram title, which this deck never draws — the heading above the frame is the title. A
902
+ margin is dead space inside the viewBox, fitToColumn caps against that viewBox, and every
903
+ unit of unused gutter is therefore drawn at the cost of the picture.
904
+
905
+ ★ `axisLabelFactor` rises with the label, for the reason stated beside `axisLabelFontSize`.
906
+ */
907
+ radar: {
908
+ useMaxWidth: true,
909
+ width: 600,
910
+ height: 600,
911
+ marginTop: 90,
912
+ marginRight: 170,
913
+ marginBottom: 90,
914
+ marginLeft: 170,
915
+ /*
916
+ ★★ THE SPOKE STOPS ON THE RING, NOT AT THE RADIUS — and those are not the same place once
917
+ the graticule is a polygon. mermaid fillets every vertex with a quadratic whose CONTROL is
918
+ the true corner, so the ring never reaches it: the curve's midpoint is (P0 + 2C + P2) / 4,
919
+ which on the outer ring puts the line at 297.06 against a nominal 300. An axis drawn to the
920
+ full radius therefore overshoots the ring it should meet by about 3 units, plus half its own
921
+ stroke — measured on the render, and visible as a spur past every corner.
922
+
923
+ 297.06 / 300 is 0.9902. 0.99 lands the spoke six hundredths of a unit inside the ring, which
924
+ is closed. ★ The sagitta is a FIXED chord in user units, not a fraction of the radius (the
925
+ inner rings fillet by the same 8.09 either side), so this factor is right for the outermost
926
+ ring specifically — which is the only one an axis line has to meet.
927
+ */
928
+ axisScaleFactor: 0.99,
929
+ axisLabelFactor: 1.14,
930
+ /*
931
+ ⚠ NO `curveTension` HERE, DELIBERATELY. It is the control-point tension for the cubic mermaid
932
+ draws through the data points — and it is consulted ONLY on the circular graticule. This deck
933
+ draws the polygon one, where the curve is a straight-sided <polygon> and the key is never read.
934
+ Setting it would be a number that looks like configuration and configures nothing, which is the
935
+ shape of every dead key this file already records.
936
+ */
937
+ },
938
+ /*
939
+ ── TREEMAP ────────────────────────────────────────────────────────────────────────────────
940
+ ★★ THE LEAF TYPE LADDER IS CHOSEN BY LEAF COUNT, NOT BY CONFIG: 38px at twenty leaves or
941
+ fewer, 16px past that, then shrunk in a loop that MEASURES the fitted text and stops when it
942
+ fits. So the deck states no size here at all. `!important` would win the declaration and lose
943
+ the diagram — the shrink loop measures at mermaid's size, and a label forced larger than the
944
+ cell it was fitted to is culled outright by the family's own `display:none` guard.
945
+
946
+ 38 against the deck's 36 rung is a coincidence worth naming rather than relying on: it holds
947
+ only while a treemap here stays under twenty leaves, which is also the point at which the
948
+ picture stops being readable from the back of a room.
949
+
950
+ The box is fixed at `nodeWidth × 10` by `nodeHeight × 10` before any data is read, so these
951
+ two are an ASPECT RATIO rather than a size — fitToColumn does the sizing.
952
+ */
953
+ treemap: { useMaxWidth: true, nodeWidth: 100, nodeHeight: 44 },
954
+ /*
955
+ ── MINDMAP ────────────────────────────────────────────────────────────────────────────────
956
+ ⚠ `padding` IS THE ONLY KEY THAT REACHES THE BOX, and it does not reach it evenly: a default
957
+ node is laid out at eight half-paddings wide and TWO high, a 4:1 ratio fixed in code. At the
958
+ deck's 36px rung mermaid's 10 leaves five units above and below the cap height, which is far
959
+ too tight, so this is raised to what the vertical half needs and the horizontal is simply
960
+ wider than it would otherwise be.
961
+ */
962
+ mindmap: { useMaxWidth: true, padding: 28 },
963
+ /*
964
+ ── KANBAN ─────────────────────────────────────────────────────────────────────────────────
965
+ `ticketBaseUrl` is deliberately unset: a ticket id renders as plain text rather than a link,
966
+ because a deck is not a place anyone can click.
967
+ */
968
+ kanban: { useMaxWidth: true, padding: 20, sectionWidth: 400 },
969
+ gitGraph: { rotateCommitLabel: true },
970
+ pie: { useMaxWidth: true },
971
+ };
972
+ }