@brandon_m_behring/book-scaffold-astro 4.10.0 → 4.11.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.
package/CLAUDE.md CHANGED
@@ -166,9 +166,16 @@ See `recipes/09-validation.md` to extend.
166
166
 
167
167
  ### Add a figure
168
168
 
169
- 1. Drop PDF in `figures/<topic>/<name>.pdf` (or set `BOOK_FIGURES_PATH`).
169
+ 1. Drop a PDF in `figures/<topic>/<name>.pdf`, or a TikZ standalone `.tex` (auto-compiled), or set `BOOK_FIGURES_PATH`.
170
170
  2. `npm run build:figures` produces `public/figures/<topic>/<name>.svg`.
171
- 3. Reference: `<Figure src="/figures/<topic>/<name>.svg" caption="..." id="..." />`.
171
+ 3. Reference: `<Figure src="/figures/<topic>/<name>.svg" caption="..." alt="..." id="..." />`.
172
+
173
+ **Accessibility + dark mode (v4.11.0, #84).** `build:figures` rewrites every generated SVG so one file serves both themes: it adds `role="img"` and remaps the *neutral* fills/strokes to `var(--diagram-ink|paper|grid, <original>)` (saturated accent colors are left as authored). `<Figure>` **inlines** a local `.svg` (vs `<img>`), so the page's `--diagram-*` tokens cascade in and the figure tracks the in-page dark-mode toggle; `caption`/`alt`/`desc` become the SVG's `<title>`/`<desc>`. Notes:
174
+
175
+ - `alt` is the short accessible name (defaults to `caption`); `desc` is an optional longer description.
176
+ - Non-SVG (`.png` fallback), remote, or unreadable `src` keep the `<img>` render.
177
+ - Opt a figure out of theming with a `%! no-theme` line in its source `.tex`.
178
+ - After upgrading, re-run `npm run build:figures` to theme pre-existing figures (the rewrite is idempotent).
172
179
 
173
180
  ### Add a new component
174
181
 
@@ -29,6 +29,7 @@ Sub-commands:
29
29
  build-labels Emit src/data/labels.json for cross-references (Phase C).
30
30
  build-bib BibTeX -> references.json (+ sources/manifest.yaml -> sources.json).
31
31
  build-figures PDF -> SVG via pdftocairo / pdftoppm fallback (+ TikZ in v4.2.0).
32
+ Each SVG gets role="img" + dark-mode var(--diagram-*) fills (v4.11.0).
32
33
  build-tips Scan chapters for <Tip> instances; emit src/data/tips.json (v4.3.0).
33
34
  build-exercises Scan chapters for <Exercise> instances; emit src/data/exercises.json (v4.4.0).
34
35
  render-notebooks ipynb -> HTML via Jupyter nbconvert.
@@ -4,13 +4,22 @@
4
4
  * `\label{...}` pattern with a single component.
5
5
  *
6
6
  * Source assets are emitted under public/figures/weekNN/ by
7
- * scripts/build-figures.sh (Phase 2.4); src paths are absolute from
8
- * site root.
7
+ * scripts/build-figures.mjs; src paths are absolute from site root.
8
+ *
9
+ * v4.11.0 (#84): a local pipeline `.svg` is **inlined** (read from public/ +
10
+ * `set:html`) rather than referenced via `<img>`. Inlining puts the SVG in the
11
+ * host DOM, so the page's tokens.css `--diagram-*` cascade in — the figure
12
+ * tracks the in-page dark-mode toggle (an `<img>`-loaded SVG is CSS-isolated
13
+ * and could only follow the OS preference). Inlining also lets a11y
14
+ * `<title>`/`<desc>` come from this component's props (assembleSvg). Non-SVG
15
+ * (the pdftoppm `.png` fallback), remote, or unreadable `src` gracefully keep
16
+ * the `<img>` render — a figure must never crash a build.
9
17
  *
10
18
  * Usage:
11
19
  * <Figure
12
20
  * src="/figures/week04/ex2_hippo_eigenvalues.svg"
13
21
  * caption="HiPPO-LegS eigenvalue structure for N = 16 and N = 64."
22
+ * alt="Two overlaid eigenvalue spectra forming nested arcs."
14
23
  * width="100%"
15
24
  * id="w4-fig-hippo-eigenvalues"
16
25
  * />
@@ -18,18 +27,41 @@
18
27
  * The id prop registers the figure with the cross-reference label map
19
28
  * (consumed by `<XRef>`).
20
29
  */
30
+ import { readFileSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+ import { shouldInline, assembleSvg } from '../src/lib/figure.mjs';
33
+
21
34
  interface Props {
22
35
  src: string;
23
36
  caption?: string;
24
37
  width?: string;
25
38
  id?: string;
26
39
  alt?: string;
40
+ /** Long-form description → SVG <desc> (alt stays the short accessible name). */
41
+ desc?: string;
27
42
  }
28
43
 
29
- const { src, caption, width = '100%', id, alt } = Astro.props;
44
+ const { src, caption, width = '100%', id, alt, desc } = Astro.props;
30
45
  const altText = alt ?? caption ?? '';
46
+
47
+ // Inline a local pipeline SVG so host CSS variables theme it + a11y nodes come
48
+ // from props. Any read failure falls back to <img> below (never throws).
49
+ let inlineSvg: string | null = null;
50
+ if (shouldInline(src)) {
51
+ try {
52
+ const raw = readFileSync(join(process.cwd(), 'public', src), 'utf8');
53
+ const idBase = id ?? `fig-${src.replace(/^\/+/, '').replace(/\.[^.]+$/, '')}`;
54
+ inlineSvg = assembleSvg(raw, { caption, alt, desc, width, idBase });
55
+ } catch {
56
+ inlineSvg = null;
57
+ }
58
+ }
31
59
  ---
32
60
  <figure class="figure" id={id}>
33
- <img src={src} alt={altText} style={`width: ${width}; max-width: 100%;`} />
61
+ {inlineSvg ? (
62
+ <Fragment set:html={inlineSvg} />
63
+ ) : (
64
+ <img src={src} alt={altText} style={`width: ${width}; max-width: 100%;`} />
65
+ )}
34
66
  {caption && <figcaption>{caption}</figcaption>}
35
67
  </figure>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "4.10.0",
4
+ "version": "4.11.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -64,7 +64,7 @@ Supported `type` values: `theorem`, `proposition`, `lemma`, `corollary`, `defini
64
64
  |---|---|---|
65
65
  | `Cite` | Inline citation linked to `/references` | `<Cite key="gu2024mamba" page="3" />` |
66
66
  | `XRef` | Cross-reference to a labeled element | `<XRef id="thm:zoh-stability" />` |
67
- | `Figure` | Image + caption + id | `<Figure src="/figures/week04/eigenvalues.svg" caption="…" id="fig-eig" />` |
67
+ | `Figure` | Image/SVG + caption + id; local SVGs inline for a11y + dark mode (`alt`, `desc`) | `<Figure src="/figures/week04/eigenvalues.svg" caption="…" alt="…" id="fig-eig" />` |
68
68
  | `MarginNote` | Right-margin annotation (Tufte-style) | `<MarginNote>side comment</MarginNote>` |
69
69
  | `Sidenote` | Auto-numbered marginalia | `<Sidenote>numbered note</Sidenote>` |
70
70
  | `WeekRef` | Jump-link to a week chapter | `<WeekRef week={4} />` |
@@ -23,7 +23,17 @@
23
23
  * Falls back to pdftoppm (PNG @ 200 DPI) if pdftocairo produces an
24
24
  * unreasonably small (likely malformed) SVG.
25
25
  *
26
- * Idempotent: skips when the target SVG is newer than the source PDF.
26
+ * v4.11.0 (closes #84): each generated SVG gets a post-export rewrite
27
+ * (recolorSvg) that injects role="img" + a CSS-variable theming layer so one
28
+ * SVG serves light + dark. Neutral fills/strokes are remapped to
29
+ * var(--diagram-ink|paper|grid, <original>) via injected attribute-selector
30
+ * rules (the original attribute stays as the fallback); saturated accent colors
31
+ * are left untouched. A `%! no-theme` line in the source .tex opts a figure out.
32
+ * <Figure> inlines local SVGs so they track the in-page [data-theme] toggle.
33
+ *
34
+ * Idempotent: skips when the target SVG is newer than the source PDF (and the
35
+ * recolor itself is a no-op on an already-themed SVG, so re-runs after an
36
+ * upgrade safely theme pre-existing figures).
27
37
  * Run on `prebuild` so Astro always sees fresh figures.
28
38
  *
29
39
  * Graceful skip: when pdftocairo / pdftoppm aren't on PATH (e.g. Cloudflare
@@ -32,10 +42,11 @@
32
42
  * from PDFs on every `npm run dev`.
33
43
  */
34
44
  import { readdir, stat, mkdir } from 'node:fs/promises';
35
- import { existsSync, statSync } from 'node:fs';
45
+ import { existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
36
46
  import { dirname, resolve, basename } from 'node:path';
37
47
  import { fileURLToPath } from 'node:url';
38
48
  import { spawnSync } from 'node:child_process';
49
+ import { recolorSvg } from '../src/lib/figure.mjs';
39
50
 
40
51
  // --help / -h: non-mutating (closes #14).
41
52
  const USAGE = `Usage: book-scaffold build-figures
@@ -44,6 +55,10 @@ Figure pipeline. PDF -> SVG via pdftocairo (PNG fallback via pdftoppm at
44
55
  200dpi). Walks figures/ (or BOOK_FIGURES_PATH), emits to public/figures/.
45
56
  Graceful-skip if pdftocairo / pdftoppm not on PATH.
46
57
 
58
+ Each SVG is rewritten to be accessible + dark-mode-aware: role="img" plus
59
+ var(--diagram-ink|paper|grid, orig) fills (a "%! no-theme" line in the
60
+ source .tex opts out). <Figure> inlines local SVGs so they track the theme.
61
+
47
62
  Env:
48
63
  BOOK_FIGURES_PATH Override figures source (default: figures/).
49
64
 
@@ -179,6 +194,25 @@ function convertToSvg(srcPath, dstPath) {
179
194
  return size >= MIN_SVG_BYTES;
180
195
  }
181
196
 
197
+ // v4.11.0 (#84): a `%! no-theme` line (anywhere in the source .tex) opts a
198
+ // figure out of the dark-mode/a11y rewrite. Matches `%!` then `no-theme`,
199
+ // tolerant of surrounding whitespace — distinct from BibTeX `%`-line comments.
200
+ const NO_THEME_RE = /^\s*%!\s*no-theme\b/m;
201
+
202
+ /**
203
+ * v4.11.0 (#84): apply the theming rewrite to a generated SVG in place.
204
+ * No-op (returns false) if the file is absent or recolorSvg leaves it unchanged
205
+ * (already themed / nothing neutral to remap). Idempotent.
206
+ */
207
+ function themeIfSvg(svgPath, optOut) {
208
+ if (!existsSync(svgPath)) return false;
209
+ const original = readFileSync(svgPath, 'utf8');
210
+ const themed = recolorSvg(original, { optOut });
211
+ if (themed === original) return false;
212
+ writeFileSync(svgPath, themed, 'utf8');
213
+ return true;
214
+ }
215
+
182
216
  function convertToPng(srcPath, pngStem) {
183
217
  // pdftoppm: -r 200 (DPI), -png, single page (first only).
184
218
  const r = spawnSync(
@@ -251,6 +285,7 @@ async function main() {
251
285
  let converted = 0;
252
286
  let skipped = 0;
253
287
  let pngFallback = 0;
288
+ let themed = 0;
254
289
 
255
290
  for (const { relPath } of pdfs) {
256
291
  total++;
@@ -259,7 +294,20 @@ async function main() {
259
294
  const svgPath = resolve(FIGURES_DST, `${stem}.svg`);
260
295
  const pngPath = resolve(FIGURES_DST, `${stem}.png`);
261
296
 
262
- if (isUpToDate(srcPath, svgPath) || isUpToDate(srcPath, pngPath)) {
297
+ // Opt-out via `%! no-theme` in the source .tex (TikZ figures only).
298
+ const texSibling = resolve(FIGURES_SRC, `${stem}.tex`);
299
+ const optOut =
300
+ existsSync(texSibling) && NO_THEME_RE.test(readFileSync(texSibling, 'utf8'));
301
+
302
+ // Cached SVG: still run the (idempotent) rewrite so an upgrade themes
303
+ // pre-existing figures without forcing a source touch. PNG fallbacks are
304
+ // raster — nothing to theme.
305
+ if (isUpToDate(srcPath, svgPath)) {
306
+ if (themeIfSvg(svgPath, optOut)) themed++;
307
+ skipped++;
308
+ continue;
309
+ }
310
+ if (isUpToDate(srcPath, pngPath)) {
263
311
  skipped++;
264
312
  continue;
265
313
  }
@@ -269,14 +317,17 @@ async function main() {
269
317
  if (!svgOK) {
270
318
  convertToPng(srcPath, svgPath.replace(/\.svg$/, ''));
271
319
  pngFallback++;
320
+ } else if (themeIfSvg(svgPath, optOut)) {
321
+ themed++;
272
322
  }
273
323
  converted++;
274
324
  }
275
325
 
276
326
  const tikzNote = tikzCompiled > 0 ? `, ${tikzCompiled} tikz→pdf` : '';
327
+ const themedNote = themed > 0 ? `, ${themed} themed` : '';
277
328
  console.log(
278
329
  `build-figures: ${total} total, ${converted} converted ` +
279
- `(${pngFallback} png fallback), ${skipped} cached${tikzNote}`,
330
+ `(${pngFallback} png fallback), ${skipped} cached${themedNote}${tikzNote}`,
280
331
  );
281
332
  }
282
333
 
@@ -0,0 +1,245 @@
1
+ /**
2
+ * src/lib/figure.mjs — pure SVG transforms for the figure pipeline (v4.11.0, #84).
3
+ *
4
+ * Two concerns, one module so the `data-diagram-*` sentinels stay DRY:
5
+ *
6
+ * • BUILD side — `recolorSvg()` rewrites a pdftocairo SVG to be theme-aware
7
+ * (called by scripts/build-figures.mjs after each PDF→SVG conversion).
8
+ * • RENDER side — `shouldInline()` + `assembleSvg()` let Figure.astro inline a
9
+ * local pipeline SVG with a11y <title>/<desc> from the <Figure> props.
10
+ *
11
+ * Why a stylesheet, not presentation attributes: `var()` inside a presentation
12
+ * attribute (`fill="var(--x, …)"`) has unreliable browser support, whereas
13
+ * `var()` in a real <style> rule is universal. So `recolorSvg` injects an
14
+ * attribute-selector rule per distinct neutral color and never mutates a
15
+ * drawing element — the untouched `fill=""`/`stroke=""` attribute is the
16
+ * automatic fallback where `var()` is unsupported.
17
+ *
18
+ * Why inline (vs <img>): an SVG loaded via <img> is CSS-isolated, so a host
19
+ * page's `var(--diagram-*)` cannot reach it — it could only follow the OS
20
+ * prefers-color-scheme via the embedded <style data-diagram-theme>. Inlining
21
+ * puts the SVG in the host DOM, so the host's tokens.css (which tracks the
22
+ * in-page [data-theme] toggle) themes it. `assembleSvg` therefore STRIPS the
23
+ * embedded theme block so the host is the sole source of --diagram-*.
24
+ *
25
+ * Pure string ops only — no `node:` imports — so the module bundles for any
26
+ * consumer (Figure.astro imports it; fs lives in the .astro/.mjs callers).
27
+ */
28
+
29
+ // Standalone self-theming defaults: used only when the SVG is NOT inlined
30
+ // (direct file open / <img>). Hex literals because host CSS vars don't exist
31
+ // there. Mirrors styles/tokens.css light + dark neutral values.
32
+ export const DIAGRAM_THEME_CSS =
33
+ ':root{--diagram-ink:#1A1A19;--diagram-paper:#FDFCF9;--diagram-grid:#B5B3AA}' +
34
+ '@media (prefers-color-scheme:dark){:root{' +
35
+ '--diagram-ink:#E8E5DD;--diagram-paper:#1A1816;--diagram-grid:#3A3632}}';
36
+
37
+ const DIAGRAM_VAR = {
38
+ ink: '--diagram-ink',
39
+ paper: '--diagram-paper',
40
+ grid: '--diagram-grid',
41
+ };
42
+
43
+ /**
44
+ * Parse a solid SVG paint into {r,g,b} in [0,1], or null when it is not a
45
+ * concrete color we should remap (none / url(...) / currentColor / inherit).
46
+ * Handles pdftocairo's `rgb(R%, G%, B%)` plus `rgb(r,g,b)` and #hex for safety.
47
+ */
48
+ export function parseColor(value) {
49
+ if (typeof value !== 'string') return null;
50
+ const v = value.trim();
51
+ let m;
52
+ if ((m = v.match(/^rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/i))) {
53
+ return { r: +m[1] / 100, g: +m[2] / 100, b: +m[3] / 100 };
54
+ }
55
+ if ((m = v.match(/^rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/i))) {
56
+ return { r: +m[1] / 255, g: +m[2] / 255, b: +m[3] / 255 };
57
+ }
58
+ if ((m = v.match(/^#([0-9a-f]{3})$/i))) {
59
+ const [a, b, c] = m[1];
60
+ return { r: parseInt(a + a, 16) / 255, g: parseInt(b + b, 16) / 255, b: parseInt(c + c, 16) / 255 };
61
+ }
62
+ if ((m = v.match(/^#([0-9a-f]{6})$/i))) {
63
+ return {
64
+ r: parseInt(m[1].slice(0, 2), 16) / 255,
65
+ g: parseInt(m[1].slice(2, 4), 16) / 255,
66
+ b: parseInt(m[1].slice(4, 6), 16) / 255,
67
+ };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Classify a color as 'ink' | 'paper' | 'grid', or null to leave it untouched.
74
+ * Saturated colors (chroma over threshold) are intentional accents and keep
75
+ * their hue across themes; only near-neutral colors are remapped, split by
76
+ * relative luminance into dark ink / light paper / mid gridlines.
77
+ */
78
+ export function classifyColor(value) {
79
+ const c = parseColor(value);
80
+ if (!c) return null;
81
+ const chroma = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
82
+ if (chroma > 0.12) return null; // saturated accent — preserve as authored
83
+ const lum = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
84
+ if (lum < 0.3) return 'ink';
85
+ if (lum > 0.9) return 'paper';
86
+ return 'grid';
87
+ }
88
+
89
+ // Escape a value for use inside a CSS [attr="…"] selector string.
90
+ function cssAttrEscape(s) {
91
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
92
+ }
93
+
94
+ /**
95
+ * Rewrite a pdftocairo SVG to be theme-aware + carry role="img". Pure and
96
+ * idempotent (a second pass is a no-op). `opts.optOut` short-circuits, returning
97
+ * the input unchanged (the `%! no-theme` authoring escape hatch).
98
+ *
99
+ * Injects, right after the opening <svg> tag:
100
+ * <style data-diagram-theme> — standalone var defaults + @media(dark).
101
+ * <style data-diagram-map> — `[fill="C"]{fill:var(--diagram-X, C)}` … per
102
+ * distinct neutral color C. Elements are NOT
103
+ * modified; the attribute stays as fallback.
104
+ */
105
+ export function recolorSvg(svg, { optOut = false } = {}) {
106
+ if (typeof svg !== 'string') return svg;
107
+ if (optOut || svg.includes('data-diagram-map')) return svg;
108
+
109
+ const openMatch = svg.match(/<svg\b[^>]*>/i);
110
+ if (!openMatch) return svg;
111
+
112
+ // Distinct concrete colors used as fill="" / stroke="" attributes.
113
+ const found = new Map(); // original string → class
114
+ const attrRe = /\b(?:fill|stroke)="([^"]+)"/g;
115
+ let am;
116
+ while ((am = attrRe.exec(svg)) !== null) {
117
+ if (found.has(am[1])) continue;
118
+ const cls = classifyColor(am[1]);
119
+ if (cls) found.set(am[1], cls);
120
+ }
121
+
122
+ let openTag = openMatch[0];
123
+ if (!/\srole=/i.test(openTag)) openTag = openTag.replace(/<svg\b/i, '<svg role="img"');
124
+
125
+ // Nothing neutral to remap — still surface role="img" for a11y, no <style>.
126
+ if (found.size === 0) {
127
+ return openTag === openMatch[0]
128
+ ? svg
129
+ : svg.slice(0, openMatch.index) + openTag + svg.slice(openMatch.index + openMatch[0].length);
130
+ }
131
+
132
+ let mapCss = '';
133
+ for (const [orig, cls] of found) {
134
+ const v = DIAGRAM_VAR[cls];
135
+ const sel = cssAttrEscape(orig);
136
+ mapCss +=
137
+ `[fill="${sel}"]{fill:var(${v}, ${orig})}` +
138
+ `[stroke="${sel}"]{stroke:var(${v}, ${orig})}`;
139
+ }
140
+
141
+ const styleBlocks =
142
+ `<style data-diagram-theme>${DIAGRAM_THEME_CSS}</style>` +
143
+ `<style data-diagram-map>${mapCss}</style>`;
144
+
145
+ return (
146
+ svg.slice(0, openMatch.index) +
147
+ openTag +
148
+ styleBlocks +
149
+ svg.slice(openMatch.index + openMatch[0].length)
150
+ );
151
+ }
152
+
153
+ // ── Render side ────────────────────────────────────────────────────────────
154
+
155
+ /** Should <Figure> inline `src` (local pipeline .svg) vs render <img>? */
156
+ export function shouldInline(src) {
157
+ return (
158
+ typeof src === 'string' &&
159
+ src.startsWith('/') &&
160
+ !src.startsWith('//') && // protocol-relative = remote host
161
+ /\.svg$/i.test(src)
162
+ );
163
+ }
164
+
165
+ const THEME_BLOCK_RE = /<style\b[^>]*\bdata-diagram-theme\b[^>]*>[\s\S]*?<\/style>/gi;
166
+
167
+ /** Remove the standalone self-theming block so the host tokens.css is the sole
168
+ * source of --diagram-* once the SVG is inlined into the page. */
169
+ export function stripThemeBlock(svg) {
170
+ return typeof svg === 'string' ? svg.replace(THEME_BLOCK_RE, '') : svg;
171
+ }
172
+
173
+ function escapeXml(s) {
174
+ return String(s)
175
+ .replace(/&/g, '&amp;')
176
+ .replace(/</g, '&lt;')
177
+ .replace(/>/g, '&gt;')
178
+ .replace(/"/g, '&quot;');
179
+ }
180
+
181
+ function ensureSvgAttr(openTag, name, value) {
182
+ const re = new RegExp(`\\s${name}=`, 'i');
183
+ if (re.test(openTag)) return openTag;
184
+ return openTag.replace(/<svg\b/i, `<svg ${name}="${value}"`);
185
+ }
186
+
187
+ function setSvgAttr(openTag, name, value) {
188
+ const re = new RegExp(`\\s${name}="[^"]*"`, 'i');
189
+ if (re.test(openTag)) return openTag.replace(re, ` ${name}="${value}"`);
190
+ return openTag.replace(/<svg\b/i, `<svg ${name}="${value}"`);
191
+ }
192
+
193
+ function mergeSvgStyle(openTag, css) {
194
+ const re = /\sstyle="([^"]*)"/i;
195
+ const m = openTag.match(re);
196
+ if (m) {
197
+ const existing = m[1].trim().replace(/;\s*$/, '');
198
+ return openTag.replace(re, ` style="${existing ? existing + ';' : ''}${css}"`);
199
+ }
200
+ return openTag.replace(/<svg\b/i, `<svg style="${css}"`);
201
+ }
202
+
203
+ /**
204
+ * Prepare a raw pipeline SVG for inline embedding in <Figure>:
205
+ * - strip the standalone <style data-diagram-theme> (host tokens.css themes it);
206
+ * - replace any pre-existing <title>/<desc> with ones from the call-site props
207
+ * (caption → <title>, desc ?? alt → <desc>) and wire aria-labelledby;
208
+ * - ensure role="img" and a responsive width on the root <svg>.
209
+ * Pure string transform; output is a trusted local build artifact (set:html).
210
+ */
211
+ export function assembleSvg(raw, opts = {}) {
212
+ const { caption, alt, desc, width = '100%', idBase = 'figure' } = opts;
213
+ if (typeof raw !== 'string') return '';
214
+
215
+ let svg = stripThemeBlock(raw);
216
+ const openMatch = svg.match(/<svg\b[^>]*>/i);
217
+ if (!openMatch) return svg;
218
+
219
+ let openTag = openMatch[0];
220
+ let body = svg
221
+ .slice(openMatch.index + openTag.length)
222
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, '')
223
+ .replace(/<desc\b[^>]*>[\s\S]*?<\/desc>/gi, '');
224
+
225
+ const titleText = caption ?? alt ?? '';
226
+ const descText = desc ?? (alt && alt !== titleText ? alt : '');
227
+ const id = String(idBase).replace(/[^a-zA-Z0-9_-]/g, '-');
228
+
229
+ const a11y = [];
230
+ const labelledby = [];
231
+ if (titleText) {
232
+ a11y.push(`<title id="${id}-title">${escapeXml(titleText)}</title>`);
233
+ labelledby.push(`${id}-title`);
234
+ }
235
+ if (descText) {
236
+ a11y.push(`<desc id="${id}-desc">${escapeXml(descText)}</desc>`);
237
+ labelledby.push(`${id}-desc`);
238
+ }
239
+
240
+ openTag = ensureSvgAttr(openTag, 'role', 'img');
241
+ if (labelledby.length) openTag = setSvgAttr(openTag, 'aria-labelledby', labelledby.join(' '));
242
+ openTag = mergeSvgStyle(openTag, `width:${width};max-width:100%;height:auto`);
243
+
244
+ return `${svg.slice(0, openMatch.index)}${openTag}${a11y.join('')}${body}`;
245
+ }
package/styles/tokens.css CHANGED
@@ -51,6 +51,15 @@
51
51
  --callout-worked: var(--warm-plum);
52
52
  --callout-learn: var(--warm-gold);
53
53
 
54
+ /* Diagram semantic roles (v4.11.0, #84): theme-aware TikZ→SVG figures.
55
+ * build-figures remaps an SVG's neutral fills/strokes to these via
56
+ * var(--diagram-*, <original>); <Figure> inlines the SVG so this cascade
57
+ * reaches it. They point at existing roles, so they auto-flip in dark mode
58
+ * (no dark-block edits) — ink↔text, paper↔page bg, grid↔border. */
59
+ --diagram-ink: var(--color-text);
60
+ --diagram-paper: var(--color-bg);
61
+ --diagram-grid: var(--color-border);
62
+
54
63
  /* ===== Typography scale ===== */
55
64
  --font-body: 'Roboto Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
56
65
  --font-code: 'Source Code Pro Variable', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;