@brandon_m_behring/book-scaffold-astro 4.27.0 → 4.28.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 +13 -15
- package/README.md +8 -0
- package/components/demo/DemoFrame.tsx +96 -0
- package/components/demo/Slider.tsx +150 -0
- package/components/demo/StatCards.tsx +100 -0
- package/components/demo/index.ts +24 -0
- package/components/demo/useThemeColors.ts +141 -0
- package/dist/demo.d.ts +96 -0
- package/dist/demo.mjs +353 -0
- package/dist/index.mjs +16 -0
- package/layouts/Base.astro +11 -2
- package/package.json +6 -1
- package/recipes/23-interactive-demo-substrate.md +103 -0
- package/recipes/README.md +2 -0
- package/styles/demo.css +207 -0
package/dist/demo.mjs
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// components/demo/DemoFrame.tsx
|
|
2
|
+
import { useId } from "preact/hooks";
|
|
3
|
+
import { jsx, jsxs } from "preact/jsx-runtime";
|
|
4
|
+
var DEMO_HEADING_LEVELS = [2, 3, 4, 5, 6];
|
|
5
|
+
function assertNonEmpty(value, prop) {
|
|
6
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
7
|
+
throw new Error(`DemoFrame: ${prop} must be a non-empty string.`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function generatedBaseId(id, generated) {
|
|
11
|
+
if (id !== void 0) {
|
|
12
|
+
assertNonEmpty(id, "id");
|
|
13
|
+
if (/\s/.test(id)) {
|
|
14
|
+
throw new Error("DemoFrame: id must not contain whitespace.");
|
|
15
|
+
}
|
|
16
|
+
return id;
|
|
17
|
+
}
|
|
18
|
+
return `demo-${generated.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
19
|
+
}
|
|
20
|
+
function DemoFrame({
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
caption,
|
|
24
|
+
children,
|
|
25
|
+
id,
|
|
26
|
+
className,
|
|
27
|
+
busy = false,
|
|
28
|
+
headingLevel = 3
|
|
29
|
+
}) {
|
|
30
|
+
assertNonEmpty(title, "title");
|
|
31
|
+
if (description !== void 0) assertNonEmpty(description, "description");
|
|
32
|
+
if (typeof caption === "string") assertNonEmpty(caption, "caption");
|
|
33
|
+
if (!DEMO_HEADING_LEVELS.includes(headingLevel)) {
|
|
34
|
+
throw new Error("DemoFrame: headingLevel must be one of 2 | 3 | 4 | 5 | 6.");
|
|
35
|
+
}
|
|
36
|
+
const baseId = generatedBaseId(id, useId());
|
|
37
|
+
const titleId = `${baseId}-title`;
|
|
38
|
+
const descriptionId = description ? `${baseId}-description` : null;
|
|
39
|
+
const captionId = caption != null ? `${baseId}-caption` : null;
|
|
40
|
+
const describedBy = [descriptionId, captionId].filter(Boolean).join(" ") || void 0;
|
|
41
|
+
const classes = ["demo-frame", className].filter(Boolean).join(" ");
|
|
42
|
+
const Heading = `h${headingLevel}`;
|
|
43
|
+
return /* @__PURE__ */ jsxs(
|
|
44
|
+
"figure",
|
|
45
|
+
{
|
|
46
|
+
id,
|
|
47
|
+
class: classes,
|
|
48
|
+
"aria-labelledby": titleId,
|
|
49
|
+
"aria-describedby": describedBy,
|
|
50
|
+
"aria-busy": busy || void 0,
|
|
51
|
+
children: [
|
|
52
|
+
/* @__PURE__ */ jsxs("header", { class: "demo-frame__header", children: [
|
|
53
|
+
/* @__PURE__ */ jsx(Heading, { id: titleId, class: "demo-frame__title", children: title }),
|
|
54
|
+
description && /* @__PURE__ */ jsx("p", { id: descriptionId, class: "demo-frame__description", children: description })
|
|
55
|
+
] }),
|
|
56
|
+
/* @__PURE__ */ jsx("div", { class: "demo-frame__body", children }),
|
|
57
|
+
caption != null && /* @__PURE__ */ jsx("figcaption", { id: captionId, class: "demo-frame__caption", children: caption })
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// components/demo/Slider.tsx
|
|
64
|
+
import { useId as useId2 } from "preact/hooks";
|
|
65
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "preact/jsx-runtime";
|
|
66
|
+
function assertFinite(value, prop) {
|
|
67
|
+
if (!Number.isFinite(value)) {
|
|
68
|
+
throw new Error(`Slider: ${prop} must be a finite number.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function isStepAligned(value, min, step) {
|
|
72
|
+
const steps = (value - min) / step;
|
|
73
|
+
const nearest = Math.round(steps);
|
|
74
|
+
const tolerance = Number.EPSILON * Math.max(1, Math.abs(steps)) * 16;
|
|
75
|
+
return Math.abs(steps - nearest) <= tolerance;
|
|
76
|
+
}
|
|
77
|
+
function assertSliderProps({
|
|
78
|
+
label,
|
|
79
|
+
value,
|
|
80
|
+
min,
|
|
81
|
+
max,
|
|
82
|
+
step = 1,
|
|
83
|
+
id,
|
|
84
|
+
description,
|
|
85
|
+
onValueChange,
|
|
86
|
+
formatValue,
|
|
87
|
+
getValueText
|
|
88
|
+
}) {
|
|
89
|
+
if (typeof label !== "string" || label.trim() === "") {
|
|
90
|
+
throw new Error("Slider: label must be a non-empty string.");
|
|
91
|
+
}
|
|
92
|
+
assertFinite(value, "value");
|
|
93
|
+
assertFinite(min, "min");
|
|
94
|
+
assertFinite(max, "max");
|
|
95
|
+
assertFinite(step, "step");
|
|
96
|
+
if (max <= min) throw new Error("Slider: max must be greater than min.");
|
|
97
|
+
if (step <= 0) throw new Error("Slider: step must be greater than 0.");
|
|
98
|
+
if (value < min || value > max) {
|
|
99
|
+
throw new Error(`Slider: value ${value} must be within [${min}, ${max}].`);
|
|
100
|
+
}
|
|
101
|
+
if (!isStepAligned(value, min, step)) {
|
|
102
|
+
throw new Error("Slider: value must align with min + (n \xD7 step).");
|
|
103
|
+
}
|
|
104
|
+
if (id !== void 0 && (typeof id !== "string" || id.trim() === "" || /\s/.test(id))) {
|
|
105
|
+
throw new Error("Slider: id must be a non-empty string without whitespace.");
|
|
106
|
+
}
|
|
107
|
+
if (description !== void 0 && (typeof description !== "string" || description.trim() === "")) {
|
|
108
|
+
throw new Error("Slider: description must be a non-empty string.");
|
|
109
|
+
}
|
|
110
|
+
if (typeof onValueChange !== "function") {
|
|
111
|
+
throw new Error("Slider: onValueChange must be a function.");
|
|
112
|
+
}
|
|
113
|
+
if (formatValue !== void 0 && typeof formatValue !== "function") {
|
|
114
|
+
throw new Error("Slider: formatValue must be a function.");
|
|
115
|
+
}
|
|
116
|
+
if (getValueText !== void 0 && typeof getValueText !== "function") {
|
|
117
|
+
throw new Error("Slider: getValueText must be a function.");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function Slider(props) {
|
|
121
|
+
assertSliderProps(props);
|
|
122
|
+
const {
|
|
123
|
+
label,
|
|
124
|
+
value,
|
|
125
|
+
min,
|
|
126
|
+
max,
|
|
127
|
+
step = 1,
|
|
128
|
+
onValueChange,
|
|
129
|
+
id,
|
|
130
|
+
name,
|
|
131
|
+
description,
|
|
132
|
+
disabled = false,
|
|
133
|
+
className,
|
|
134
|
+
formatValue = String,
|
|
135
|
+
getValueText = formatValue
|
|
136
|
+
} = props;
|
|
137
|
+
const generated = useId2().replace(/[^a-zA-Z0-9_-]/g, "");
|
|
138
|
+
const inputId = id ?? `demo-slider-${generated}`;
|
|
139
|
+
const descriptionId = description ? `${inputId}-description` : void 0;
|
|
140
|
+
const displayValue = formatValue(value);
|
|
141
|
+
if (typeof displayValue !== "string" || displayValue.trim() === "") {
|
|
142
|
+
throw new Error("Slider: formatValue must return a non-empty string.");
|
|
143
|
+
}
|
|
144
|
+
const valueText = getValueText(value);
|
|
145
|
+
if (typeof valueText !== "string" || valueText.trim() === "") {
|
|
146
|
+
throw new Error("Slider: getValueText must return a non-empty string.");
|
|
147
|
+
}
|
|
148
|
+
const classes = ["demo-slider", className].filter(Boolean).join(" ");
|
|
149
|
+
return /* @__PURE__ */ jsxs2("div", { class: classes, "data-disabled": disabled || void 0, children: [
|
|
150
|
+
/* @__PURE__ */ jsxs2("div", { class: "demo-slider__head", children: [
|
|
151
|
+
/* @__PURE__ */ jsx2("label", { class: "demo-slider__label", for: inputId, children: label }),
|
|
152
|
+
/* @__PURE__ */ jsx2("output", { class: "demo-slider__value", for: inputId, children: displayValue })
|
|
153
|
+
] }),
|
|
154
|
+
description && /* @__PURE__ */ jsx2("p", { id: descriptionId, class: "demo-slider__description", children: description }),
|
|
155
|
+
/* @__PURE__ */ jsx2(
|
|
156
|
+
"input",
|
|
157
|
+
{
|
|
158
|
+
id: inputId,
|
|
159
|
+
class: "demo-slider__input",
|
|
160
|
+
type: "range",
|
|
161
|
+
name,
|
|
162
|
+
min,
|
|
163
|
+
max,
|
|
164
|
+
step,
|
|
165
|
+
value,
|
|
166
|
+
disabled,
|
|
167
|
+
"aria-describedby": descriptionId,
|
|
168
|
+
"aria-valuetext": valueText,
|
|
169
|
+
onInput: (event) => {
|
|
170
|
+
const next = Number.parseFloat(event.currentTarget.value);
|
|
171
|
+
if (!Number.isFinite(next)) {
|
|
172
|
+
throw new Error("Slider: native input produced a non-finite value.");
|
|
173
|
+
}
|
|
174
|
+
onValueChange(next);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
] });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// components/demo/StatCards.tsx
|
|
182
|
+
import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "preact/jsx-runtime";
|
|
183
|
+
var STAT_CARD_TONES = ["neutral", "accent", "positive", "warning"];
|
|
184
|
+
function assertText(value, path) {
|
|
185
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
186
|
+
throw new Error(`StatCards: ${path} must be a non-empty string.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function assertItems(items) {
|
|
190
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
191
|
+
throw new Error("StatCards: items must contain at least one statistic.");
|
|
192
|
+
}
|
|
193
|
+
const keys = /* @__PURE__ */ new Set();
|
|
194
|
+
for (const [index, item] of items.entries()) {
|
|
195
|
+
if (item === null || typeof item !== "object") {
|
|
196
|
+
throw new Error(`StatCards: items[${index}] must be a statistic object.`);
|
|
197
|
+
}
|
|
198
|
+
assertText(item.label, `items[${index}].label`);
|
|
199
|
+
if (typeof item.value !== "string" && typeof item.value !== "number") {
|
|
200
|
+
throw new Error(`StatCards: items[${index}].value must be a string or number.`);
|
|
201
|
+
}
|
|
202
|
+
if (typeof item.value === "number" && !Number.isFinite(item.value)) {
|
|
203
|
+
throw new Error(`StatCards: items[${index}].value must be finite.`);
|
|
204
|
+
}
|
|
205
|
+
if (typeof item.value === "string") assertText(item.value, `items[${index}].value`);
|
|
206
|
+
if (item.unit !== void 0) assertText(item.unit, `items[${index}].unit`);
|
|
207
|
+
if (item.detail !== void 0) assertText(item.detail, `items[${index}].detail`);
|
|
208
|
+
const tone = item.tone ?? "neutral";
|
|
209
|
+
if (!STAT_CARD_TONES.includes(tone)) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`StatCards: items[${index}].tone must be one of ${STAT_CARD_TONES.join(" | ")}.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const key = item.id ?? item.label;
|
|
215
|
+
assertText(key, `items[${index}].id`);
|
|
216
|
+
if (keys.has(key)) throw new Error(`StatCards: duplicate item key "${key}".`);
|
|
217
|
+
keys.add(key);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function StatCards({
|
|
221
|
+
items,
|
|
222
|
+
label = "Key statistics",
|
|
223
|
+
live = "off",
|
|
224
|
+
className
|
|
225
|
+
}) {
|
|
226
|
+
assertItems(items);
|
|
227
|
+
assertText(label, "label");
|
|
228
|
+
if (live !== "off" && live !== "polite") {
|
|
229
|
+
throw new Error("StatCards: live must be off | polite.");
|
|
230
|
+
}
|
|
231
|
+
const classes = ["demo-stat-cards", className].filter(Boolean).join(" ");
|
|
232
|
+
return /* @__PURE__ */ jsx3(
|
|
233
|
+
"dl",
|
|
234
|
+
{
|
|
235
|
+
class: classes,
|
|
236
|
+
"aria-label": label,
|
|
237
|
+
"aria-live": live,
|
|
238
|
+
"aria-atomic": live === "polite" || void 0,
|
|
239
|
+
children: items.map((item) => {
|
|
240
|
+
const tone = item.tone ?? "neutral";
|
|
241
|
+
return /* @__PURE__ */ jsxs3("div", { class: "demo-stat-card", "data-tone": tone, children: [
|
|
242
|
+
/* @__PURE__ */ jsx3("dt", { class: "demo-stat-card__label", children: item.label }),
|
|
243
|
+
/* @__PURE__ */ jsxs3("dd", { class: "demo-stat-card__value", children: [
|
|
244
|
+
item.value,
|
|
245
|
+
item.unit && /* @__PURE__ */ jsxs3(Fragment, { children: [
|
|
246
|
+
" ",
|
|
247
|
+
/* @__PURE__ */ jsx3("span", { class: "demo-stat-card__unit", children: item.unit })
|
|
248
|
+
] })
|
|
249
|
+
] }),
|
|
250
|
+
item.detail && /* @__PURE__ */ jsx3("dd", { class: "demo-stat-card__detail", children: item.detail })
|
|
251
|
+
] }, item.id ?? item.label);
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// components/demo/useThemeColors.ts
|
|
258
|
+
import { useEffect, useState } from "preact/hooks";
|
|
259
|
+
function fallbackColors(specs) {
|
|
260
|
+
return Object.fromEntries(
|
|
261
|
+
Object.entries(specs).map(([key, [, fallback]]) => [key, fallback])
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
function unresolvedSnapshot(specs) {
|
|
265
|
+
return {
|
|
266
|
+
theme: null,
|
|
267
|
+
colors: fallbackColors(specs),
|
|
268
|
+
reducedMotion: null
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function assertSpecs(specs) {
|
|
272
|
+
if (specs === null || typeof specs !== "object" || Array.isArray(specs)) {
|
|
273
|
+
throw new Error("useThemeColors: specs must be a token-map object.");
|
|
274
|
+
}
|
|
275
|
+
const entries = Object.entries(specs);
|
|
276
|
+
if (entries.length === 0) {
|
|
277
|
+
throw new Error("useThemeColors: provide at least one CSS token mapping.");
|
|
278
|
+
}
|
|
279
|
+
for (const [key, spec] of entries) {
|
|
280
|
+
if (key.trim() === "") {
|
|
281
|
+
throw new Error("useThemeColors: color keys must be non-empty strings.");
|
|
282
|
+
}
|
|
283
|
+
if (!Array.isArray(spec) || spec.length !== 2) {
|
|
284
|
+
throw new Error(`useThemeColors: "${key}" must be a [token, fallback] pair.`);
|
|
285
|
+
}
|
|
286
|
+
const [token, fallback] = spec;
|
|
287
|
+
if (typeof token !== "string" || !/^--\S+$/.test(token)) {
|
|
288
|
+
throw new Error(`useThemeColors: "${key}" token must start with -- and contain no whitespace.`);
|
|
289
|
+
}
|
|
290
|
+
if (typeof fallback !== "string" || fallback.trim() === "") {
|
|
291
|
+
throw new Error(`useThemeColors: "${key}" fallback must be a non-empty string.`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function effectiveTheme() {
|
|
296
|
+
const explicit = document.documentElement.getAttribute("data-theme");
|
|
297
|
+
if (explicit === "light" || explicit === "dark") return explicit;
|
|
298
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
299
|
+
}
|
|
300
|
+
function snapshot(specs, themeHint) {
|
|
301
|
+
const computed = getComputedStyle(document.documentElement);
|
|
302
|
+
const colors = Object.fromEntries(
|
|
303
|
+
Object.entries(specs).map(([key, [token, fallback]]) => {
|
|
304
|
+
const resolved = computed.getPropertyValue(token).trim();
|
|
305
|
+
return [key, resolved || fallback];
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
return {
|
|
309
|
+
theme: themeHint ?? effectiveTheme(),
|
|
310
|
+
colors,
|
|
311
|
+
reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function useThemeColors(specs) {
|
|
315
|
+
assertSpecs(specs);
|
|
316
|
+
const signature = JSON.stringify(specs);
|
|
317
|
+
const [current, setCurrent] = useState(() => ({
|
|
318
|
+
signature,
|
|
319
|
+
value: unresolvedSnapshot(specs)
|
|
320
|
+
}));
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
const refresh = (themeHint) => setCurrent({
|
|
323
|
+
signature,
|
|
324
|
+
value: snapshot(specs, themeHint)
|
|
325
|
+
});
|
|
326
|
+
const onThemeChange = (event) => {
|
|
327
|
+
const detail = event.detail;
|
|
328
|
+
const theme = detail?.theme === "light" || detail?.theme === "dark" ? detail.theme : void 0;
|
|
329
|
+
refresh(theme);
|
|
330
|
+
};
|
|
331
|
+
const onMediaChange = () => refresh();
|
|
332
|
+
const colorScheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
333
|
+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
334
|
+
refresh();
|
|
335
|
+
window.addEventListener("book:theme:change", onThemeChange);
|
|
336
|
+
colorScheme.addEventListener("change", onMediaChange);
|
|
337
|
+
reducedMotion.addEventListener("change", onMediaChange);
|
|
338
|
+
return () => {
|
|
339
|
+
window.removeEventListener("book:theme:change", onThemeChange);
|
|
340
|
+
colorScheme.removeEventListener("change", onMediaChange);
|
|
341
|
+
reducedMotion.removeEventListener("change", onMediaChange);
|
|
342
|
+
};
|
|
343
|
+
}, [signature]);
|
|
344
|
+
return current.signature === signature ? current.value : unresolvedSnapshot(specs);
|
|
345
|
+
}
|
|
346
|
+
export {
|
|
347
|
+
DEMO_HEADING_LEVELS,
|
|
348
|
+
DemoFrame,
|
|
349
|
+
STAT_CARD_TONES,
|
|
350
|
+
Slider,
|
|
351
|
+
StatCards,
|
|
352
|
+
useThemeColors
|
|
353
|
+
};
|
package/dist/index.mjs
CHANGED
|
@@ -1130,6 +1130,21 @@ function defineMdxComponents(components) {
|
|
|
1130
1130
|
// src/integration.ts
|
|
1131
1131
|
var BOOK_CONFIG_VIRTUAL_ID = "virtual:book-scaffold/book-config";
|
|
1132
1132
|
var BOOK_CONFIG_RESOLVED_ID = "\0" + BOOK_CONFIG_VIRTUAL_ID;
|
|
1133
|
+
function makeRobotoFontDisplayVitePlugin() {
|
|
1134
|
+
return {
|
|
1135
|
+
name: "book-scaffold:roboto-font-display",
|
|
1136
|
+
enforce: "pre",
|
|
1137
|
+
transform(code, id) {
|
|
1138
|
+
const [path = id] = id.split("?");
|
|
1139
|
+
const normalizedPath = path.replaceAll("\\", "/");
|
|
1140
|
+
if (!normalizedPath.endsWith("/@fontsource-variable/roboto/index.css")) {
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
const transformed = code.replace(/font-display:\s*swap/g, "font-display: optional");
|
|
1144
|
+
return transformed === code ? null : { code: transformed, map: null };
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1133
1148
|
function makeBookConfigVitePlugin(config) {
|
|
1134
1149
|
const serialized = `export default ${JSON.stringify(config)};`;
|
|
1135
1150
|
return {
|
|
@@ -1302,6 +1317,7 @@ function bookScaffoldIntegration(opts) {
|
|
|
1302
1317
|
updateConfig({
|
|
1303
1318
|
vite: {
|
|
1304
1319
|
plugins: [
|
|
1320
|
+
makeRobotoFontDisplayVitePlugin(),
|
|
1305
1321
|
makeMdxComponentsVitePlugin(resolvedMdxPath),
|
|
1306
1322
|
makeBookConfigVitePlugin({
|
|
1307
1323
|
title: title ?? null,
|
package/layouts/Base.astro
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* — or the page ends up with duplicate `<main>` landmarks (a11y, #91).
|
|
40
40
|
*/
|
|
41
41
|
import '@fontsource-variable/roboto';
|
|
42
|
+
import robotoLatinUrl from '@fontsource-variable/roboto/files/roboto-latin-wght-normal.woff2?url';
|
|
42
43
|
import '@fontsource-variable/source-code-pro';
|
|
43
44
|
// KaTeX CSS is injected by bookScaffoldIntegration for academic profile
|
|
44
45
|
// only (since v3.0 alpha.8) — tools/minimal profiles don't install katex
|
|
@@ -128,6 +129,15 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
|
|
|
128
129
|
<head>
|
|
129
130
|
<meta charset="utf-8" />
|
|
130
131
|
<link rel="icon" type="image/svg+xml" href={`${baseUrl}favicon.svg`} />
|
|
132
|
+
{/* #187: start the common Latin body face early. Its Fontsource rules use
|
|
133
|
+
font-display: optional, so a slow response never swaps after paint. */}
|
|
134
|
+
<link
|
|
135
|
+
rel="preload"
|
|
136
|
+
href={robotoLatinUrl}
|
|
137
|
+
as="font"
|
|
138
|
+
type="font/woff2"
|
|
139
|
+
crossorigin="anonymous"
|
|
140
|
+
/>
|
|
131
141
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
132
142
|
<meta name="color-scheme" content="light dark" />
|
|
133
143
|
<meta name="generator" content={Astro.generator} />
|
|
@@ -244,8 +254,7 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
|
|
|
244
254
|
CSS recolors via the `data-theme` attribute alone; canvas/JS islands can't,
|
|
245
255
|
so they subscribe to this event (and read the attribute / matchMedia on
|
|
246
256
|
mount for the initial value) to redraw. Contract documented in the package
|
|
247
|
-
CLAUDE.md
|
|
248
|
-
with the demo kit.
|
|
257
|
+
CLAUDE.md; the opt-in demo entry now wraps it with `useThemeColors` (#143).
|
|
249
258
|
*/}
|
|
250
259
|
<script is:inline>
|
|
251
260
|
(function () {
|
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.
|
|
4
|
+
"version": "4.28.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Brandon Behring",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"types": "./dist/schemas.d.ts",
|
|
41
41
|
"import": "./dist/schemas.mjs"
|
|
42
42
|
},
|
|
43
|
+
"./demo": {
|
|
44
|
+
"types": "./dist/demo.d.ts",
|
|
45
|
+
"import": "./dist/demo.mjs"
|
|
46
|
+
},
|
|
43
47
|
"./package.json": "./package.json",
|
|
44
48
|
"./components/AICollaborationDisclosure.astro": "./components/AICollaborationDisclosure.astro",
|
|
45
49
|
"./components/AssessmentTest.astro": "./components/AssessmentTest.astro",
|
|
@@ -138,6 +142,7 @@
|
|
|
138
142
|
"./styles/exam-runner.css": "./styles/exam-runner.css",
|
|
139
143
|
"./styles/flashcards.css": "./styles/flashcards.css",
|
|
140
144
|
"./styles/section-map.css": "./styles/section-map.css",
|
|
145
|
+
"./styles/demo.css": "./styles/demo.css",
|
|
141
146
|
"./layouts/Base.astro": "./layouts/Base.astro",
|
|
142
147
|
"./layouts/Chapter.astro": "./layouts/Chapter.astro",
|
|
143
148
|
"./lib": {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Recipe 23 — Interactive demo substrate
|
|
2
|
+
|
|
3
|
+
**Profile:** any
|
|
4
|
+
|
|
5
|
+
## TL;DR
|
|
6
|
+
|
|
7
|
+
Build the domain logic in your book, then compose the package's opt-in Preact
|
|
8
|
+
primitives for the repeated shell and accessibility plumbing. Import the
|
|
9
|
+
stylesheet explicitly and hydrate your own island; the scaffold never mounts a
|
|
10
|
+
demo, chooses data, or ships statistical/visualization kernels for you.
|
|
11
|
+
|
|
12
|
+
```astro
|
|
13
|
+
---
|
|
14
|
+
import SamplingDemo from '../components/SamplingDemo.tsx';
|
|
15
|
+
import '@brandon_m_behring/book-scaffold-astro/styles/demo.css';
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<SamplingDemo client:visible />
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Inside the island:
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { useState } from 'preact/hooks';
|
|
25
|
+
import { DemoFrame, Slider, StatCards } from '@brandon_m_behring/book-scaffold-astro/demo';
|
|
26
|
+
|
|
27
|
+
export default function SamplingDemo() {
|
|
28
|
+
const [size, setSize] = useState(20);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<DemoFrame id="sampling-demo" title="Sampling distribution"
|
|
32
|
+
headingLevel={2}
|
|
33
|
+
description="Adjust sample size and compare uncertainty."
|
|
34
|
+
caption="Simulation and chart logic remain in this book.">
|
|
35
|
+
<Slider
|
|
36
|
+
label="Sample size"
|
|
37
|
+
value={size} min={10} max={100} step={10}
|
|
38
|
+
onValueChange={setSize}
|
|
39
|
+
formatValue={(value) => `n = ${value}`}
|
|
40
|
+
getValueText={(value) => `${value} observations`}
|
|
41
|
+
/>
|
|
42
|
+
<StatCards items={[
|
|
43
|
+
{ label: 'Sample size', value: size, tone: 'accent' },
|
|
44
|
+
{ label: 'Standard error', value: (1 / Math.sqrt(size)).toFixed(3) },
|
|
45
|
+
]} />
|
|
46
|
+
</DemoFrame>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Theme-aware visuals
|
|
52
|
+
|
|
53
|
+
Inline SVG should normally stay in CSS. Give the SVG an accessible name and
|
|
54
|
+
use the stylesheet's semantic attributes; they recolor without JavaScript:
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
<div class="demo-frame__visual">
|
|
58
|
+
<svg data-demo-visual role="img" aria-labelledby="plot-title plot-desc" viewBox="0 0 320 120">
|
|
59
|
+
<title id="plot-title">Estimate by sample size</title>
|
|
60
|
+
<desc id="plot-desc">The estimate approaches the reference line.</desc>
|
|
61
|
+
<path data-demo-stroke="accent" fill="none" d="M0 90 L320 30" />
|
|
62
|
+
<circle data-demo-fill="warning" cx="80" cy="70" r="5" />
|
|
63
|
+
</svg>
|
|
64
|
+
</div>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Canvas and JS-computed drawing attributes need concrete colors. Resolve an
|
|
68
|
+
explicit token map with `useThemeColors`; it is SSR-safe, refreshes after
|
|
69
|
+
`book:theme:change` and system color-scheme changes, and reports reduced motion:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { useThemeColors } from '@brandon_m_behring/book-scaffold-astro/demo';
|
|
73
|
+
|
|
74
|
+
const TOKENS = { ink: ['--color-text', '#1a1a19'], accent: ['--color-link', '#3b6fa0'] } as const;
|
|
75
|
+
|
|
76
|
+
const { colors, theme, reducedMotion } = useThemeColors(TOKENS);
|
|
77
|
+
// Repaint from colors. Theme/motion are null during SSR; animate only when
|
|
78
|
+
// reducedMotion === false.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Contracts and gotchas
|
|
82
|
+
|
|
83
|
+
- `DemoFrame` owns `<figure>`, heading, description, caption, and generated ID relationships. Set `headingLevel` to match the surrounding outline (h2-h6; default h3). Give the visualization itself `role="img"` and a useful `<title>`/`<desc>` or `aria-label`; the frame cannot describe chart content.
|
|
84
|
+
- `Slider` is controlled. Update its `value` in `onValueChange`. Its label and
|
|
85
|
+
current value are always visible; use `getValueText` when visible shorthand
|
|
86
|
+
would be unclear when spoken. The value must align with `min + (n * step)`;
|
|
87
|
+
this prevents the browser from silently displaying a different range value.
|
|
88
|
+
- `StatCards` renders a semantic definition list. It is not live by default so
|
|
89
|
+
dragging a slider does not create announcement spam. Use `live="polite"`
|
|
90
|
+
only for changes that occur independently of the user's control.
|
|
91
|
+
- `demo.css` scopes its reduced-motion guard to `.demo-frame`; the hook exposes the same preference for canvas or requestAnimationFrame logic. Its value is `null` until the client preference resolves, so start animation only when it is explicitly `false`.
|
|
92
|
+
- Props fail loudly for empty labels, invalid numeric ranges, non-finite values,
|
|
93
|
+
invalid tones, duplicate metric keys, and malformed token maps.
|
|
94
|
+
|
|
95
|
+
Not included: OLS/residualization/sampling kernels, chart primitives, data loaders,
|
|
96
|
+
tabs, predict/reveal policy, or domain logic. Keep those consumer-owned.
|
|
97
|
+
|
|
98
|
+
## Canonical files
|
|
99
|
+
|
|
100
|
+
- `components/demo/` — component and hook source
|
|
101
|
+
- `styles/demo.css` — explicitly imported styling and SVG token helpers
|
|
102
|
+
- `tests/demo-substrate.test.mjs` — semantics and fail-loud contracts
|
|
103
|
+
- `gallery/src/pages/demo-substrate.astro` — interaction/theme fixture
|
package/recipes/README.md
CHANGED
|
@@ -27,6 +27,8 @@ Terse pointers into canonical code for the most common book-authoring workflows.
|
|
|
27
27
|
| 19 | [Prevalidate hook](19-prevalidate-hook.md) | any | Wire `prevalidate` to run `build:bib` + `build:labels` before `validate` |
|
|
28
28
|
| 20 | [Anki deck export (consumer-side)](20-anki-export.md) | any (esp. course-notes, research-portfolio) | Roll-your-own `<AnkiCard>` + extractor; scaffold deliberately doesn't ship this |
|
|
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
|
+
| 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
|
+
| 23 | [Interactive demo substrate](23-interactive-demo-substrate.md) | any | Opt-in Preact shell, slider, stat cards, theme colors, a11y, and reduced motion |
|
|
30
32
|
|
|
31
33
|
## How to read recipes
|
|
32
34
|
|