@brandon_m_behring/book-scaffold-astro 4.30.0 → 4.31.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
@@ -229,9 +229,11 @@ See `recipes/09-validation.md` to extend.
229
229
  2. `npm run build:figures` produces `public/figures/<topic>/<name>.svg`.
230
230
  3. Reference: `<Figure src="/figures/<topic>/<name>.svg" caption="..." alt="..." id="..." />`.
231
231
 
232
- **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:
232
+ **Accessibility + dark mode (v4.11.0, #84; #161/#164).** `build:figures` rewrites every generated SVG so one file serves both themes: it adds `role="img"`, maps exact Warm–Tol authoring colors to semantic `--fig-*`, maps the seven chromatic Okabe–Ito colors to stable `--series-1..7`, and remaps neutral fills/strokes to the backward-compatible `--diagram-ink|paper|grid` aliases. Canonical series-8 black is indistinguishable from structural ink after PDF export; both resolve through `--fig-ink` in either theme. Unknown saturated colors stay authored. `<Figure>` **inlines** a local `.svg` (vs `<img>`), so the page's tokens cascade in and the figure tracks the in-page dark-mode toggle; `alt` (falling back to `caption`) becomes the SVG's accessible `<title>`, and `desc` becomes its optional longer `<desc>`. Notes:
233
233
 
234
234
  - `alt` is the short accessible name (defaults to `caption`); `desc` is an optional longer description.
235
+ - For pale fills, export the canonical base color with a separate opacity (`fill opacity` / `alpha`), not a pre-blended tint such as `warmblue!13`; see `recipes/24-figure-authoring-standard.md`.
236
+ - Use `--fig-*` for meaning and `--series-*` only for categorical ordinals. Always add a non-color cue (label, marker, dash, shape, or texture).
235
237
  - Non-SVG (`.png` fallback), remote, or unreadable `src` keep the `<img>` render.
236
238
  - Opt a figure out of theming with a `%! no-theme` line in its source `.tex`.
237
239
  - After upgrading, re-run `npm run build:figures` to theme pre-existing figures (the rewrite is idempotent).
@@ -29,7 +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
+ Adds a11y + theme-aware semantic/categorical palette mappings.
33
33
  build-tips Scan chapters for <Tip> instances; emit src/data/tips.json (v4.3.0).
34
34
  build-exercises Scan chapters for <Exercise> instances; emit src/data/exercises.json (v4.4.0).
35
35
  render-notebooks ipynb -> HTML via Jupyter nbconvert.
@@ -8,9 +8,9 @@
8
8
  *
9
9
  * v4.11.0 (#84): a local pipeline `.svg` is **inlined** (read from public/ +
10
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
11
+ * host DOM, so tokens.css `--fig-*`, `--series-*`, and `--diagram-*` cascade
12
+ * in — the figure tracks the in-page dark-mode toggle (an `<img>`-loaded SVG
13
+ * is CSS-isolated and could only follow the OS preference). Inlining also lets a11y
14
14
  * `<title>`/`<desc>` come from this component's props (assembleSvg). Non-SVG
15
15
  * (the pdftoppm `.png` fallback), remote, or unreadable `src` gracefully keep
16
16
  * the `<img>` render — a figure must never crash a build.
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import { readFileSync } from 'node:fs';
31
31
  import { join } from 'node:path';
32
- import { shouldInline, assembleSvg } from '../src/lib/figure.mjs';
32
+ import { publicSvgPath, assembleSvg } from '../src/lib/figure.mjs';
33
33
 
34
34
  interface Props {
35
35
  src: string;
@@ -47,9 +47,10 @@ const altText = alt ?? caption ?? '';
47
47
  // Inline a local pipeline SVG so host CSS variables theme it + a11y nodes come
48
48
  // from props. Any read failure falls back to <img> below (never throws).
49
49
  let inlineSvg: string | null = null;
50
- if (shouldInline(src)) {
50
+ const localSvgPath = publicSvgPath(src, import.meta.env.BASE_URL);
51
+ if (localSvgPath) {
51
52
  try {
52
- const raw = readFileSync(join(process.cwd(), 'public', src), 'utf8');
53
+ const raw = readFileSync(join(process.cwd(), 'public', localSvgPath), 'utf8');
53
54
  const idBase = id ?? `fig-${src.replace(/^\/+/, '').replace(/\.[^.]+$/, '')}`;
54
55
  inlineSvg = assembleSvg(raw, { caption, alt, desc, width, idBase });
55
56
  } catch {
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 with five typed presets, Tufte typography, citations, search, PDF, and Cloudflare deployment.",
4
- "version": "4.30.0",
4
+ "version": "4.31.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -174,6 +174,8 @@
174
174
  ],
175
175
  "scripts": {
176
176
  "build": "tsup",
177
+ "sync:figure-tokens": "node scripts/sync-figure-tokens.mjs --write",
178
+ "check:figure-tokens": "node scripts/sync-figure-tokens.mjs --check",
177
179
  "check:types": "tsc -p tests/types/tsconfig.json",
178
180
  "prepublishOnly": "npm run build && npm run check:types",
179
181
  "test": "node --test tests/*.test.mjs"
@@ -2,7 +2,12 @@
2
2
 
3
3
  **Profile**: any (build:figures and build:notebooks both graceful-skip when source dirs / tools are absent).
4
4
 
5
- **TL;DR**: Put PDFs in `figures/`, Jupyter notebooks in `notebooks/`. `npm run dev` / `npm run build` runs both pipelines via the `build:assets` prebuild hook. Output: `public/figures/*.svg` (PDF→SVG via `pdftocairo`) and `public/notebooks/*.html` (ipynb→HTML via `uv run jupyter nbconvert`).
5
+ **TL;DR**: Put PDFs in `figures/`, Jupyter notebooks in `notebooks/`, then run
6
+ `npm run build:figures` and `npm run build:notebooks` explicitly. Output:
7
+ `public/figures/*.svg` (PDF→SVG via `pdftocairo`) and
8
+ `public/notebooks/*.html` (ipynb→HTML via `uv run jupyter nbconvert`). These
9
+ optional system-tool pipelines are not part of `prebuild`; generated books run
10
+ validation there instead.
6
11
 
7
12
  ## How each pipeline works
8
13
 
@@ -11,6 +16,7 @@
11
16
  - **Source**: `figures/` at scaffold root (override via `BOOK_FIGURES_PATH` env var; e.g. `BOOK_FIGURES_PATH=../shared/figures` to share with a LaTeX sibling)
12
17
  - **Output**: `public/figures/<same-subdir-structure>/<stem>.svg`
13
18
  - **Tool**: `pdftocairo` (poppler-utils) with `pdftoppm` fallback for malformed SVG
19
+ - **Theming**: canonical Warm–Tol / Okabe–Ito colors and neutral paints are mapped to theme-aware CSS variables; see [Recipe 24](24-figure-authoring-standard.md)
14
20
  - **Idempotency**: skips when SVG mtime >= PDF mtime
15
21
  - **Graceful skip**: if `pdftocairo` or `pdftoppm` not on PATH (Cloudflare build container), warns and exits 0 — committed SVGs under `public/figures/` are served as-is
16
22
 
@@ -58,8 +64,9 @@ Cloudflare Workers build containers don't have `pdftocairo` or `uv` installed. T
58
64
 
59
65
  1. **Commit derived artifacts** (recommended for low-friction deploy):
60
66
  - Edit `.gitignore`: remove the `public/figures/` and `public/notebooks/` lines.
61
- - Run `npm run build:assets` locally; commit the generated outputs.
62
- - CI's prebuild gracefully skips (tools missing), serves committed artifacts.
67
+ - Run `npm run build:figures` and `npm run build:notebooks` locally; commit
68
+ the generated outputs.
69
+ - CI serves the committed artifacts without needing either optional system tool.
63
70
  - Trade-off: ~3 MB of binary artifacts in git history.
64
71
 
65
72
  2. **Install poppler + uv in CI**: prepend `apt-get install -y poppler-utils && curl -LsSf https://astral.sh/uv/install.sh | sh && ...` to the build command. More setup; cleaner repo.
@@ -71,12 +78,13 @@ post_transformers chose option 1 (see commit `f7fa75d`).
71
78
  - **Notebooks should be output-free** for a clean rendered HTML — clear outputs before committing, or use `nbstripout` as a pre-commit hook. Cells with embedded outputs render those outputs in the HTML; this may or may not be what you want.
72
79
  - **Stub-size threshold** at 1500 bytes is empirical from post_transformers — adjust via `NOTEBOOK_STUB_BYTES` if your placeholder notebooks are larger.
73
80
  - **`pdftocairo` produces a tiny SVG** for some PDF inputs (vector layers ungrouped, etc.). The script auto-falls back to `pdftoppm -r 200 -png` at 200 DPI when SVG output is < 200 bytes.
81
+ - **Do not pre-blend semantic fills** (`warmblue!13`, for example). Export the canonical base color with a separate opacity so the SVG rewrite can preserve its role in dark mode. Recipe 24 has TikZ and matplotlib examples.
74
82
 
75
83
  ## Canonical files
76
84
 
77
85
  - `scripts/build-figures.mjs:23-32` — path resolution + env overrides
78
86
  - `scripts/render-notebooks.mjs:30-46` — same
79
- - `package.json` `prebuild` / `predev` / `build:assets` — orchestration
87
+ - `package.json` `build:figures` / `build:notebooks` — explicit authoring commands
80
88
  - `.gitignore` — toggles whether artifacts are committed
81
89
 
82
90
  ## Reference implementation
@@ -4,7 +4,11 @@
4
4
 
5
5
  ## TL;DR
6
6
 
7
- Drop `figures/<topic>/diagram.tex` (standalone TikZ source). Run `npm run build:figures` (or it's wired into `prebuild`). Get `public/figures/<topic>/diagram.svg` ready to reference in MDX as `<Figure src="/figures/<topic>/diagram.svg" />`.
7
+ Drop `figures/<topic>/diagram.tex` (standalone TikZ source). Run
8
+ `npm run build:figures` explicitly; figure conversion is not wired into
9
+ `prebuild` because TeX and Poppler are optional system tools. The command emits
10
+ `public/figures/<topic>/diagram.svg`, ready to reference in MDX as
11
+ `<Figure src="/figures/<topic>/diagram.svg" />`.
8
12
 
9
13
  ## The TikZ source
10
14
 
@@ -25,6 +29,13 @@ Use the `standalone` document class; configure for SVG via `tikz` option. Recomm
25
29
 
26
30
  The `border=2mm` adds a small margin around the figure so it doesn't crop right at the edge.
27
31
 
32
+ For theme-aware color, define the canonical Warm–Tol or Okabe–Ito hex and use
33
+ `fill opacity` rather than an xcolor pre-blend such as `warmblue!13`. The base
34
+ color survives PDF export and `build-figures` can map it to `--fig-*` or
35
+ `--series-*`; the pre-blended RGB cannot preserve its meaning. See
36
+ [Recipe 24 — Figure authoring standard](24-figure-authoring-standard.md) for
37
+ the palette, accessible description pattern, and dual-theme release gate.
38
+
28
39
  ## Discovery rule
29
40
 
30
41
  `build-figures` walks `figures/` (or `BOOK_FIGURES_PATH`) for both `.pdf` and `.tex` files. For each `.tex` source:
@@ -136,6 +147,6 @@ layout: wide # widens --measure-main to 80ch; omit (or 'default') for the s
136
147
 
137
148
  ## See also
138
149
 
139
- - `recipes/06-figures.md` — overall figure pipeline + matplotlib/svg sources
150
+ - `recipes/24-figure-authoring-standard.md` — palette, matplotlib/TikZ authoring, dark mode, and accessibility
140
151
  - `PACKAGE_DESIGN.md §7` — peer dependencies (lists `pdflatex` as optional system dep)
141
152
  - `PACKAGE_DESIGN.md §8` — `book-scaffold` CLI reference (build-figures subcommand)
@@ -0,0 +1,241 @@
1
+ # Recipe 24 — Figure authoring standard
2
+
3
+ **Profile**: any profile that publishes diagrams, plots, or TikZ figures.
4
+
5
+ **TL;DR**: Use `--fig-*` when a color means something and `--series-1` through
6
+ `--series-8` when colors only distinguish data series. Author exported figures
7
+ with the canonical hex plus a separate opacity; `build:figures` maps those
8
+ colors to theme-aware CSS variables. Every figure needs a useful caption and
9
+ accessible description, and color must never be the only way to read it.
10
+
11
+ ## 1. Choose the right color contract
12
+
13
+ Semantic colors keep the same meaning across a book:
14
+
15
+ | Token | Meaning | Light | Dark | Export hex |
16
+ |---|---|---|---|---|
17
+ | `--fig-blue` | default, lightweight, informational | `#3B6FA0` | `#7297BB` | `#3B6FA0` |
18
+ | `--fig-green` | positive or successful outcome | `#4A7E3F` | `#7DA275` | `#4A7E3F` |
19
+ | `--fig-rose` | caution or problem | `#C06858` | `#D29287` | `#C06858` |
20
+ | `--fig-plum` | authority, control, or heaviest weight | `#8A4E82` | `#AB80A5` | `#8A4E82` |
21
+ | `--fig-gold` | packaging, coordination, or convergence | `#9D7D34` | `#D2B575` | `#C09840` |
22
+ | `--fig-crimson` | failure or severe problem | `#A03838` | `#BB7070` | `#A03838` |
23
+ | `--fig-ink` | labels, essential outlines, axes | `#1A1A19` | `#E8E5DD` | `#1A1A19` or black |
24
+ | `--fig-paper` | figure background | `#FDFCF9` | `#1A1816` | `#FDFCF9` or white |
25
+ | `--fig-grid` | essential gridlines and secondary structure | `#8C8981` | `#746E67` | `#B5B3AA` or a mid-neutral |
26
+
27
+ `--fig-gold` resolves to a slightly darker gold in the light theme so an
28
+ essential stroke clears 3:1 against `--fig-paper`. The export rewrite still
29
+ recognizes the canonical `#C09840` authoring value.
30
+
31
+ Categorical plots use the stable Okabe–Ito order:
32
+
33
+ | Ordinal | Token | Canonical color |
34
+ |---:|---|---|
35
+ | 1 | `--series-1` | orange `#E69F00` |
36
+ | 2 | `--series-2` | sky blue `#56B4E9` |
37
+ | 3 | `--series-3` | bluish green `#009E73` |
38
+ | 4 | `--series-4` | yellow `#F0E442` |
39
+ | 5 | `--series-5` | blue `#0072B2` |
40
+ | 6 | `--series-6` | vermillion `#D55E00` |
41
+ | 7 | `--series-7` | reddish purple `#CC79A7` |
42
+ | 8 | `--series-8` | theme ink (canonical authoring color: black) |
43
+
44
+ Series 1–7 use the same canonical value in light and dark; each already clears
45
+ 3:1 against the dark figure paper. Series 8 changes with `--fig-ink`.
46
+
47
+ Assign series in that order and keep the ordinal attached to the same series
48
+ when a chart or theme changes. Never use `--series-*` to mean good, bad,
49
+ warning, or success; those meanings belong to `--fig-*`.
50
+
51
+ After PDF export, canonical series-8 black is indistinguishable from labels and
52
+ axes. The rewrite therefore maps it to structural ink; `--series-8` aliases the
53
+ same `--fig-ink` value, so it still renders correctly in both themes. Use
54
+ `var(--series-8)` directly in a CSS-native SVG if you need to override that
55
+ ordinal independently.
56
+
57
+ ## 2. CSS-native SVG: the preferred hand-authored form
58
+
59
+ Use variables directly. Pale fills should be computed from the current figure
60
+ paper, while their solid borders carry the category:
61
+
62
+ ```svg
63
+ <svg role="img" aria-labelledby="pipeline-title pipeline-desc"
64
+ viewBox="0 0 420 140" xmlns="http://www.w3.org/2000/svg">
65
+ <title id="pipeline-title">A three-stage publishing pipeline</title>
66
+ <desc id="pipeline-desc">
67
+ Draft flows to review and then publication. Review is the only caution stage.
68
+ </desc>
69
+ <style>
70
+ .label { fill: var(--fig-ink); font: 16px sans-serif; }
71
+ .edge { fill: none; stroke: var(--fig-ink); stroke-width: 2; }
72
+ .draft {
73
+ fill: color-mix(in srgb, var(--fig-blue) 14%, var(--fig-paper));
74
+ stroke: var(--fig-blue);
75
+ }
76
+ .review {
77
+ fill: color-mix(in srgb, var(--fig-rose) 14%, var(--fig-paper));
78
+ stroke: var(--fig-rose);
79
+ }
80
+ .published {
81
+ fill: color-mix(in srgb, var(--fig-green) 14%, var(--fig-paper));
82
+ stroke: var(--fig-green);
83
+ }
84
+ .node { stroke-width: 3; }
85
+ </style>
86
+ <!-- geometry omitted -->
87
+ </svg>
88
+ ```
89
+
90
+ When this is a local SVG rendered by `<Figure>`, the host page supplies the
91
+ variables and its light/dark toggle changes the figure immediately. An SVG
92
+ loaded from another origin cannot inherit host variables.
93
+
94
+ For a categorical chart, use `stroke: var(--series-1)`, then `--series-2`, and
95
+ so on. Give every line a second channel too: different dashes, markers, or a
96
+ direct text label.
97
+
98
+ ## 3. TikZ and matplotlib exports
99
+
100
+ PDF cannot carry CSS variables. `build:figures` therefore recognizes the exact
101
+ Warm–Tol and Okabe–Ito authoring colors in the generated SVG and maps them back
102
+ to variables. Unknown saturated colors stay exactly as authored.
103
+
104
+ The important rule is **base color plus separate opacity**. Do not use a
105
+ pre-blended low-chroma color such as `figblue!13`: after PDF export it is only
106
+ an anonymous near-white RGB and can be mistaken for figure paper.
107
+
108
+ TikZ:
109
+
110
+ ```latex
111
+ \definecolor{figblue}{HTML}{3B6FA0}
112
+ \definecolor{figrose}{HTML}{C06858}
113
+ \definecolor{seriesone}{HTML}{E69F00}
114
+
115
+ \node[
116
+ draw=figblue,
117
+ fill=figblue,
118
+ fill opacity=.14,
119
+ text opacity=1,
120
+ line width=1.2pt,
121
+ ] (draft) {Draft};
122
+ \node[draw=figrose, fill=figrose, fill opacity=.14, text opacity=1] (review) {Review};
123
+ \draw[seriesone, very thick, dashed] plot coordinates {(0,0) (1,2) (2,3)};
124
+ ```
125
+
126
+ Matplotlib:
127
+
128
+ ```python
129
+ import matplotlib.pyplot as plt
130
+
131
+ FIG = {
132
+ "blue": "#3B6FA0",
133
+ "rose": "#C06858",
134
+ "green": "#4A7E3F",
135
+ }
136
+ SERIES = [
137
+ "#E69F00", "#56B4E9", "#009E73", "#F0E442",
138
+ "#0072B2", "#D55E00", "#CC79A7", "#000000",
139
+ ]
140
+
141
+ fig, ax = plt.subplots()
142
+ # Keep the canvas transparent: the host's --fig-paper must show through after
143
+ # PDF → SVG conversion instead of baking a light-only rectangle into the plot.
144
+ fig.patch.set_alpha(0)
145
+ ax.set_facecolor("none")
146
+ ax.fill_between(x, low, high, color=FIG["blue"], alpha=.14)
147
+ ax.plot(x, mean, color=FIG["blue"], linewidth=2)
148
+ ax.plot(x, baseline, color=SERIES[0], linestyle="--", marker="o", label="Baseline")
149
+ fig.savefig(
150
+ "figures/results/summary.pdf",
151
+ format="pdf",
152
+ transparent=True,
153
+ bbox_inches="tight",
154
+ )
155
+ ```
156
+
157
+ Then run `npm run build:figures` to preserve the vector PDF→SVG path. Export a
158
+ raster (`.png`) only when the source is true pixel data, such as a photograph
159
+ or instrument raster. Lines, text, markers, and diagram geometry should remain
160
+ vector PDF/SVG. Re-run the command after upgrading the scaffold so
161
+ already-generated SVGs receive new mappings; the rewrite is idempotent.
162
+
163
+ ## 4. Accessible name and description
164
+
165
+ Use `<Figure>` as the accessibility boundary:
166
+
167
+ ```mdx
168
+ <Figure
169
+ src="/figures/results/summary.svg"
170
+ id="fig-results-summary"
171
+ caption="Review catches most defects before publication."
172
+ alt="Three-stage publishing pipeline"
173
+ desc="Draft flows right to review, then publication. The review node is marked as a caution stage, and the publication node is marked as a successful outcome."
174
+ />
175
+ ```
176
+
177
+ - `caption` states the point the reader should take from the figure.
178
+ - `alt` is a short accessible name, not a duplicate of every visible label.
179
+ - `desc` gives reading order, relationships, trend, and any conclusion carried
180
+ by position, shape, line style, or color.
181
+ - For a local pipeline SVG, `<Figure>` replaces stale `<title>`/`<desc>` nodes
182
+ with these props and wires `aria-labelledby` automatically.
183
+ - Raster and remote images remain `<img>` elements, so give them complete alt
184
+ text and put any longer explanation in adjacent prose.
185
+
186
+ Do not write “blue means success” as the only description. Name the category
187
+ and the redundant cue: “the successful outcome is the green node with a check
188
+ mark.”
189
+
190
+ ## 5. Contrast and color-vision rules
191
+
192
+ Okabe–Ito is color-vision-deficiency-friendly; that does not make every hue a
193
+ high-contrast thin line on light paper. In particular, orange, sky blue,
194
+ yellow, and reddish purple need redundant encoding.
195
+
196
+ - Never encode a distinction by color alone. Add marker shape, dash pattern,
197
+ direct labels, texture, or position.
198
+ - Use `--fig-ink` for axes, text, arrowheads, and essential outlines.
199
+ - Give pale semantic areas a solid `--fig-*` border. The semantic strokes and
200
+ `--fig-grid` clear WCAG's 3:1 non-text contrast threshold in both themes.
201
+ - Outline small categorical marks with `--fig-ink`; use thicker/dashed lines
202
+ and markers for categorical series that do not clear 3:1 by color alone.
203
+ - Do not reorder `--series-*` in dark mode. `--series-8` follows theme ink so
204
+ the neutral series remains visible rather than staying literal black.
205
+
206
+ ## 6. Dual-theme release gate
207
+
208
+ Before publishing a new figure:
209
+
210
+ 1. Run `npm run build:figures` and confirm the SVG contains one
211
+ `data-diagram-map` style block.
212
+ 2. Render it through `<Figure>` with explicit light and dark page themes.
213
+ 3. Open the standalone SVG with both OS color schemes; its embedded defaults
214
+ should also switch.
215
+ 4. Check labels, axes, arrowheads, boundaries, and all series at the smallest
216
+ published size.
217
+ 5. Inspect a grayscale or common CVD simulation and verify that markers,
218
+ dashes, labels, or shapes preserve every distinction.
219
+ 6. Confirm the caption and screen-reader description communicate the takeaway
220
+ without relying on color names.
221
+
222
+ If automatic recoloring is genuinely wrong for a special TikZ figure, add a
223
+ `%! no-theme` line to its `.tex` source and supply a fully authored light/dark
224
+ solution yourself. That escape hatch opts out of both palette and neutral
225
+ mapping.
226
+
227
+ ## Canonical files
228
+
229
+ - `src/lib/figure-palette.mjs` — sole palette manifest + generated CSS/theme renderers
230
+ - `styles/tokens.css` — public `--fig-*`, `--series-*`, and compatibility tokens
231
+ - `src/lib/figure.mjs` — exact palette recognition and SVG theme injection
232
+ - `scripts/sync-figure-tokens.mjs` — checks or refreshes the generated token block
233
+ - `scripts/build-figures.mjs` — PDF/TikZ conversion pipeline
234
+ - `components/Figure.astro` — inlining plus accessible `<title>`/`<desc>`
235
+ - `tests/figure-palette.test.mjs` — token, mapping, theme, and contrast contract
236
+
237
+ ## See also
238
+
239
+ - [Recipe 03 — Asset pipelines](03-asset-pipelines.md)
240
+ - [Recipe 04 — Component library](04-component-library.md)
241
+ - [Recipe 16 — TikZ figures](16-tikz-figures.md)
package/recipes/README.md CHANGED
@@ -29,6 +29,7 @@ Terse pointers into canonical code for the most common book-authoring workflows.
29
29
  | 21 | [Multiple guides in one app](21-multi-guide-single-app.md) | any | `generateId` namespacing over one collection; flat-slug footgun; interim until multibook (#80) |
30
30
  | 22 | [Responsive navigation and multi-book routing](22-responsive-nav-and-multibook-routing.md) | any | Mobile/desktop nav, base-aware route ownership, and the interim multi-book routing contract |
31
31
  | 23 | [Interactive demo substrate](23-interactive-demo-substrate.md) | any | Opt-in Preact shell, slider, stat cards, theme colors, a11y, and reduced motion |
32
+ | 24 | [Figure authoring standard](24-figure-authoring-standard.md) | any | Warm–Tol semantics, Okabe–Ito series, dual-theme exports, contrast, and accessible descriptions |
32
33
 
33
34
  ## How to read recipes
34
35
 
@@ -25,21 +25,22 @@
25
25
  *
26
26
  * v4.11.0 (closes #84): each generated SVG gets a post-export rewrite
27
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.
28
+ * SVG serves light + dark. Known Warm–Tol semantic and Okabe–Ito categorical
29
+ * colors are mapped to var(--fig-*|--series-*, <original>); neutral paints map
30
+ * to var(--diagram-ink|paper|grid, <original>). Unknown saturated colors stay
31
+ * authored. A `%! no-theme` line in the source .tex opts a figure out.
32
32
  * <Figure> inlines local SVGs so they track the in-page [data-theme] toggle.
33
33
  *
34
34
  * Idempotent: skips when the target SVG is newer than the source PDF (and the
35
35
  * recolor itself is a no-op on an already-themed SVG, so re-runs after an
36
36
  * upgrade safely theme pre-existing figures).
37
- * Run on `prebuild` so Astro always sees fresh figures.
37
+ * Run explicitly with `npm run build:figures`; optional system tools keep this
38
+ * authoring pipeline out of the generated project's `prebuild` hook.
38
39
  *
39
40
  * Graceful skip: when pdftocairo / pdftoppm aren't on PATH (e.g. Cloudflare
40
41
  * build container), the script warns and exits 0. Committed SVGs/PNGs under
41
- * public/figures/ are served as-is. Local devs with poppler-utils regenerate
42
- * from PDFs on every `npm run dev`.
42
+ * public/figures/ are served as-is. Local authors with poppler-utils regenerate
43
+ * from PDFs when source figures change.
43
44
  */
44
45
  import { readdir, stat, mkdir } from 'node:fs/promises';
45
46
  import { existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
@@ -56,8 +57,10 @@ Figure pipeline. PDF -> SVG via pdftocairo (PNG fallback via pdftoppm at
56
57
  Graceful-skip if pdftocairo / pdftoppm not on PATH.
57
58
 
58
59
  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.
60
+ exact Warm–Tol/Okabe–Ito palette mappings and neutral
61
+ var(--diagram-ink|paper|grid, orig) mappings. Use base color + separate opacity,
62
+ not a pre-blended tint. A "%! no-theme" line in the source .tex opts out.
63
+ <Figure> inlines local SVGs so they track the theme.
61
64
 
62
65
  Env:
63
66
  BOOK_FIGURES_PATH Override figures source (default: figures/).
@@ -202,7 +205,7 @@ const NO_THEME_RE = /^\s*%!\s*no-theme\b/m;
202
205
  /**
203
206
  * v4.11.0 (#84): apply the theming rewrite to a generated SVG in place.
204
207
  * No-op (returns false) if the file is absent or recolorSvg leaves it unchanged
205
- * (already themed / nothing neutral to remap). Idempotent.
208
+ * (already themed / nothing themeable to remap). Idempotent.
206
209
  */
207
210
  function themeIfSvg(svgPath, optOut) {
208
211
  if (!existsSync(svgPath)) return false;
@@ -16,7 +16,8 @@
16
16
  * canonical; the notebook is a code companion.
17
17
  *
18
18
  * Idempotent: skips when the target HTML is newer than the source .ipynb.
19
- * Run on `prebuild` so Astro always sees fresh notebook HTML.
19
+ * Run explicitly with `npm run build:notebooks`; optional system tools keep
20
+ * this authoring pipeline out of the generated project's `prebuild` hook.
20
21
  *
21
22
  * Style scoping: nbconvert's `basic` template emits HTML without nbviewer
22
23
  * chrome, but with embedded <style> tags. To prevent CSS bleed, each
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ /** Regenerate or verify the manifest-owned block in styles/tokens.css. */
3
+ import { readFileSync, writeFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ FIGURE_TOKEN_BLOCK_END,
7
+ FIGURE_TOKEN_BLOCK_START,
8
+ renderFigureTokenCssBlock,
9
+ } from '../src/lib/figure-palette.mjs';
10
+
11
+ const TOKENS_PATH = fileURLToPath(new URL('../styles/tokens.css', import.meta.url));
12
+ const args = new Set(process.argv.slice(2));
13
+ const write = args.has('--write');
14
+ if (args.size > 1 || (args.size === 1 && !write && !args.has('--check'))) {
15
+ console.error('Usage: node scripts/sync-figure-tokens.mjs [--check|--write]');
16
+ process.exit(2);
17
+ }
18
+
19
+ const css = readFileSync(TOKENS_PATH, 'utf8');
20
+ const start = css.indexOf(FIGURE_TOKEN_BLOCK_START);
21
+ const endStart = css.indexOf(FIGURE_TOKEN_BLOCK_END, start);
22
+ if (start < 0 || endStart < 0) {
23
+ throw new Error('tokens.css is missing the generated figure-token block markers.');
24
+ }
25
+ const end = endStart + FIGURE_TOKEN_BLOCK_END.length;
26
+ const expected = renderFigureTokenCssBlock();
27
+ const actual = css.slice(start, end);
28
+
29
+ if (write) {
30
+ if (actual === expected) {
31
+ console.log('sync-figure-tokens: tokens.css already current');
32
+ } else {
33
+ writeFileSync(TOKENS_PATH, css.slice(0, start) + expected + css.slice(end), 'utf8');
34
+ console.log('sync-figure-tokens: updated styles/tokens.css');
35
+ }
36
+ } else if (actual !== expected) {
37
+ console.error(
38
+ 'sync-figure-tokens: styles/tokens.css drifted from src/lib/figure-palette.mjs; ' +
39
+ 'run npm run sync:figure-tokens --workspace package',
40
+ );
41
+ process.exit(1);
42
+ } else {
43
+ console.log('sync-figure-tokens: tokens.css matches the palette manifest');
44
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Canonical figure palette contract (#161, #164).
3
+ *
4
+ * This pure manifest owns every authored RGB, host token value, and standalone
5
+ * SVG value. `figure.mjs` derives its exact SVG mappings and embedded theme CSS
6
+ * from it; `tokens.css` carries a checked generated block rendered below.
7
+ * Keep semantic Warm–Tol roles separate from ordinal Okabe–Ito series.
8
+ */
9
+
10
+ function frozenEntries(entries) {
11
+ return Object.freeze(entries.map((entry) => Object.freeze(entry)));
12
+ }
13
+
14
+ export const SEMANTIC_FIGURE_TOKENS = frozenEntries([
15
+ {
16
+ family: 'semantic', name: 'blue', token: '--fig-blue', label: 'default / lightweight',
17
+ authoring: '#3B6FA0', hostLight: '#3B6FA0', standaloneLight: '#3B6FA0',
18
+ hostDark: '#7297BB', standaloneDark: '#7297BB',
19
+ },
20
+ {
21
+ family: 'semantic', name: 'rose', token: '--fig-rose', label: 'caution / problem',
22
+ authoring: '#C06858', hostLight: '#C06858', standaloneLight: '#C06858',
23
+ hostDark: '#D29287', standaloneDark: '#D29287',
24
+ },
25
+ {
26
+ family: 'semantic', name: 'green', token: '--fig-green', label: 'positive outcome',
27
+ authoring: '#4A7E3F', hostLight: '#4A7E3F', standaloneLight: '#4A7E3F',
28
+ hostDark: '#7DA275', standaloneDark: '#7DA275',
29
+ },
30
+ {
31
+ family: 'semantic', name: 'plum', token: '--fig-plum', label: 'authority / heaviest',
32
+ authoring: '#8A4E82', hostLight: '#8A4E82', standaloneLight: '#8A4E82',
33
+ hostDark: '#AB80A5', standaloneDark: '#AB80A5',
34
+ },
35
+ {
36
+ family: 'semantic', name: 'gold', token: '--fig-gold', label: 'packaging / coordination',
37
+ // Export the canonical Warm–Tol gold; the light host value is deliberately
38
+ // darker so an essential stroke clears 3:1 against figure paper.
39
+ authoring: '#C09840', hostLight: '#9D7D34', standaloneLight: '#9D7D34',
40
+ hostDark: '#D2B575', standaloneDark: '#D2B575',
41
+ },
42
+ {
43
+ family: 'semantic', name: 'crimson', token: '--fig-crimson', label: 'failure / severe problem',
44
+ authoring: '#A03838', hostLight: '#A03838', standaloneLight: '#A03838',
45
+ hostDark: '#BB7070', standaloneDark: '#BB7070',
46
+ },
47
+ ]);
48
+
49
+ export const STRUCTURAL_FIGURE_TOKENS = frozenEntries([
50
+ {
51
+ family: 'structural', name: 'ink', token: '--fig-ink', label: 'labels / axes / outlines',
52
+ hostLight: 'var(--color-text)', standaloneLight: '#1A1A19',
53
+ hostDark: 'var(--color-text)', standaloneDark: '#E8E5DD',
54
+ },
55
+ {
56
+ family: 'structural', name: 'paper', token: '--fig-paper', label: 'figure background',
57
+ hostLight: 'var(--color-bg)', standaloneLight: '#FDFCF9',
58
+ hostDark: 'var(--color-bg)', standaloneDark: '#1A1816',
59
+ },
60
+ {
61
+ family: 'structural', name: 'grid', token: '--fig-grid', label: 'essential secondary structure',
62
+ hostLight: '#8C8981', standaloneLight: '#8C8981',
63
+ hostDark: '#746E67', standaloneDark: '#746E67',
64
+ },
65
+ ]);
66
+
67
+ export const SERIES_FIGURE_TOKENS = frozenEntries([
68
+ { family: 'series', ordinal: 1, name: 'orange', token: '--series-1', authoring: '#E69F00', hostLight: '#E69F00', standaloneLight: '#E69F00', hostDark: '#E69F00', standaloneDark: '#E69F00' },
69
+ { family: 'series', ordinal: 2, name: 'sky blue', token: '--series-2', authoring: '#56B4E9', hostLight: '#56B4E9', standaloneLight: '#56B4E9', hostDark: '#56B4E9', standaloneDark: '#56B4E9' },
70
+ { family: 'series', ordinal: 3, name: 'bluish green', token: '--series-3', authoring: '#009E73', hostLight: '#009E73', standaloneLight: '#009E73', hostDark: '#009E73', standaloneDark: '#009E73' },
71
+ { family: 'series', ordinal: 4, name: 'yellow', token: '--series-4', authoring: '#F0E442', hostLight: '#F0E442', standaloneLight: '#F0E442', hostDark: '#F0E442', standaloneDark: '#F0E442' },
72
+ { family: 'series', ordinal: 5, name: 'blue', token: '--series-5', authoring: '#0072B2', hostLight: '#0072B2', standaloneLight: '#0072B2', hostDark: '#0072B2', standaloneDark: '#0072B2' },
73
+ { family: 'series', ordinal: 6, name: 'vermillion', token: '--series-6', authoring: '#D55E00', hostLight: '#D55E00', standaloneLight: '#D55E00', hostDark: '#D55E00', standaloneDark: '#D55E00' },
74
+ { family: 'series', ordinal: 7, name: 'reddish purple', token: '--series-7', authoring: '#CC79A7', hostLight: '#CC79A7', standaloneLight: '#CC79A7', hostDark: '#CC79A7', standaloneDark: '#CC79A7' },
75
+ {
76
+ family: 'series', ordinal: 8, name: 'neutral ink', token: '--series-8',
77
+ authoring: '#000000', hostLight: 'var(--fig-ink)', standaloneLight: 'var(--fig-ink)',
78
+ hostDark: 'var(--fig-ink)', standaloneDark: 'var(--fig-ink)',
79
+ // PDF export loses the distinction between categorical black and labels.
80
+ // Both aliases resolve through --fig-ink, so the rendered value agrees.
81
+ svgVariable: '--diagram-ink',
82
+ },
83
+ ]);
84
+
85
+ export const FIGURE_TOKEN_DEFINITIONS = Object.freeze([
86
+ ...SEMANTIC_FIGURE_TOKENS,
87
+ ...STRUCTURAL_FIGURE_TOKENS,
88
+ ...SERIES_FIGURE_TOKENS,
89
+ ]);
90
+
91
+ export const FIGURE_COMPATIBILITY_ALIASES = frozenEntries([
92
+ { token: '--diagram-ink', value: 'var(--fig-ink)' },
93
+ { token: '--diagram-paper', value: 'var(--fig-paper)' },
94
+ { token: '--diagram-grid', value: 'var(--fig-grid)' },
95
+ ]);
96
+
97
+ export const FIGURE_AUTHORING_COLOR_MAP = Object.freeze([
98
+ ...SEMANTIC_FIGURE_TOKENS,
99
+ ...SERIES_FIGURE_TOKENS,
100
+ ].map((entry) => Object.freeze({
101
+ family: entry.family,
102
+ authoring: entry.authoring,
103
+ token: entry.token,
104
+ variable: entry.svgVariable ?? entry.token,
105
+ })));
106
+
107
+ export const FIGURE_TOKEN_BLOCK_START = '/* BEGIN GENERATED FIGURE TOKENS — source: src/lib/figure-palette.mjs */';
108
+ export const FIGURE_TOKEN_BLOCK_END = '/* END GENERATED FIGURE TOKENS */';
109
+
110
+ function declarations(entries, key, { comments = false } = {}) {
111
+ return entries.map((entry) => {
112
+ const comment = comments ? ` /* ${entry.label ?? entry.name} */` : '';
113
+ return ` ${entry.token}: ${entry[key]};${comment}`;
114
+ });
115
+ }
116
+
117
+ /** Render the checked block committed in styles/tokens.css. */
118
+ export function renderFigureTokenCssBlock() {
119
+ const light = [
120
+ ...declarations(SEMANTIC_FIGURE_TOKENS, 'hostLight', { comments: true }),
121
+ ...declarations(STRUCTURAL_FIGURE_TOKENS, 'hostLight', { comments: true }),
122
+ '',
123
+ ' /* Canonical Okabe–Ito order. Never reorder ordinals per chart or theme. */',
124
+ ...declarations(SERIES_FIGURE_TOKENS, 'hostLight', { comments: true }),
125
+ '',
126
+ ' /* Backward-compatible aliases used by build-figures since v4.11.0. */',
127
+ ...FIGURE_COMPATIBILITY_ALIASES.map((entry) => ` ${entry.token}: ${entry.value};`),
128
+ ];
129
+ const dark = declarations(FIGURE_TOKEN_DEFINITIONS, 'hostDark');
130
+
131
+ return [
132
+ FIGURE_TOKEN_BLOCK_START,
133
+ '/* Do not edit this block by hand. Semantic Warm–Tol roles and ordinal',
134
+ ' * Okabe–Ito series are intentionally separate public contracts. */',
135
+ ':root {',
136
+ ...light,
137
+ '}',
138
+ '',
139
+ '@media (prefers-color-scheme: dark) {',
140
+ ' :root:not([data-theme="light"]) {',
141
+ ...dark.map((line) => ` ${line}`),
142
+ ' }',
143
+ '}',
144
+ '',
145
+ ':root[data-theme="dark"] {',
146
+ ...dark,
147
+ '}',
148
+ FIGURE_TOKEN_BLOCK_END,
149
+ ].join('\n');
150
+ }
151
+
152
+ /** Render minified defaults embedded into standalone generated SVGs. */
153
+ export function renderStandaloneFigureThemeCss() {
154
+ const themed = (key) => FIGURE_TOKEN_DEFINITIONS
155
+ .map((entry) => `${entry.token}:${entry[key]}`)
156
+ .join(';');
157
+ const aliases = FIGURE_COMPATIBILITY_ALIASES
158
+ .map((entry) => `${entry.token}:${entry.value}`)
159
+ .join(';');
160
+ return `:root{${themed('standaloneLight')};${aliases}}` +
161
+ `@media (prefers-color-scheme:dark){:root{${themed('standaloneDark')}}}`;
162
+ }
@@ -11,7 +11,7 @@
11
11
  * Why a stylesheet, not presentation attributes: `var()` inside a presentation
12
12
  * attribute (`fill="var(--x, …)"`) has unreliable browser support, whereas
13
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
14
+ * attribute-selector rule per distinct themeable color and never mutates a
15
15
  * drawing element — the untouched `fill=""`/`stroke=""` attribute is the
16
16
  * automatic fallback where `var()` is unsupported.
17
17
  *
@@ -20,19 +20,22 @@
20
20
  * prefers-color-scheme via the embedded <style data-diagram-theme>. Inlining
21
21
  * puts the SVG in the host DOM, so the host's tokens.css (which tracks the
22
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-*.
23
+ * embedded theme block so the host is the sole source of --fig-*, --series-*,
24
+ * and the backward-compatible --diagram-* aliases.
24
25
  *
25
26
  * Pure string ops only — no `node:` imports — so the module bundles for any
26
27
  * consumer (Figure.astro imports it; fs lives in the .astro/.mjs callers).
27
28
  */
28
29
 
30
+ import {
31
+ FIGURE_AUTHORING_COLOR_MAP,
32
+ renderStandaloneFigureThemeCss,
33
+ } from './figure-palette.mjs';
34
+
29
35
  // Standalone self-theming defaults: used only when the SVG is NOT inlined
30
36
  // (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}}';
37
+ // there. Generated from the same pure manifest as the host tokens.css block.
38
+ export const DIAGRAM_THEME_CSS = renderStandaloneFigureThemeCss();
36
39
 
37
40
  const DIAGRAM_VAR = {
38
41
  ink: '--diagram-ink',
@@ -40,6 +43,24 @@ const DIAGRAM_VAR = {
40
43
  grid: '--diagram-grid',
41
44
  };
42
45
 
46
+ // Exact canonical authoring colors. Match by parsed RGB rather than source
47
+ // spelling so #3B6FA0, rgb(59,111,160), and Poppler's rgb(23.137%,…) all
48
+ // resolve to one semantic variable. Palette recognition MUST run before the
49
+ // neutral heuristic: base hue + fill-opacity remains themeable, whereas a
50
+ // pre-blended pale tint can otherwise look neutral and collapse to paper.
51
+ function rgbKeyFromHex(hex) {
52
+ return [1, 3, 5]
53
+ .map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16))
54
+ .join(',');
55
+ }
56
+
57
+ const PALETTE_VAR_BY_RGB = new Map(
58
+ FIGURE_AUTHORING_COLOR_MAP.map(({ authoring, variable }) => [
59
+ rgbKeyFromHex(authoring),
60
+ variable,
61
+ ]),
62
+ );
63
+
43
64
  /**
44
65
  * Parse a solid SVG paint into {r,g,b} in [0,1], or null when it is not a
45
66
  * concrete color we should remap (none / url(...) / currentColor / inherit).
@@ -86,11 +107,32 @@ export function classifyColor(value) {
86
107
  return 'grid';
87
108
  }
88
109
 
110
+ /**
111
+ * Return the CSS custom property for a known Warm–Tol / Okabe–Ito color, or
112
+ * for a neutral paint classified as ink/paper/grid. Unknown saturated colors
113
+ * return null and stay exactly as authored.
114
+ */
115
+ export function figureColorVariable(value) {
116
+ const c = parseColor(value);
117
+ if (c && [c.r, c.g, c.b].every((channel) => channel >= 0 && channel <= 1)) {
118
+ const key = [c.r, c.g, c.b].map((channel) => Math.round(channel * 255)).join(',');
119
+ const paletteVar = PALETTE_VAR_BY_RGB.get(key);
120
+ if (paletteVar) return paletteVar;
121
+ }
122
+
123
+ const cls = classifyColor(value);
124
+ return cls ? DIAGRAM_VAR[cls] : null;
125
+ }
126
+
89
127
  // Escape a value for use inside a CSS [attr="…"] selector string.
90
128
  function cssAttrEscape(s) {
91
129
  return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
92
130
  }
93
131
 
132
+ const THEME_BLOCK_RE = /<style\b[^>]*\bdata-diagram-theme\b[^>]*>[\s\S]*?<\/style>/gi;
133
+ const MAP_BLOCK_RE = /<style\b[^>]*\bdata-diagram-map\b[^>]*>[\s\S]*?<\/style>/gi;
134
+ const CURRENT_MAP_RE = /\bdata-diagram-map=(?:"2"|'2')/i;
135
+
94
136
  /**
95
137
  * Rewrite a pdftocairo SVG to be theme-aware + carry role="img". Pure and
96
138
  * idempotent (a second pass is a no-op). `opts.optOut` short-circuits, returning
@@ -98,55 +140,63 @@ function cssAttrEscape(s) {
98
140
  *
99
141
  * Injects, right after the opening <svg> tag:
100
142
  * <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.
143
+ * <style data-diagram-map="2"> — `[fill="C"]{fill:var(--fig-X, C)}` … per
144
+ * distinct known-palette or neutral color C.
145
+ * Elements are NOT modified; the attribute
146
+ * stays as fallback.
104
147
  */
105
148
  export function recolorSvg(svg, { optOut = false } = {}) {
106
149
  if (typeof svg !== 'string') return svg;
107
- if (optOut || svg.includes('data-diagram-map')) return svg;
150
+ if (optOut || CURRENT_MAP_RE.test(svg)) return svg;
151
+ if (!/<svg\b[^>]*>/i.test(svg)) return svg;
108
152
 
109
- const openMatch = svg.match(/<svg\b[^>]*>/i);
110
- if (!openMatch) return svg;
153
+ // A pre-v2 map contains only neutral roles. Remove its generated blocks and
154
+ // rebuild from the untouched presentation attributes so cached SVGs gain
155
+ // palette mappings after an upgrade without needing a source-file touch.
156
+ const cleanSvg = svg.includes('data-diagram-map')
157
+ ? svg.replace(THEME_BLOCK_RE, '').replace(MAP_BLOCK_RE, '')
158
+ : svg;
159
+
160
+ const openMatch = cleanSvg.match(/<svg\b[^>]*>/i);
161
+ if (!openMatch) return svg; // defensive: generated-block cleanup never removes <svg>
111
162
 
112
163
  // Distinct concrete colors used as fill="" / stroke="" attributes.
113
- const found = new Map(); // original string → class
164
+ const found = new Map(); // original string → CSS custom property
114
165
  const attrRe = /\b(?:fill|stroke)="([^"]+)"/g;
115
166
  let am;
116
- while ((am = attrRe.exec(svg)) !== null) {
167
+ while ((am = attrRe.exec(cleanSvg)) !== null) {
117
168
  if (found.has(am[1])) continue;
118
- const cls = classifyColor(am[1]);
119
- if (cls) found.set(am[1], cls);
169
+ const variable = figureColorVariable(am[1]);
170
+ if (variable) found.set(am[1], variable);
120
171
  }
121
172
 
122
173
  let openTag = openMatch[0];
123
174
  if (!/\srole=/i.test(openTag)) openTag = openTag.replace(/<svg\b/i, '<svg role="img"');
124
175
 
125
- // Nothing neutral to remap — still surface role="img" for a11y, no <style>.
176
+ // Nothing themeable to remap — still surface role="img" for a11y, no <style>.
126
177
  if (found.size === 0) {
127
178
  return openTag === openMatch[0]
128
- ? svg
129
- : svg.slice(0, openMatch.index) + openTag + svg.slice(openMatch.index + openMatch[0].length);
179
+ ? cleanSvg
180
+ : cleanSvg.slice(0, openMatch.index) + openTag + cleanSvg.slice(openMatch.index + openMatch[0].length);
130
181
  }
131
182
 
132
183
  let mapCss = '';
133
- for (const [orig, cls] of found) {
134
- const v = DIAGRAM_VAR[cls];
184
+ for (const [orig, variable] of found) {
135
185
  const sel = cssAttrEscape(orig);
136
186
  mapCss +=
137
- `[fill="${sel}"]{fill:var(${v}, ${orig})}` +
138
- `[stroke="${sel}"]{stroke:var(${v}, ${orig})}`;
187
+ `[fill="${sel}"]{fill:var(${variable}, ${orig})}` +
188
+ `[stroke="${sel}"]{stroke:var(${variable}, ${orig})}`;
139
189
  }
140
190
 
141
191
  const styleBlocks =
142
192
  `<style data-diagram-theme>${DIAGRAM_THEME_CSS}</style>` +
143
- `<style data-diagram-map>${mapCss}</style>`;
193
+ `<style data-diagram-map="2">${mapCss}</style>`;
144
194
 
145
195
  return (
146
- svg.slice(0, openMatch.index) +
196
+ cleanSvg.slice(0, openMatch.index) +
147
197
  openTag +
148
198
  styleBlocks +
149
- svg.slice(openMatch.index + openMatch[0].length)
199
+ cleanSvg.slice(openMatch.index + openMatch[0].length)
150
200
  );
151
201
  }
152
202
 
@@ -162,10 +212,36 @@ export function shouldInline(src) {
162
212
  );
163
213
  }
164
214
 
165
- const THEME_BLOCK_RE = /<style\b[^>]*\bdata-diagram-theme\b[^>]*>[\s\S]*?<\/style>/gi;
215
+ /**
216
+ * Convert a local SVG URL into a path relative to the consumer's `public/`
217
+ * directory. Astro prefixes dynamic asset URLs with BASE_URL on non-root
218
+ * deployments, but that prefix is a URL concern and is not present on disk.
219
+ * Literal root URLs remain supported so this helper is backward-compatible.
220
+ *
221
+ * Reject dot segments and backslashes before the filesystem boundary. Figure
222
+ * sources are author-controlled, but an inline helper should never make an
223
+ * out-of-tree file available to `set:html` accidentally.
224
+ */
225
+ export function publicSvgPath(src, base = '/') {
226
+ if (!shouldInline(src) || src.includes('\\')) return null;
227
+
228
+ const baseSegments = typeof base === 'string'
229
+ ? base.split('/').filter(Boolean)
230
+ : [];
231
+ const normalizedBase = baseSegments.length ? `/${baseSegments.join('/')}/` : '/';
232
+ const withoutBase = normalizedBase !== '/' && src.startsWith(normalizedBase)
233
+ ? src.slice(normalizedBase.length)
234
+ : src.replace(/^\/+/, '');
235
+ const segments = withoutBase.split('/');
236
+
237
+ if (!segments.length || segments.some((segment) => !segment || segment === '.' || segment === '..')) {
238
+ return null;
239
+ }
240
+ return segments.join('/');
241
+ }
166
242
 
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. */
243
+ /** Remove the standalone self-theming block so host tokens.css is the sole
244
+ * source of figure variables once the SVG is inlined into the page. */
169
245
  export function stripThemeBlock(svg) {
170
246
  return typeof svg === 'string' ? svg.replace(THEME_BLOCK_RE, '') : svg;
171
247
  }
@@ -204,7 +280,7 @@ function mergeSvgStyle(openTag, css) {
204
280
  * Prepare a raw pipeline SVG for inline embedding in <Figure>:
205
281
  * - strip the standalone <style data-diagram-theme> (host tokens.css themes it);
206
282
  * - replace any pre-existing <title>/<desc> with ones from the call-site props
207
- * (caption → <title>, desc ?? alt → <desc>) and wire aria-labelledby;
283
+ * (alt ?? caption → <title>, desc → <desc>) and wire aria-labelledby;
208
284
  * - ensure role="img" and a responsive width on the root <svg>.
209
285
  * Pure string transform; output is a trusted local build artifact (set:html).
210
286
  */
@@ -222,23 +298,23 @@ export function assembleSvg(raw, opts = {}) {
222
298
  .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, '')
223
299
  .replace(/<desc\b[^>]*>[\s\S]*?<\/desc>/gi, '');
224
300
 
225
- const titleText = caption ?? alt ?? '';
226
- const descText = desc ?? (alt && alt !== titleText ? alt : '');
301
+ // Match the raster fallback: `alt` is the short accessible name. A caption
302
+ // is only its fallback, while `desc` remains the optional long description.
303
+ const titleText = alt ?? caption ?? '';
304
+ const descText = desc ?? '';
227
305
  const id = String(idBase).replace(/[^a-zA-Z0-9_-]/g, '-');
228
306
 
229
307
  const a11y = [];
230
- const labelledby = [];
231
308
  if (titleText) {
232
309
  a11y.push(`<title id="${id}-title">${escapeXml(titleText)}</title>`);
233
- labelledby.push(`${id}-title`);
234
310
  }
235
311
  if (descText) {
236
312
  a11y.push(`<desc id="${id}-desc">${escapeXml(descText)}</desc>`);
237
- labelledby.push(`${id}-desc`);
238
313
  }
239
314
 
240
315
  openTag = ensureSvgAttr(openTag, 'role', 'img');
241
- if (labelledby.length) openTag = setSvgAttr(openTag, 'aria-labelledby', labelledby.join(' '));
316
+ if (titleText) openTag = setSvgAttr(openTag, 'aria-labelledby', `${id}-title`);
317
+ if (descText) openTag = setSvgAttr(openTag, 'aria-describedby', `${id}-desc`);
242
318
  openTag = mergeSvgStyle(openTag, `width:${width};max-width:100%;height:auto`);
243
319
 
244
320
  return `${svg.slice(0, openMatch.index)}${openTag}${a11y.join('')}${body}`;
package/styles/tokens.css CHANGED
@@ -54,15 +54,6 @@
54
54
  --callout-learn: var(--warm-gold);
55
55
  --callout-diagnostic: color-mix(in srgb, var(--warm-blue) 55%, var(--warm-green)); /* #110 DIKTA self-check — teal blend, distinct from plum Practice */
56
56
 
57
- /* Diagram semantic roles (v4.11.0, #84): theme-aware TikZ→SVG figures.
58
- * build-figures remaps an SVG's neutral fills/strokes to these via
59
- * var(--diagram-*, <original>); <Figure> inlines the SVG so this cascade
60
- * reaches it. They point at existing roles, so they auto-flip in dark mode
61
- * (no dark-block edits) — ink↔text, paper↔page bg, grid↔border. */
62
- --diagram-ink: var(--color-text);
63
- --diagram-paper: var(--color-bg);
64
- --diagram-grid: var(--color-border);
65
-
66
57
  /* ===== Typography scale ===== */
67
58
  --font-body: 'Roboto Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
68
59
  --font-code: 'Source Code Pro Variable', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
@@ -212,3 +203,76 @@
212
203
  --astro-code-token-parameter: color-mix(in srgb, var(--warm-blue) 55%, white);
213
204
  --astro-code-token-function: color-mix(in srgb, var(--warm-gold) 65%, white);
214
205
  }
206
+
207
+ /* BEGIN GENERATED FIGURE TOKENS — source: src/lib/figure-palette.mjs */
208
+ /* Do not edit this block by hand. Semantic Warm–Tol roles and ordinal
209
+ * Okabe–Ito series are intentionally separate public contracts. */
210
+ :root {
211
+ --fig-blue: #3B6FA0; /* default / lightweight */
212
+ --fig-rose: #C06858; /* caution / problem */
213
+ --fig-green: #4A7E3F; /* positive outcome */
214
+ --fig-plum: #8A4E82; /* authority / heaviest */
215
+ --fig-gold: #9D7D34; /* packaging / coordination */
216
+ --fig-crimson: #A03838; /* failure / severe problem */
217
+ --fig-ink: var(--color-text); /* labels / axes / outlines */
218
+ --fig-paper: var(--color-bg); /* figure background */
219
+ --fig-grid: #8C8981; /* essential secondary structure */
220
+
221
+ /* Canonical Okabe–Ito order. Never reorder ordinals per chart or theme. */
222
+ --series-1: #E69F00; /* orange */
223
+ --series-2: #56B4E9; /* sky blue */
224
+ --series-3: #009E73; /* bluish green */
225
+ --series-4: #F0E442; /* yellow */
226
+ --series-5: #0072B2; /* blue */
227
+ --series-6: #D55E00; /* vermillion */
228
+ --series-7: #CC79A7; /* reddish purple */
229
+ --series-8: var(--fig-ink); /* neutral ink */
230
+
231
+ /* Backward-compatible aliases used by build-figures since v4.11.0. */
232
+ --diagram-ink: var(--fig-ink);
233
+ --diagram-paper: var(--fig-paper);
234
+ --diagram-grid: var(--fig-grid);
235
+ }
236
+
237
+ @media (prefers-color-scheme: dark) {
238
+ :root:not([data-theme="light"]) {
239
+ --fig-blue: #7297BB;
240
+ --fig-rose: #D29287;
241
+ --fig-green: #7DA275;
242
+ --fig-plum: #AB80A5;
243
+ --fig-gold: #D2B575;
244
+ --fig-crimson: #BB7070;
245
+ --fig-ink: var(--color-text);
246
+ --fig-paper: var(--color-bg);
247
+ --fig-grid: #746E67;
248
+ --series-1: #E69F00;
249
+ --series-2: #56B4E9;
250
+ --series-3: #009E73;
251
+ --series-4: #F0E442;
252
+ --series-5: #0072B2;
253
+ --series-6: #D55E00;
254
+ --series-7: #CC79A7;
255
+ --series-8: var(--fig-ink);
256
+ }
257
+ }
258
+
259
+ :root[data-theme="dark"] {
260
+ --fig-blue: #7297BB;
261
+ --fig-rose: #D29287;
262
+ --fig-green: #7DA275;
263
+ --fig-plum: #AB80A5;
264
+ --fig-gold: #D2B575;
265
+ --fig-crimson: #BB7070;
266
+ --fig-ink: var(--color-text);
267
+ --fig-paper: var(--color-bg);
268
+ --fig-grid: #746E67;
269
+ --series-1: #E69F00;
270
+ --series-2: #56B4E9;
271
+ --series-3: #009E73;
272
+ --series-4: #F0E442;
273
+ --series-5: #0072B2;
274
+ --series-6: #D55E00;
275
+ --series-7: #CC79A7;
276
+ --series-8: var(--fig-ink);
277
+ }
278
+ /* END GENERATED FIGURE TOKENS */