@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.
@@ -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 */