@brandon_m_behring/book-scaffold-astro 4.29.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
@@ -201,6 +201,7 @@ the full precedence and customization contract.
201
201
  - Literal `<Theorem n>` values that disagree with `labels.json` (dynamic expressions and `label=` overrides are skipped)
202
202
  - Missing `<Figure src>` files under `public/`
203
203
  - Internal markdown links that don't resolve
204
+ - Authored root-absolute Markdown/HTML/JSX `href` or `src` targets that escape a configured non-root Astro `base` (#190). Structurally parsed and decoded literal targets fail with file/line context; root-base books, external/protocol-relative URLs, fragments, dynamic JSX expressions, and already base-prefixed targets are unaffected. `rel="external"` does not change URL resolution or opt out. Validation never rewrites prose.
204
205
  - Study-guide questions (v4.17.0, #112) — a question whose frontmatter `domain` isn't in `examDomains`, and duplicate question `id`s
205
206
 
206
207
  Missing `src/data/labels.json` or `references.json` self-heals before checks by running the corresponding package build script; an existing artifact is never rewritten implicitly.
@@ -228,9 +229,11 @@ See `recipes/09-validation.md` to extend.
228
229
  2. `npm run build:figures` produces `public/figures/<topic>/<name>.svg`.
229
230
  3. Reference: `<Figure src="/figures/<topic>/<name>.svg" caption="..." alt="..." id="..." />`.
230
231
 
231
- **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:
232
233
 
233
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).
234
237
  - Non-SVG (`.png` fallback), remote, or unreadable `src` keep the `<img>` render.
235
238
  - Opt a figure out of theming with a `%! no-theme` line in its source `.tex`.
236
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.29.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"
@@ -190,9 +192,6 @@
190
192
  },
191
193
  "rehype-katex": {
192
194
  "optional": true
193
- },
194
- "remark-math": {
195
- "optional": true
196
195
  }
197
196
  },
