@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 +4 -1
- package/bin/book-scaffold.mjs +1 -1
- package/components/Figure.astro +7 -6
- package/package.json +5 -4
- package/recipes/03-asset-pipelines.md +12 -4
- package/recipes/09-validation.md +21 -2
- package/recipes/16-tikz-figures.md +13 -2
- package/recipes/24-figure-authoring-standard.md +241 -0
- package/recipes/README.md +1 -0
- package/scripts/authored-links.mjs +328 -0
- package/scripts/build-figures.mjs +13 -10
- package/scripts/render-notebooks.mjs +2 -1
- package/scripts/resolve-book-config.mjs +16 -2
- package/scripts/sync-figure-tokens.mjs +44 -0
- package/scripts/validate.mjs +89 -15
- package/src/lib/figure-palette.mjs +162 -0
- package/src/lib/figure.mjs +113 -37
- package/styles/tokens.css +73 -9
|
@@ -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
|
+
}
|
package/src/lib/figure.mjs
CHANGED
|
@@ -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
|
|
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 --
|
|
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.
|
|
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>
|
|
102
|
-
* distinct neutral color C.
|
|
103
|
-
* modified; the attribute
|
|
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 ||
|
|
150
|
+
if (optOut || CURRENT_MAP_RE.test(svg)) return svg;
|
|
151
|
+
if (!/<svg\b[^>]*>/i.test(svg)) return svg;
|
|
108
152
|
|
|
109
|
-
|
|
110
|
-
|
|
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 →
|
|
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(
|
|
167
|
+
while ((am = attrRe.exec(cleanSvg)) !== null) {
|
|
117
168
|
if (found.has(am[1])) continue;
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
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
|
|
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
|
-
?
|
|
129
|
-
:
|
|
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,
|
|
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(${
|
|
138
|
-
`[stroke="${sel}"]{stroke:var(${
|
|
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
|
-
|
|
196
|
+
cleanSvg.slice(0, openMatch.index) +
|
|
147
197
|
openTag +
|
|
148
198
|
styleBlocks +
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
168
|
-
* source of
|
|
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
|
|
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
|
-
|
|
226
|
-
|
|
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 (
|
|
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 */
|