snyvi 1.0.2

A fast, beautiful viewer for the documents your agents produce
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
# Diagrams: where the time goes, how they look, and the plan

Written 2026-09-12, after a report that a page with a big Mermaid diagram
takes seconds to open. Everything in section 1 is measured on this
machine under headless Chromium, not estimated.

**Status.** Phases 1, 2a, 3 and 8 (section 9's theming) have landed, along
with the find fix and the parse-error fix from the small list, and phase 4's
idle prefetch. The harness that took section 1's numbers is now
`bench/browser.mjs`, runs in CI, and section 7 records what it measures.
The rest of phase 4 (the trimmed bundle, re-theming on toggle) and 2b are
still ahead.

Sections 1 to 8 are about time. Section 9 is about the other half of the
roadmap's test — whether a diagram looks like it belongs in the document
around it — and was measured the same way.

## 1. What is actually slow

| What | Measured |
|---|---|
| Server TTFB, 1.25 MB Markdown document | 2–29 ms |
| First contentful paint, document with two diagrams | 144 ms |
| First contentful paint, 1.25 MB Markdown, no diagrams | 116 ms |
| Longest frozen frame, 1.25 MB Markdown, no diagrams | 128 ms |
| `mermaid.js` transfer (975 KB gzip, localhost) | 79 ms |
| `mermaid.js` parse and compile (3.57 MB of JS) | 523 ms |
| `mermaid.initialize` | 5 ms |
| One 3-node flowchart, first render / later renders | 73 / 37 ms |
| One 220-node flowchart | 2433 ms |
| **Longest frozen frame on that page** | **3123 ms** |
| The same document, revisited in the same tab | 2426 ms again |
| That diagram's natural width, and the width it is shown at | 4738 px shown at 630 px — 13% |

The server is not the problem. Markdown is not the problem. The network
is not the problem. One synchronous JavaScript task is: for 3.1 seconds
the page does not scroll, does not answer a key, does not repaint, and
shows the reader a wall of grey `flowchart TD n0[...]` source while it
works.

## 2. The five faults behind that number

1. **It blocks.** `mermaid.run({ nodes })` renders every diagram on the
   page in one unyielding task. Nothing else can happen until it ends.
2. **It is eager.** Every diagram renders, on screen or not. A plan with
   eight diagrams pays for eight before the reader sees the first.
3. **It is uncached.** 2426 ms again on a revisit in the same tab. The
   document is immutable and the answer was already computed; it is
   thrown away.
4. **It is unreadable.** A 4738 px graph is squeezed into the 630 px
   reading column with no zoom, no pan and no fullscreen. The one
   diagram big enough to be worth drawing is the one you cannot read.
5. **It is silent.** No placeholder, no progress, no reserved space —
   just raw source, then a jump.

Five smaller faults found on the way:

- **The theme toggle leaves diagrams behind.** `initialize` runs per
  render call, so an already-drawn SVG keeps the theme it was drawn in.
  Switch to dark and the diagrams stay light.
- **Find hides what it finds.** `runFind` walks every text node under
  `#doc`, including the SVG's. In a sequence diagram all 11 matches
  landed inside `<text>`, where an HTML `<mark>` renders at 0×0 — so the
  matched label *disappears from the diagram*, and the counter counts
  matches nobody can see. Flowchart labels survive (they are
  `foreignObject` HTML) but at 13% scale a match is a 2.6 px smudge that
  `gotoFind` dutifully scrolls to.
- **A parse error eats the source.** The `<pre>` is replaced by Mermaid's
  error graphic, so a reader cannot see or copy what the agent got
  wrong — which is exactly the moment they want to.
- **`snyvi watch` re-renders everything on every save.** `refreshDoc`
  replaces the body, so a file saved every few seconds pays the full
  diagram cost every few seconds.
- **The first diagram in a tab pays the 523 ms parse on the critical
  path.** Nothing prefetches the library.

## 3. The plan

Four phases, cheapest first. Phase 1 is the one the reader feels.

### Phase 1 — never block the reader — **done**

Replace the single `mermaid.run` with a scheduler.

- **Reserve the space first.** Each `pre.mermaid` becomes a placeholder
  box of an estimated height, so the page does not jump when the SVG
  lands. *Built without the server-side size hint this asked for: the
  client already holds the source as text, and counting its lines is
  microseconds, so a hint in `render.rs` would have bought a change to the
  renderer and its tests and nothing measurable. The estimate is weak
  either way — the source says how much there is to draw, never how tall
  the drawing will be. Phase 3 is what makes it exact.*
- **Render only what is near the viewport.** An `IntersectionObserver`
  with a generous `rootMargin` queues a diagram as it approaches. Eight
  diagrams become one.
- **One diagram per task, yielding between.** `scheduler.yield()` where
  it exists, a `setTimeout` turn otherwise. A 2433 ms diagram is still
  2433 ms of CPU — but it is one diagram's worth, and every slot boundary
  gives the browser a frame.
- **Cancel on navigation.** A render token per document; queued diagrams
  belonging to a document the reader has left are dropped rather than
  drawn into detached nodes.
- **Say what is happening.** A quiet label in the placeholder, and a
  spinner once a render passes ~150 ms. Never a flash of source.
- **A cap with an escape hatch.** Past a threshold — measure it, roughly
  150 nodes or 20 KB of source — do not render automatically. Show
  "Render diagram" and let the reader ask. For a diagram that genuinely
  costs seconds this is the only honest answer.

Target: first paint unchanged at ~144 ms, longest frame from 3123 ms down
to one diagram's slice, the page scrollable and navigable throughout.

### Phase 2a — render each diagram once per tab — **done**

A diagram is a pure function of its source and the theme, and documents
are immutable. A `Map` from the theme and the source to the SVG string
turns a revisit, a `snyvi watch` refresh and a back-button into a string
assignment: measured at **34 ms for the whole document**, against a
budget row that no longer has anything to report.

Three things it turned out to want beyond the twenty lines:

- **A source that will not parse is remembered too**, so the rule is the
  whole of it: no source is handed to Mermaid twice in one tab. The
  failure is a fact about the source in the way the drawing is, and the
  document holding one is the document a reader re-opens to see what the
  agent actually wrote. Without this the harness counted one render call
  per revisit, which is how the rule got stated properly.
- **The id is not part of the drawing.** Mermaid writes the id it is
  given into the root element, into an id-scoped `<style>` block, and
  into the ids of the markers the edges point at. Cached verbatim and
  painted twice, a document carrying the same diagram twice would hold
  two of each, with the second one's arrowheads resolving to the first.
  So what is cached carries a token where the id was, and the figure
  being painted supplies one.
- **A bound, in bytes rather than entries**, because one diagram's SVG is
  two orders of magnitude larger than another's and the tab left open all
  day reading documents is the one this project promises will stay small.
  4 MB, least-recently-used first, and never the entry just asked for.

It also made the cap honest. A diagram over the 150-line threshold is
offered rather than drawn because drawing it costs seconds — and one
already in the cache costs a string assignment, so a reader who asked for
the 220-node flowchart once is not asked again on the way back.

### Phase 3 — make a big diagram readable

Not a custom renderer. Mermaid hands us an SVG with a `viewBox`;
everything needed is a viewport around it.

- Wrap each SVG in a frame with a bounded height, fitted at first.
- **Pan and zoom the `viewBox`** — wheel or pinch zooms toward the
  cursor, drag pans, double-click zooms in, `0` refits. Driving the
  `viewBox` rather than a CSS transform keeps strokes crisp at any zoom
  and costs nothing per frame.
- **Fill the screen**, on a button and a key: the one place a 4738 px
  graph is legible. The figure is laid over the page and the document
  asks for the browser's fullscreen; the figure itself in the top layer
  drew no text at all in WebKitGTK (0.13 in ROADMAP.md).
- **Fit / 100%** as one toggle, so "show me the shape" and "let me read
  that label" are each one click.
- **Let a diagram escape the reading measure.** 0.3.2 gave the pane to
  everything that is not prose and missed diagrams.
- **Leave plain scrolling alone.** Zoom on ⌘/ctrl + wheel, the web's own
  convention, so a cursor crossing a diagram never traps the page.

About 150 lines of vanilla JavaScript and no new dependency.

### Phase 4 — the 523 ms, and the small faults

- ~~**Prefetch Mermaid when idle**~~ **Done.** Asked for as soon as a
  document is known to hold a diagram at all, in idle time, rather than
  when one comes near the viewport — which was the moment the reader had
  arrived and was waiting. `requestIdleCallback` waits for a gap instead
  of making one, with a timeout as the floor under a tab that never has
  one: the compile is coming either way and any moment beats the one the
  reader chose. Measured on the harness's fixture: first diagram drawn
  1170 ms → 783 ms, the parse task 494 → 364 ms, first paint 220 → 156 ms.
  A page with no diagram asks for nothing.
- **Version the bundle's URL.** Done beside the prefetch, because it had
  to be: `/assets/mermaid.js` carried no version while being served
  `immutable` for a year, so a browser that had cached one snyvi's Mermaid
  would have kept it across every upgrade — and the trimmed bundle below
  could never have replaced it. The build hash now covers the bundle's
  bytes.
- **Trim the bundle.** 3.57 MB is the parse cost. The full build carries
  every diagram type — gantt, gitgraph, mindmap, quadrant, sankey,
  xychart, C4, journey, timeline, block, packet, architecture, radar,
  kanban, treemap. Agents emit flowchart, sequence, class, state, ER and
  gantt. Build a trimmed bundle with the types we choose and measure it;
  if it halves the parse it earns its build step. It needs a documented
  regeneration command and a CI size check either way — a vendored blob
  nobody can rebuild is a liability.
- **Re-theme on toggle.** Cheap once 2a exists: drop the other theme's
  cached SVGs and re-queue what is visible.
- ~~**Keep find out of diagrams.**~~ **Done, in phase 1.** A one-line
  rejection in the tree walker, and it was hiding content rather than
  merely mis-counting it, so it did not earn a wait. Counting diagram
  matches *and* zooming to them is still better, and still belongs after
  phase 3. The placeholder label is skipped along with the SVG: it is
  chrome, and would otherwise put a match in every diagram on the page
  for anyone searching "diagram".
- ~~**Keep the source on a parse error.**~~ **Done, in phase 1**, because
  the placeholder made it compulsory rather than optional: a diagram that
  throws now has a box of its own to fill, and leaving it empty would
  have been worse than what Mermaid did. The error message sits above the
  source, which is shown with ligatures off — JetBrains Mono draws `-->`
  as a single arrow, and a reader looking at a parse error needs the
  characters the agent actually typed.

### Phase 2b — cache SVGs in the daemon (a decision, not a default)

The tab posts a rendered SVG back, keyed by the source hash and theme;
the daemon stores it and serves it inside the document HTML. A new tab, a
new window and a restart all become instant, and it matches what snyvi
already does everywhere else: do the heavy work once and cache it
forever.

It needs a call first. The roadmap's one-way rule says agents send and
snyvi shows, so that nothing can leak a project's documents back into a
session's context. A render cache is not that channel — it is the
viewer's memo of its own work, written and read only by the viewer, never
reachable from MCP or the CLI. That argument should be made explicitly in
the code and the docs, or not made at all. Phase 2a alone removes most of
the pain, so this can wait for a considered answer.

## 4. What is not worth doing

- **A custom Mermaid renderer.** Reimplementing flowchart layout, sequence
  layout and the rest is months, and buys nothing the phases above do not:
  zoom is a `viewBox`, responsiveness is a scheduler, repeat cost is a
  cache, bundle size is a build flag. Revisit only if a trimmed bundle
  still cannot get a common diagram under ~200 ms.
- **Server-side Mermaid.** It wants a JavaScript engine inside a binary
  whose whole pitch is 12 MB and static.
- **Streaming the document HTML.** Measured: the daemon answers a 1.25 MB
  document in 2–29 ms and the browser paints at 116 ms. There is nothing
  to stream, and the roadmap already rules it out for a different and
  still-good reason. The progressive treatment belongs to diagrams, which
  is where the delay actually is.

## 5. Order and cost

| | Phase | Cost | Payoff | |
|---|---|---|---|---|
| 1 | Scheduler, viewport-gated, placeholders, cap | M | 3123 ms freeze → responsive | **done** |
| 2 | In-tab SVG cache | XS | revisit 2426 ms → 34 ms | **done** |
| 3 | Pan, zoom, fill the screen | M | a big diagram becomes readable | **done** |
| 4 | Idle prefetch | XS | −390 ms on the first diagram, measured | **done** |
| 5 | Find, theme, error-source fixes | S | correctness | find and error done |
| 6 | Trimmed bundle | M | measure before committing | |
| 7 | Daemon-side SVG cache | M | instant everywhere; needs the call above | |
| 8 | Theme the diagrams (section 9) | S | they stop looking borrowed | **done** |

## 6. How we know

`bench/browser.mjs`, beside `snyvi bench`, and in CI on every push. It
builds a fixture document, sends it through the CLI so the whole path is
covered, drives headless Chromium over the DevTools protocol, and checks
both the clock and the behaviour. No dependencies: Node 22's own
WebSocket and fetch. A `npm install` in a project whose pitch is one
static binary would be a poor trade for a wrapper.

    node bench/browser.mjs            report the numbers
    node bench/browser.mjs --check    and fail if one is over budget

The fixture carries five diagrams, each in the document to settle one
question: two small ones above the fold that must be drawn, an
unparseable one that must keep its source, a 40-node one below the fold
that must be left alone, and the 220-node flowchart that must be offered
rather than spent. `SNYVI_BENCH_FACTOR` scales the budgets (CI uses 3);
`SNYVI_BENCH_CPU` throttles the CPU, which is the honest way to see what
a slower machine would report.

**A shared machine's speed is not snyvi's to promise.** Two hosted
runners eighteen minutes apart reported 444 ms and 1404 ms of first paint
for the same commit, and 221 ms and 783 ms of boot task. A budget loose
enough to admit the slow one catches nothing; one that is not fails the
build for whoever drew it. So `SNYVI_BENCH_SHARED=1`, which CI sets,
prints those rows and does not enforce them — they are a fact about the
runner.

One timing row is enforced everywhere, because it is not a fact about the
runner: **longest task, drawing**. It asks whether the work is cut into
slices, not how fast the machine cutting them is. It reads 0 ms on a fast
runner, 0 ms on one three times slower, 369 ms under an eightfold CPU
throttle — and 1455 ms the moment the scheduler is taken out. That is the
number phase 1 is about, and the margin is two orders of magnitude rather
than a factor of three. Everything in section 6's behaviour list is
enforced everywhere too, because none of it depends on the clock.

Run it without `SNYVI_BENCH_SHARED` on a machine you control and every
row is enforced, which is where first paint's 250 ms budget means
something.

**Three windows, not one longest frame.** The single number the plan
asked for would have been dominated by Mermaid's own 523 ms of parsing,
which no scheduler can touch — a budget that phase 1 could not pass and
phase 4 alone could move. So long tasks are attributed to the window they
fall in: before the library is fetched, while it compiles, and after it
is ready. Only the third is phase 1's, and it is the one held tight.
app.js emits `snyvi:mermaid-load` and `snyvi:mermaid-ready` to draw those
lines, and a `snyvi:diagram` measure per render.

## 7. What it measures now

Taken with the harness on the machine section 1 was measured on, before
and after phase 1. "Before" is the same fixture against the `mermaid.run`
the scheduler replaced.

| | Before | After | Budget |
|---|---|---|---|
| First contentful paint | 160 ms | 76–128 ms | 200 |
| Longest task, boot | **3193 ms** | 0–66 ms | 200 |
| Longest task, drawing || 0 ms | 250 |
| First diagram drawn | never (all four at once) | ~550 ms | 2000 |
| Longest task, Mermaid parse | *(inside the 3193)* | ~310 ms | reported |
| The 220-node diagram, asked for | 2426 ms, unasked | ~1530 ms, on a click | reported |

The 3193 ms is section 1's 3123 ms reproduced independently, which is the
only reason to trust either.

Section 9 adds a pass of its own on a second document, which is not
timed at all: the theme toggle must actually re-draw what is on screen,
and every diagram family must come back legible in both themes. It is
reported as thirteen lines rather than folded into one, because the
useful thing about "gantt, dark, 1.4:1" is all three parts of it.

Phase 2a adds a row of its own, and it is a count rather than a clock:
**diagrams drawn again on a revisit**, which must be zero. Counting calls
into the renderer is the only honest way to ask — a machine fast enough
makes a real re-render look like a cache — and it names the diagrams it
caught, because "one diagram was drawn again" is not something anyone can
act on. With the cache taken out it reads `small-flow, sequence, broken`.
It also checks that a restored SVG carries the id of the figure it is in
rather than the one it was first drawn under, which is the fault that
stays invisible until a document holds the same diagram twice.

Four behaviours are checked as well, and they do not depend on the clock:
a diagram below the fold is not drawn, one over the cap is offered rather
than spent, find marks nothing inside an SVG while still finding the
prose, and a reader who leaves a document mid-render strands nothing —
neither diagrams drawn into the page they left nor diagrams never drawn
on the page they arrived at.

That last one earned its place. The first scheduler restarted its queue
only under the token it had captured, so navigating away while the
library was still loading left the *next* document's diagrams queued
forever. Every other check passed while it did. The harness holds the
request for `mermaid.js` open over the DevTools protocol rather than
racing a `sleep` against it, so the window is the same width every run.

## 8. What phase 1 left for later

Measured on the way, and not fixed here:

- ~~**A big diagram is still unreadable, and now demonstrably so.**~~
  **Fixed in phase 3.** It rendered 30 px tall because `max-width: 100%`
  fitted its width into the column and `height: auto` took the height down
  with it. It opens in a frame of its own now, at a size a label can be
  read at, and zooms, pans and fills the screen.
- **The placeholder height is still a guess**, and phase 3 only bounded
  it. A drawn diagram is at most about 70% of the window tall, so the jump
  when one lands is bounded too -- but the source says how much there is
  to draw and never how tall the drawing will be, so the reserve is still
  an estimate sitting between a small flowchart's 258 px and a sequence
  diagram's 383. Only a cache of what a diagram measured last time could
  make it exact, which is phase 2b's ground rather than phase 3's.
- ~~**The theme toggle still leaves drawn diagrams behind.**~~ **Fixed**
  with section 9, which is where it belonged: 2a made it cheap, and a
  toggle is only worth re-drawing for once the colours are the viewer's
  own. The harness checks it by reading the fills back before and after.
- ~~**`snyvi watch` still redraws on every save**~~, though it no longer
  blocked while doing it. **Fixed by 2a**, which removed the work rather
  than rescheduling it: the file's diagrams are unchanged across a save,
  so every one of them is a cache hit.

## 9. How they look

The roadmap asks two questions of every feature: does it make reading
more beautiful, and does it keep everything instant. Sections 1 to 8
answered the second one for diagrams. This section is the first, and it
was measured the same way — rendered under headless Chromium against the
bundle we actually ship, `ui/mermaid.min.js.gz`, Mermaid 11.17.2.

Today `app.js` asks for `theme: "neutral"` on a light page and
`theme: "dark"` on a dark one, and that is the whole of it. The result is
a diagram that is recognisably Mermaid's rather than snyvi's, in a viewer
whose entire pitch is that the document is the hero:

- **Grey boxes on warm paper.** `neutral` fills nodes `#eeeeee` and
  strokes them `#999999`, on a page whose paper is `#faf9f6` and whose
  rules are `#e6e2da`. Nothing else in the viewer is that colour.
- **Edge labels get a highlight box in dark mode.** `yes`, `no`,
  `near viewport` and `parse error` each render on `#585858`, which
  matches neither `--bg` (`#15181f`) nor `--code-bg` (`#1b1f28`). It
  reads as a selection highlight nobody asked for.
- **Nothing is emphasised.** Every node in a plan is drawn with equal
  weight, so a seven-node flowchart has no subject.
- **The title is Inter at 18px**, where every other heading in the viewer
  is Source Serif.

### What the reference actually is

`cathrynlavery/diagram-design` was the prompt for this. It is worth being
precise about what it is, because the obvious reading is wrong: **it is
not a Mermaid theme.** It is an agent skill — 39 hand-authored SVG
templates, a `style-guide.md` of semantic tokens (`paper`, `ink`,
`accent`, `muted`), three typefaces, and a hard rule that coordinates are
divisible by 4. Its `mermaid_extract.py` is an *importer*: it reads
Mermaid source and redraws it as bespoke SVG.

So adopting it wholesale means putting a model in the render path, which
section 4 already rules out for server-side Mermaid and for the same
reason. What ports is its vocabulary, not its machinery — and snyvi
already has the vocabulary, in `:root`. `paper` is `--bg`, `ink` is
`--fg`, `muted` is `--fg-3`, `accent` is `--accent`. The work is joining
two things that already exist.

### The lever

Mermaid 11 takes `theme: "base"` plus a `themeVariables` map, and a
`themeCSS` string that it appends to each diagram's own `<style>` block,
scoped to that diagram's id. That scoping is why this is the right lever
and `app.css` is not: Mermaid's own rules are `#id`-scoped, so a rule in
`app.css` written as `.mmd svg .node rect` loses on specificity and a
rule written to win needs `!important` on every line. `themeCSS` is
emitted *inside* the same block, after them, and wins by order.

`themeVariables` carries the colours:

| Mermaid | snyvi |
|---|---|
| `background`, `edgeLabelBackground` | `--bg` |
| `mainBkg`, `primaryColor`, `actorBkg`, `stateBkg` | `--bg-raise` |
| `secondaryColor`, `clusterBkg`, `labelBoxBkgColor` | `--bg-side` |
| `primaryTextColor`, `textColor`, `nodeTextColor` | `--fg` |
| `signalColor`, `signalTextColor`, `titleColor` | `--fg-2` |
| `lineColor` | `--fg-3` |
| `clusterBorder` | `--rule` |
| `nodeBorder`, `primaryBorderColor`, `actorBorder` | `--rule-2` |
| `noteBkgColor`, `activationBkgColor` | `--accent-bg` |
| `noteBorderColor`, `activationBorderColor` | `--accent` |

`themeCSS` carries everything a colour cannot say: 1px node strokes and
1.25px edges instead of Mermaid's heavier defaults, `rx: 8px` on
clusters to match `--radius`, the small positive letter-spacing the rest
of the UI uses, and the diagram's own title in Source Serif so it reads
as a heading rather than a caption.

### The accent, spent once

The reference's strongest rule is one accent per diagram and one or two
focal elements. Mermaid cannot infer a focus, but it does not have to:
an author writes `B:::focus`, and two rules in `themeCSS` —
`.node.focus rect` and `.node.focus .nodeLabel` — draw that node in
`--accent-bg` on `--accent`. A `:::muted` does the reverse for the
branch that is context rather than subject.

Measured, because the syntax is not obvious: **`B:::focus` needs no
`classDef` at all.** Mermaid puts the class on the node regardless and
`themeCSS` styles it. A bare `classDef focus` with no declarations is a
parse error, and a `classDef focus fill:...` emits an inline `style`
attribute that beats `themeCSS` on exactly the properties it names — so
the class must be applied without one. `class B focus` behaves
identically to `:::`.

### What it costs

Nothing the budget can see. Same two diagrams, same tab:

| | Flowchart | Sequence |
|---|---|---|
| Today (`neutral`) | 88 / 45 ms | 34 / 20 ms |
| `base` + tokens | 45 ms | 20 ms |
| + `themeCSS` | 40 ms | 19 ms |

The 88 is the first render in the tab warming up; the honest reading of
that table is that theming is free, which is what one would expect from
changing the values a renderer already substitutes.

It does add bytes. `themeCSS` is emitted into *every* diagram's `<style>`
block, so a page with eight diagrams carries eight copies: **+651 bytes
per diagram** for the block described above. That is worth knowing before
2b caches SVGs in the daemon, and it is not worth acting on.

`look: "neo"`, Mermaid 11's roomier shape set, was measured alongside and
is **not** recommended. At reading size it is nearly indistinguishable
from the tokens alone, and it hardcodes
`drop-shadow(rgba(185,185,185,1))`, which is a light-grey shadow drawn on
a dark page.

### Three things found on the way

- **`stateBkg` silently sets the state-diagram label colour.** Mermaid
  computes `stateLabelColor = stateLabelColor || stateBkg ||
  primaryTextColor`. Mapping `stateBkg` to `--bg-raise`, which is right
  for the box, therefore made every state label `#ffffff` on `#ffffff`.
  The labels were in the DOM the whole time, correctly positioned, and
  invisible. An explicit `stateLabelColor` fixes it — measured:
  `rgb(255,255,255)` before, `rgb(31,29,26)` after. The lesson is not the
  token, it is that tokens leak across diagram families, so this wants a
  render check per family rather than per token.
- **A `>` combinator in `themeCSS` survives, but only just.** Mermaid
  HTML-escapes it, so the SVG *string* contains `&gt;` and anything
  reading that string as text sees a broken selector. It round-trips
  correctly through `frame.innerHTML = svg`, which is what `mmdRender`
  does, because inside foreign content a `<style>` element decodes
  character references — verified end to end: the rule parses and
  applies. Descendant selectors avoid the question entirely and cost
  nothing here.
- **An author's `classDef` beats `themeCSS`**, per the accent note above.
  This is ordinary inline-style precedence, and it is the right way
  round: a diagram that asks for a specific colour should get it.

### What it needed before it landed, and what happened

Both conditions were met, and the first one paid for itself twice.

**A render check per diagram family.** `bench/browser.mjs` now sends a
second document — one small flowchart, sequence, class, state, ER and
gantt — scrolls to each, and reads the colours back off the real render
in *both* themes. For every label it finds what is actually behind it
with `elementFromPoint` (text made click-through for the duration, so the
point lands on the fill rather than on the glyph) and reports the worst
contrast ratio in the diagram. Alpha is composited rather than ignored:
half of snyvi's dark tokens are `rgba(255,255,255,…)`, and a ratio taken
before blending is a number about nothing. The floor is 3:1, WCAG's for
large text, because what this is here to catch is not a design opinion
but a label drawn in the colour of the thing behind it — which lands at
1.0 and which nothing else in the harness can see.

It caught two, both in dark, both of which would have shipped:

- **The focus node's label measured 1.0:1** — the same colour, twice.
  `--accent-bg` is a pale wash of the accent on paper and reads well
  there, but in the dark palette it is that same orange at 14% alpha, so
  the label composited onto the accent fill behind it *is* the accent
  fill. The label is `--bg` now, which is the one token guaranteed to
  oppose the accent in both palettes, since the accent is chosen to sit
  on it.
- **A gantt drew "Scheduler" at 1.4:1 on its own bar.** A gantt derives
  none of its colours from the table above — it has its own dozen and a
  half for bars, section bands, the grid, and text placed inside a bar or
  beside it depending on room. They are mapped now, all the text to
  `--fg`, because every bar fill is within a shade of the page.

The check was harder to write than to run, and the fault was the app's:
`#main` sets `scroll-behavior: smooth`, which a plain `scrollIntoView`
inherits, so the first version measured while the page was still gliding
and reported "no label could be read" for whichever families were
furthest from where it started. It asks for an instant scroll.

**2a first, or at least beside it.** 2a landed first, and made the theme
toggle a fix rather than a project: the theme is half of the cache key,
so a toggle is put-every-figure-back-to-a-placeholder and re-queue
what is near the viewport, and anything this tab has already drawn in
the theme being returned to costs a string assignment. Nothing is
dropped, which the plan assumed would be necessary — toggling back is
free as well, and the byte bound is what keeps holding both from
mattering. The same fault arrives by a second route, so the page also
follows the system theme moving under it when no choice is stored.

Cost was as estimated: about sixty lines in `app.js`, no new dependency,
no build step, and nothing touched in `render.rs` or the vendored bundle.
The timing rows did not move, which is what the measurements above
predicted.