198
197
  "dependencies": {
@@ -203,6 +202,8 @@
203
202
  "@fontsource-variable/roboto": "^5.2.10",
204
203
  "@fontsource-variable/source-code-pro": "^5.2.7",
205
204
  "pagefind": "^1.5.2",
205
+ "parse5": "^7.3.0",
206
+ "remark-math": "^6.0.0",
206
207
  "remark-mdx": "^3.1.1",
207
208
  "remark-parse": "^11.0.0",
208
209
  "unified": "^11.0.5",
@@ -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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Profile**: any (Cite checks skip under non-academic).
4
4
 
5
- **TL;DR**: `npm run validate` runs `scripts/validate.mjs` against all chapter MDX files. It first regenerates missing `labels.json` and `references.json`, then catches typo'd bibkeys / XRef ids / Figure paths / internal links that `astro build` would either miss or surface with poor context. Auto-runs as `prebuild`; recommend wiring into pre-commit too.
5
+ **TL;DR**: `npm run validate` runs `scripts/validate.mjs` against chapter MD/MDX files (plus its question-collection checks). It first regenerates missing `labels.json` and `references.json`, then catches typo'd bibkeys / XRef ids / Figure paths / internal links that `astro build` would either miss or surface with poor context. Auto-runs as `prebuild`; recommend wiring into pre-commit too.
6
6
 
7
7
  ## What gets checked
8
8
 
@@ -17,6 +17,7 @@
17
17
  | `<BookLink book= to=>` both present; `book=` registered; literal fragment target resolves in a declared sibling labels index (#96, #147) | all | Pre-flights the component's build-time throw and dead cross-book anchors across all files at once. |
18
18
  | Questions collection: unique `id`s + `domain` in `examDomains` (#112); `<Rationale appendix>` carries `for=` (#114, v4.21.0) | all, when `src/content/questions/` exists | Duplicate ids break the appendix/flashcards cross-ref key; an unregistered domain throws one-at-a-time at build; an appendix rationale without its anchor target throws at build. |
19
19
  | `los[].anchor` ↔ `{/* anchor: <slug> */}` prose markers agree both ways (#130, v4.20.0) | all, when frontmatter has `los:` | A declared objective whose prose marker is missing/misspelled (or an orphan marker) builds green otherwise — frontmatter↔prose drift only a hand audit would catch. |
20
+ | Literal authored Markdown/HTML/JSX `href`/`src` stays inside a non-root Astro `base` (#190) | all | A browser resolves `/chapters/...` from the host root, bypassing Astro's deployment base and producing a convincing 404. This is a build-blocking error, not a rewrite. |
20
21
 
21
22
  Validate also emits two **non-blocking shadow-route warnings** (exit code unaffected): a consumer-owned `src/pages/chapters/[...slug].astro` without `routes: { chapters: false }` (v4.6.0, #76), and a consumer-owned `src/pages/index.astro` without `routes: { landing: false }` (v4.20.0, #129 — Astro has announced this collision becomes a hard error). See [recipe 18](./18-chapter-route-ownership.md).
22
23
 
@@ -48,6 +49,24 @@ Heading-entry keys are intentionally opaque and path-qualified: `#summary` can l
48
49
 
49
50
  A declared `labels` file that is missing, unreadable, malformed, or not a JSON object is an error. An unknown book, unknown fragment, wrong path, or malformed label entry is also an error with the source file and target. Dynamic `book=`/`to=` expressions and prop spreads cannot be evaluated safely, so they emit explicit skip warnings. URL strings and `{ url }` descriptors likewise warn for literal fragment links because no index was declared. Non-fragment route links warn and skip: labels indexes describe anchors, not every sibling route.
50
51
 
52
+ ## Authored links under a non-root base (#190)
53
+
54
+ When evaluated `astro.config.*` sets a non-root base such as `base: '/library/books/'`, a root-absolute authored URL escapes that deployment mount:
55
+
56
+ ```mdx
57
+ <!-- Errors: both resolve from the host root, outside /library/books/. -->
58
+ [Chapter one](/chapters/one/)
59
+ <img src="/figures/overview.svg" alt="Overview" />
60
+
61
+ <!-- Valid: explicitly remains inside the configured base. -->
62
+ [Chapter one](/library/books/chapters/one/)
63
+ <a href={`${import.meta.env.BASE_URL}references/`}>References</a>
64
+ ```
65
+
66
+ The validator structurally parses inline Markdown links and images, Markdown reference definitions, quoted or unquoted HTML `href`/`src`, and quoted or statically braced JSX string attributes. HTML entities and JavaScript string escapes are decoded before containment is checked. It reports the authored file and line, the evaluated target, the evaluated base, and a browser-normalized base-prefixed replacement. It also scans question MD/MDX files already visited by validation.
67
+
68
+ The check is deliberately validation-only: it does not mutate or rewrite content, and there is no opt-out. Under the root base (`/`) it is inert. Protocol URLs (`https:`, `mailto:`, `data:`, and others), protocol-relative URLs (`//cdn…`), fragments, relative targets, targets already inside the base, and dynamic JSX expressions are excluded. `rel="external"` describes a relationship but does not change where a browser navigates, so it does not exempt a host-root URL. Frontmatter, comments, fenced/inline/indented code examples, and `<pre>`/`<code>` examples are excluded by their parsed Markdown/MDX structure so documentation snippets do not become false diagnostics.
69
+
51
70
  ## Missing generated artifacts
52
71
 
53
72
  `src/data/labels.json` and `src/data/references.json` are derived and normally gitignored. v4.27+ `validate` self-heals either missing file unconditionally by invoking the package's own `build-labels` or `build-bib` command before loading data. Existing files are untouched. A failed child command stops validation with the child's original diagnostic and exit status.
@@ -96,7 +115,7 @@ For pre-commit: add to `.pre-commit-config.yaml`:
96
115
 
97
116
  ## Preset / chaptersBase resolution (v4.7.0+, #75)
98
117
 
99
- The validator evaluates `astro.config.*` through Vite first. A resolved scaffold integration supplies the composed preset and `numberStyle`, so CLI tooling sees the same Style chain as the Astro build. Without such an integration, the legacy preset chain remains: `--preset` → process `BOOK_PRESET`/`BOOK_PROFILE` → root `.env` → the literal value in `defineBookSchemas` → a warned `minimal` v4 compatibility fallback. Every selected value is checked against the five-preset enum; config-evaluation failures stop validation instead of silently using defaults.
118
+ The validator evaluates `astro.config.*` through Vite first. A resolved scaffold integration supplies the composed preset and `numberStyle`, so CLI tooling sees the same Style chain as the Astro build. The evaluated Astro `base` is read from that same config regardless of whether the scaffold integration is present; omission defaults to `/`. Without such an integration, the legacy preset chain remains: `--preset` → process `BOOK_PRESET`/`BOOK_PROFILE` → root `.env` → the literal value in `defineBookSchemas` → a warned `minimal` v4 compatibility fallback. Every selected value is checked against the five-preset enum; config-evaluation failures stop validation instead of silently using defaults.
100
119
 
101
120
  `chaptersBase` resolution still consults `BOOK_CHAPTERS_DIR`, content configuration, then `src/content/chapters`. The v4.5+ canonical form is:
102
121
 
@@ -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