@brandon_m_behring/book-scaffold-astro 4.27.1 → 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/layouts/Base.astro +1 -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/CLAUDE.md
CHANGED
|
@@ -110,6 +110,8 @@ Two callout families coexist. Authors import what they need.
|
|
|
110
110
|
|
|
111
111
|
**Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginFigure`, `MarginNote`, `Sidenote`, `EvidenceTag`, `Newthought`, `Epigraph`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `BookLink` (v4.16.0+; cross-book link — `<BookLink book="design" to="…"/>` resolves `book` against `defineBookConfig({ siblingBooks })` and throws on an unknown book; `<XRef>` is in-book only — #96), `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
|
|
112
112
|
|
|
113
|
+
**Interactive demo substrate (#143; opt-in):** `DemoFrame`, `Slider`, `StatCards`, and `useThemeColors` are named exports from `@brandon_m_behring/book-scaffold-astro/demo`. Import `@brandon_m_behring/book-scaffold-astro/styles/demo.css` on the page that mounts the consumer-owned Preact island; it is never included by a profile and nothing auto-mounts. The substrate owns figure/label/metric semantics, focus/reduced-motion styling, SVG token helpers, and theme-token resolution. Consumers own all data, kernels, charts, and domain interaction policy. See Recipe 23.
|
|
114
|
+
|
|
113
115
|
**`MarginNote` vs `Sidenote` (don't let the names mislead).** `MarginNote` renders **inline** — a colored callout in the running text column; despite the name it does **not** go in the margin. It's for a load-bearing aside the reader must see. `Sidenote` is the one that **floats into the right gutter** (auto-numbered Tufte marginalia, reflowing inline on mobile). Reach for `Sidenote` for footnote-like asides; `MarginNote` for an inline colored callout.
|
|
114
116
|
|
|
115
117
|
**`MarginFigure` + figure/content placement (1d).** `MarginFigure` is a `<Figure>` that **floats into the right gutter** (the same Tufte float + negative-margin technique `Sidenote`/SectionMap use — *not* a grid), shown at ≥64rem and reflowing inline below on mobile. Props mirror `<Figure>` (`src`/`caption`/`alt`/`desc`/`id`/`width`); rendering is delegated to `Figure`, and `width` defaults to `100%` (of the ~28ch gutter column). Two additive placement classes back it: **`.column-margin`** floats any block into the gutter (the un-figure version), and **`.column-page`** is a full-width breakout — an alias of the canonical **`.wide`** escape (max-width override, spans the gutter column too). For a full-bleed figure use `<Figure class="wide" …/>`; for the margin use `<MarginFigure …/>`. **Per-page width knob:** an optional `layout: wide` frontmatter field (closed enum `default`|`wide`, every profile) widens the main text measure for figure-/table-heavy chapters — `Chapter.astro` threads it as `data-layout="wide"` on `<article class="prose">` and `layout.css` maps `.prose[data-layout="wide"] { --measure-main: 80ch }`. Omitting it (or `layout: default`) emits no attribute, so existing chapters are byte-identical. All of these live in the always-loaded `layout.css` (token-only; additive — `.prose`'s block layout and `.sidenote`'s float are unchanged).
|
|
@@ -124,27 +126,23 @@ Two callout families coexist. Authors import what they need.
|
|
|
124
126
|
|
|
125
127
|
Full reference in `recipes/04-component-library.md`.
|
|
126
128
|
|
|
127
|
-
### Theme
|
|
129
|
+
### Theme colors for JS visuals (v4.14.2 event; #143 hook)
|
|
128
130
|
|
|
129
131
|
`Base.astro` emits `book:theme:change` on `window` whenever the **effective** theme changes — both the chrome's dark-mode toggle and a system `prefers-color-scheme` flip (the latter only when no explicit theme is pinned). Use it for **canvas / JS islands** that can't recolor via CSS alone; CSS-token elements recolor automatically from the `[data-theme]` attribute.
|
|
130
132
|
|
|
131
133
|
```ts
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const onChange = (e: Event) => draw((e as CustomEvent).detail.theme);
|
|
142
|
-
window.addEventListener('book:theme:change', onChange);
|
|
143
|
-
return () => window.removeEventListener('book:theme:change', onChange);
|
|
144
|
-
}, []);
|
|
134
|
+
import { useThemeColors } from '@brandon_m_behring/book-scaffold-astro/demo';
|
|
135
|
+
|
|
136
|
+
const TOKENS = {
|
|
137
|
+
ink: ['--color-text', '#1a1a19'],
|
|
138
|
+
accent: ['--color-link', '#3b6fa0'],
|
|
139
|
+
} as const;
|
|
140
|
+
|
|
141
|
+
const { colors, theme, reducedMotion } = useThemeColors(TOKENS);
|
|
142
|
+
// redraw from colors; animate only when reducedMotion === false
|
|
145
143
|
```
|
|
146
144
|
|
|
147
|
-
`
|
|
145
|
+
The hook is SSR-safe (`theme` and `reducedMotion` are `null` until the first client effect), resolves the explicit token map with fallbacks, refreshes on `book:theme:change`, system color-scheme changes, and reduced-motion changes, and removes all listeners on cleanup. Start animation only when `reducedMotion === false`. `detail.theme` on the underlying event remains `'light' | 'dark'` for non-Preact consumers. Prefer CSS variables or `demo.css`'s `data-demo-fill` / `data-demo-stroke` helpers for inline SVG; those recolor automatically and do not need the hook. See Recipe 23 for the complete composition.
|
|
148
146
|
|
|
149
147
|
## Citation patterns
|
|
150
148
|
|
package/README.md
CHANGED
|
@@ -26,6 +26,14 @@ security defaults. A consumer-owned `public/_headers` wins unchanged;
|
|
|
26
26
|
and `securityHeaders.contentSecurityPolicy` replaces only the default CSP.
|
|
27
27
|
See [Recipe 05](./recipes/05-deploy-cloudflare.md#default-security-headers).
|
|
28
28
|
|
|
29
|
+
## Interactive demos
|
|
30
|
+
|
|
31
|
+
The opt-in `@brandon_m_behring/book-scaffold-astro/demo` entry exports
|
|
32
|
+
`DemoFrame`, `Slider`, `StatCards`, and `useThemeColors` for consumer-owned
|
|
33
|
+
Preact islands. Import `styles/demo.css` explicitly; no demo is mounted and no
|
|
34
|
+
domain kernel or data loader is bundled automatically. See
|
|
35
|
+
[Recipe 23](./recipes/23-interactive-demo-substrate.md).
|
|
36
|
+
|
|
29
37
|
## Licensing
|
|
30
38
|
|
|
31
39
|
Code, configuration, and scripts are MIT-licensed. Recipes, pedagogy, examples,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DemoFrame — accessible shell for a consumer-owned interactive teaching
|
|
3
|
+
* figure (#143).
|
|
4
|
+
*
|
|
5
|
+
* The scaffold owns only the stable chrome: figure semantics, an explicit
|
|
6
|
+
* accessible name/description, a body region, and an optional caption. The
|
|
7
|
+
* consumer still owns the visualization, data, and interaction model.
|
|
8
|
+
*/
|
|
9
|
+
import type { ComponentChildren } from 'preact';
|
|
10
|
+
import { useId } from 'preact/hooks';
|
|
11
|
+
|
|
12
|
+
export const DEMO_HEADING_LEVELS = [2, 3, 4, 5, 6] as const;
|
|
13
|
+
export type DemoHeadingLevel = (typeof DEMO_HEADING_LEVELS)[number];
|
|
14
|
+
|
|
15
|
+
export interface DemoFrameProps {
|
|
16
|
+
/** Visible accessible name for the teaching figure. */
|
|
17
|
+
title: string;
|
|
18
|
+
/** Short explanation rendered directly below the title. */
|
|
19
|
+
description?: string;
|
|
20
|
+
/** Figure caption rendered after the interactive body. */
|
|
21
|
+
caption?: ComponentChildren;
|
|
22
|
+
/** Consumer-owned controls and visualization. */
|
|
23
|
+
children?: ComponentChildren;
|
|
24
|
+
/** Optional stable root id for deep links and deterministic child ids. */
|
|
25
|
+
id?: string;
|
|
26
|
+
/** Additional class on the root figure. */
|
|
27
|
+
className?: string;
|
|
28
|
+
/** Reflect a consumer-owned recomputation/loading state to assistive tech. */
|
|
29
|
+
busy?: boolean;
|
|
30
|
+
/** Heading depth in the surrounding document outline. Defaults to 3. */
|
|
31
|
+
headingLevel?: DemoHeadingLevel;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertNonEmpty(value: string, prop: string): void {
|
|
35
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
36
|
+
throw new Error(`DemoFrame: ${prop} must be a non-empty string.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function generatedBaseId(id: string | undefined, generated: string): string {
|
|
41
|
+
if (id !== undefined) {
|
|
42
|
+
assertNonEmpty(id, 'id');
|
|
43
|
+
if (/\s/.test(id)) {
|
|
44
|
+
throw new Error('DemoFrame: id must not contain whitespace.');
|
|
45
|
+
}
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
return `demo-${generated.replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function DemoFrame({
|
|
52
|
+
title,
|
|
53
|
+
description,
|
|
54
|
+
caption,
|
|
55
|
+
children,
|
|
56
|
+
id,
|
|
57
|
+
className,
|
|
58
|
+
busy = false,
|
|
59
|
+
headingLevel = 3,
|
|
60
|
+
}: DemoFrameProps) {
|
|
61
|
+
assertNonEmpty(title, 'title');
|
|
62
|
+
if (description !== undefined) assertNonEmpty(description, 'description');
|
|
63
|
+
if (typeof caption === 'string') assertNonEmpty(caption, 'caption');
|
|
64
|
+
if (!(DEMO_HEADING_LEVELS as readonly number[]).includes(headingLevel)) {
|
|
65
|
+
throw new Error('DemoFrame: headingLevel must be one of 2 | 3 | 4 | 5 | 6.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const baseId = generatedBaseId(id, useId());
|
|
69
|
+
const titleId = `${baseId}-title`;
|
|
70
|
+
const descriptionId = description ? `${baseId}-description` : null;
|
|
71
|
+
const captionId = caption != null ? `${baseId}-caption` : null;
|
|
72
|
+
const describedBy = [descriptionId, captionId].filter(Boolean).join(' ') || undefined;
|
|
73
|
+
const classes = ['demo-frame', className].filter(Boolean).join(' ');
|
|
74
|
+
const Heading = `h${headingLevel}` as 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<figure
|
|
78
|
+
id={id}
|
|
79
|
+
class={classes}
|
|
80
|
+
aria-labelledby={titleId}
|
|
81
|
+
aria-describedby={describedBy}
|
|
82
|
+
aria-busy={busy || undefined}
|
|
83
|
+
>
|
|
84
|
+
<header class="demo-frame__header">
|
|
85
|
+
<Heading id={titleId} class="demo-frame__title">{title}</Heading>
|
|
86
|
+
{description && (
|
|
87
|
+
<p id={descriptionId!} class="demo-frame__description">{description}</p>
|
|
88
|
+
)}
|
|
89
|
+
</header>
|
|
90
|
+
<div class="demo-frame__body">{children}</div>
|
|
91
|
+
{caption != null && (
|
|
92
|
+
<figcaption id={captionId!} class="demo-frame__caption">{caption}</figcaption>
|
|
93
|
+
)}
|
|
94
|
+
</figure>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Controlled, visibly-labelled range input for interactive demos (#143). */
|
|
2
|
+
import { useId } from 'preact/hooks';
|
|
3
|
+
|
|
4
|
+
export interface SliderProps {
|
|
5
|
+
/** Visible label associated with the native range input. */
|
|
6
|
+
label: string;
|
|
7
|
+
/** Controlled numeric value. */
|
|
8
|
+
value: number;
|
|
9
|
+
min: number;
|
|
10
|
+
max: number;
|
|
11
|
+
/** Positive increment. Defaults to the native range-input step of 1. */
|
|
12
|
+
step?: number;
|
|
13
|
+
/** Receives the parsed numeric value for every native input event. */
|
|
14
|
+
onValueChange: (value: number) => void;
|
|
15
|
+
/** Optional stable input id. A hydration-stable id is generated otherwise. */
|
|
16
|
+
id?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
className?: string;
|
|
21
|
+
/** Formats the visible current-value output. */
|
|
22
|
+
formatValue?: (value: number) => string;
|
|
23
|
+
/** Optional spoken value text when the visible format is not descriptive. */
|
|
24
|
+
getValueText?: (value: number) => string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertFinite(value: number, prop: string): void {
|
|
28
|
+
if (!Number.isFinite(value)) {
|
|
29
|
+
throw new Error(`Slider: ${prop} must be a finite number.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isStepAligned(value: number, min: number, step: number): boolean {
|
|
34
|
+
const steps = (value - min) / step;
|
|
35
|
+
const nearest = Math.round(steps);
|
|
36
|
+
const tolerance = Number.EPSILON * Math.max(1, Math.abs(steps)) * 16;
|
|
37
|
+
return Math.abs(steps - nearest) <= tolerance;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertSliderProps({
|
|
41
|
+
label,
|
|
42
|
+
value,
|
|
43
|
+
min,
|
|
44
|
+
max,
|
|
45
|
+
step = 1,
|
|
46
|
+
id,
|
|
47
|
+
description,
|
|
48
|
+
onValueChange,
|
|
49
|
+
formatValue,
|
|
50
|
+
getValueText,
|
|
51
|
+
}: SliderProps): void {
|
|
52
|
+
if (typeof label !== 'string' || label.trim() === '') {
|
|
53
|
+
throw new Error('Slider: label must be a non-empty string.');
|
|
54
|
+
}
|
|
55
|
+
assertFinite(value, 'value');
|
|
56
|
+
assertFinite(min, 'min');
|
|
57
|
+
assertFinite(max, 'max');
|
|
58
|
+
assertFinite(step, 'step');
|
|
59
|
+
if (max <= min) throw new Error('Slider: max must be greater than min.');
|
|
60
|
+
if (step <= 0) throw new Error('Slider: step must be greater than 0.');
|
|
61
|
+
if (value < min || value > max) {
|
|
62
|
+
throw new Error(`Slider: value ${value} must be within [${min}, ${max}].`);
|
|
63
|
+
}
|
|
64
|
+
if (!isStepAligned(value, min, step)) {
|
|
65
|
+
throw new Error('Slider: value must align with min + (n × step).');
|
|
66
|
+
}
|
|
67
|
+
if (id !== undefined && (
|
|
68
|
+
typeof id !== 'string' || id.trim() === '' || /\s/.test(id)
|
|
69
|
+
)) {
|
|
70
|
+
throw new Error('Slider: id must be a non-empty string without whitespace.');
|
|
71
|
+
}
|
|
72
|
+
if (description !== undefined && (
|
|
73
|
+
typeof description !== 'string' || description.trim() === ''
|
|
74
|
+
)) {
|
|
75
|
+
throw new Error('Slider: description must be a non-empty string.');
|
|
76
|
+
}
|
|
77
|
+
if (typeof onValueChange !== 'function') {
|
|
78
|
+
throw new Error('Slider: onValueChange must be a function.');
|
|
79
|
+
}
|
|
80
|
+
if (formatValue !== undefined && typeof formatValue !== 'function') {
|
|
81
|
+
throw new Error('Slider: formatValue must be a function.');
|
|
82
|
+
}
|
|
83
|
+
if (getValueText !== undefined && typeof getValueText !== 'function') {
|
|
84
|
+
throw new Error('Slider: getValueText must be a function.');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function Slider(props: SliderProps) {
|
|
89
|
+
assertSliderProps(props);
|
|
90
|
+
const {
|
|
91
|
+
label,
|
|
92
|
+
value,
|
|
93
|
+
min,
|
|
94
|
+
max,
|
|
95
|
+
step = 1,
|
|
96
|
+
onValueChange,
|
|
97
|
+
id,
|
|
98
|
+
name,
|
|
99
|
+
description,
|
|
100
|
+
disabled = false,
|
|
101
|
+
className,
|
|
102
|
+
formatValue = String,
|
|
103
|
+
getValueText = formatValue,
|
|
104
|
+
} = props;
|
|
105
|
+
|
|
106
|
+
const generated = useId().replace(/[^a-zA-Z0-9_-]/g, '');
|
|
107
|
+
const inputId = id ?? `demo-slider-${generated}`;
|
|
108
|
+
const descriptionId = description ? `${inputId}-description` : undefined;
|
|
109
|
+
const displayValue = formatValue(value);
|
|
110
|
+
if (typeof displayValue !== 'string' || displayValue.trim() === '') {
|
|
111
|
+
throw new Error('Slider: formatValue must return a non-empty string.');
|
|
112
|
+
}
|
|
113
|
+
const valueText = getValueText(value);
|
|
114
|
+
if (typeof valueText !== 'string' || valueText.trim() === '') {
|
|
115
|
+
throw new Error('Slider: getValueText must return a non-empty string.');
|
|
116
|
+
}
|
|
117
|
+
const classes = ['demo-slider', className].filter(Boolean).join(' ');
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div class={classes} data-disabled={disabled || undefined}>
|
|
121
|
+
<div class="demo-slider__head">
|
|
122
|
+
<label class="demo-slider__label" for={inputId}>{label}</label>
|
|
123
|
+
<output class="demo-slider__value" for={inputId}>{displayValue}</output>
|
|
124
|
+
</div>
|
|
125
|
+
{description && (
|
|
126
|
+
<p id={descriptionId} class="demo-slider__description">{description}</p>
|
|
127
|
+
)}
|
|
128
|
+
<input
|
|
129
|
+
id={inputId}
|
|
130
|
+
class="demo-slider__input"
|
|
131
|
+
type="range"
|
|
132
|
+
name={name}
|
|
133
|
+
min={min}
|
|
134
|
+
max={max}
|
|
135
|
+
step={step}
|
|
136
|
+
value={value}
|
|
137
|
+
disabled={disabled}
|
|
138
|
+
aria-describedby={descriptionId}
|
|
139
|
+
aria-valuetext={valueText}
|
|
140
|
+
onInput={(event) => {
|
|
141
|
+
const next = Number.parseFloat(event.currentTarget.value);
|
|
142
|
+
if (!Number.isFinite(next)) {
|
|
143
|
+
throw new Error('Slider: native input produced a non-finite value.');
|
|
144
|
+
}
|
|
145
|
+
onValueChange(next);
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Semantic metric-card list for interactive teaching figures (#143). */
|
|
2
|
+
|
|
3
|
+
export const STAT_CARD_TONES = ['neutral', 'accent', 'positive', 'warning'] as const;
|
|
4
|
+
export type StatCardTone = (typeof STAT_CARD_TONES)[number];
|
|
5
|
+
|
|
6
|
+
export interface StatCardItem {
|
|
7
|
+
/** Optional stable key. Defaults to the label, which must then be unique. */
|
|
8
|
+
id?: string;
|
|
9
|
+
label: string;
|
|
10
|
+
value: string | number;
|
|
11
|
+
/** Optional unit rendered beside the primary value. */
|
|
12
|
+
unit?: string;
|
|
13
|
+
/** Secondary explanatory line. */
|
|
14
|
+
detail?: string;
|
|
15
|
+
tone?: StatCardTone;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StatCardsProps {
|
|
19
|
+
items: readonly StatCardItem[];
|
|
20
|
+
/** Accessible name for the definition list. */
|
|
21
|
+
label?: string;
|
|
22
|
+
/** Opt-in announcements for values that change independently of a control. */
|
|
23
|
+
live?: 'off' | 'polite';
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertText(value: string, path: string): void {
|
|
28
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
29
|
+
throw new Error(`StatCards: ${path} must be a non-empty string.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertItems(items: readonly StatCardItem[]): void {
|
|
34
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
35
|
+
throw new Error('StatCards: items must contain at least one statistic.');
|
|
36
|
+
}
|
|
37
|
+
const keys = new Set<string>();
|
|
38
|
+
for (const [index, item] of items.entries()) {
|
|
39
|
+
if (item === null || typeof item !== 'object') {
|
|
40
|
+
throw new Error(`StatCards: items[${index}] must be a statistic object.`);
|
|
41
|
+
}
|
|
42
|
+
assertText(item.label, `items[${index}].label`);
|
|
43
|
+
if (typeof item.value !== 'string' && typeof item.value !== 'number') {
|
|
44
|
+
throw new Error(`StatCards: items[${index}].value must be a string or number.`);
|
|
45
|
+
}
|
|
46
|
+
if (typeof item.value === 'number' && !Number.isFinite(item.value)) {
|
|
47
|
+
throw new Error(`StatCards: items[${index}].value must be finite.`);
|
|
48
|
+
}
|
|
49
|
+
if (typeof item.value === 'string') assertText(item.value, `items[${index}].value`);
|
|
50
|
+
if (item.unit !== undefined) assertText(item.unit, `items[${index}].unit`);
|
|
51
|
+
if (item.detail !== undefined) assertText(item.detail, `items[${index}].detail`);
|
|
52
|
+
const tone = item.tone ?? 'neutral';
|
|
53
|
+
if (!(STAT_CARD_TONES as readonly string[]).includes(tone)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`StatCards: items[${index}].tone must be one of ${STAT_CARD_TONES.join(' | ')}.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const key = item.id ?? item.label;
|
|
59
|
+
assertText(key, `items[${index}].id`);
|
|
60
|
+
if (keys.has(key)) throw new Error(`StatCards: duplicate item key "${key}".`);
|
|
61
|
+
keys.add(key);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function StatCards({
|
|
66
|
+
items,
|
|
67
|
+
label = 'Key statistics',
|
|
68
|
+
live = 'off',
|
|
69
|
+
className,
|
|
70
|
+
}: StatCardsProps) {
|
|
71
|
+
assertItems(items);
|
|
72
|
+
assertText(label, 'label');
|
|
73
|
+
if (live !== 'off' && live !== 'polite') {
|
|
74
|
+
throw new Error('StatCards: live must be off | polite.');
|
|
75
|
+
}
|
|
76
|
+
const classes = ['demo-stat-cards', className].filter(Boolean).join(' ');
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<dl
|
|
80
|
+
class={classes}
|
|
81
|
+
aria-label={label}
|
|
82
|
+
aria-live={live}
|
|
83
|
+
aria-atomic={live === 'polite' || undefined}
|
|
84
|
+
>
|
|
85
|
+
{items.map((item) => {
|
|
86
|
+
const tone = item.tone ?? 'neutral';
|
|
87
|
+
return (
|
|
88
|
+
<div class="demo-stat-card" data-tone={tone} key={item.id ?? item.label}>
|
|
89
|
+
<dt class="demo-stat-card__label">{item.label}</dt>
|
|
90
|
+
<dd class="demo-stat-card__value">
|
|
91
|
+
{item.value}
|
|
92
|
+
{item.unit && <> <span class="demo-stat-card__unit">{item.unit}</span></>}
|
|
93
|
+
</dd>
|
|
94
|
+
{item.detail && <dd class="demo-stat-card__detail">{item.detail}</dd>}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</dl>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Stable, consumer-agnostic interactive-demo substrate (#143). */
|
|
2
|
+
export {
|
|
3
|
+
DemoFrame,
|
|
4
|
+
DEMO_HEADING_LEVELS,
|
|
5
|
+
type DemoFrameProps,
|
|
6
|
+
type DemoHeadingLevel,
|
|
7
|
+
} from './DemoFrame.js';
|
|
8
|
+
export { Slider, type SliderProps } from './Slider.js';
|
|
9
|
+
export {
|
|
10
|
+
StatCards,
|
|
11
|
+
STAT_CARD_TONES,
|
|
12
|
+
type StatCardItem,
|
|
13
|
+
type StatCardTone,
|
|
14
|
+
type StatCardsProps,
|
|
15
|
+
} from './StatCards.js';
|
|
16
|
+
export {
|
|
17
|
+
useThemeColors,
|
|
18
|
+
type BookTheme,
|
|
19
|
+
type ResolvedThemeColors,
|
|
20
|
+
type ThemeColorMap,
|
|
21
|
+
type ThemeColorSpec,
|
|
22
|
+
type ThemeColorsSnapshot,
|
|
23
|
+
type ThemeColorToken,
|
|
24
|
+
} from './useThemeColors.js';
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme-token resolver for SVG/canvas islands (#143).
|
|
3
|
+
*
|
|
4
|
+
* CSS-authored visuals should keep using var(--token) directly. Canvas and
|
|
5
|
+
* SVG attributes sometimes need concrete strings, so this hook resolves an
|
|
6
|
+
* explicit token map, refreshes it on `book:theme:change`, and exposes the
|
|
7
|
+
* user's reduced-motion preference to consumer animation code.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
10
|
+
|
|
11
|
+
export type BookTheme = 'light' | 'dark';
|
|
12
|
+
export type ThemeColorToken = `--${string}`;
|
|
13
|
+
export type ThemeColorSpec = readonly [token: ThemeColorToken, fallback: string];
|
|
14
|
+
export type ThemeColorMap = Readonly<Record<string, ThemeColorSpec>>;
|
|
15
|
+
export type ResolvedThemeColors<T extends ThemeColorMap> = {
|
|
16
|
+
readonly [Key in keyof T]: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface ThemeColorsSnapshot<T extends ThemeColorMap> {
|
|
20
|
+
/** Null during SSR; resolved on the first client effect. */
|
|
21
|
+
theme: BookTheme | null;
|
|
22
|
+
colors: ResolvedThemeColors<T>;
|
|
23
|
+
/** Null until the client preference is known; animate only when explicitly false. */
|
|
24
|
+
reducedMotion: boolean | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface StoredThemeColorsSnapshot<T extends ThemeColorMap> {
|
|
28
|
+
signature: string;
|
|
29
|
+
value: ThemeColorsSnapshot<T>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fallbackColors<T extends ThemeColorMap>(specs: T): ResolvedThemeColors<T> {
|
|
33
|
+
return Object.fromEntries(
|
|
34
|
+
Object.entries(specs).map(([key, [, fallback]]) => [key, fallback]),
|
|
35
|
+
) as ResolvedThemeColors<T>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unresolvedSnapshot<T extends ThemeColorMap>(specs: T): ThemeColorsSnapshot<T> {
|
|
39
|
+
return {
|
|
40
|
+
theme: null,
|
|
41
|
+
colors: fallbackColors(specs),
|
|
42
|
+
reducedMotion: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertSpecs(specs: ThemeColorMap): void {
|
|
47
|
+
if (specs === null || typeof specs !== 'object' || Array.isArray(specs)) {
|
|
48
|
+
throw new Error('useThemeColors: specs must be a token-map object.');
|
|
49
|
+
}
|
|
50
|
+
const entries = Object.entries(specs);
|
|
51
|
+
if (entries.length === 0) {
|
|
52
|
+
throw new Error('useThemeColors: provide at least one CSS token mapping.');
|
|
53
|
+
}
|
|
54
|
+
for (const [key, spec] of entries) {
|
|
55
|
+
if (key.trim() === '') {
|
|
56
|
+
throw new Error('useThemeColors: color keys must be non-empty strings.');
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(spec) || spec.length !== 2) {
|
|
59
|
+
throw new Error(`useThemeColors: "${key}" must be a [token, fallback] pair.`);
|
|
60
|
+
}
|
|
61
|
+
const [token, fallback] = spec;
|
|
62
|
+
if (typeof token !== 'string' || !/^--\S+$/.test(token)) {
|
|
63
|
+
throw new Error(`useThemeColors: "${key}" token must start with -- and contain no whitespace.`);
|
|
64
|
+
}
|
|
65
|
+
if (typeof fallback !== 'string' || fallback.trim() === '') {
|
|
66
|
+
throw new Error(`useThemeColors: "${key}" fallback must be a non-empty string.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function effectiveTheme(): BookTheme {
|
|
72
|
+
const explicit = document.documentElement.getAttribute('data-theme');
|
|
73
|
+
if (explicit === 'light' || explicit === 'dark') return explicit;
|
|
74
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function snapshot<T extends ThemeColorMap>(
|
|
78
|
+
specs: T,
|
|
79
|
+
themeHint?: BookTheme,
|
|
80
|
+
): ThemeColorsSnapshot<T> {
|
|
81
|
+
const computed = getComputedStyle(document.documentElement);
|
|
82
|
+
const colors = Object.fromEntries(
|
|
83
|
+
Object.entries(specs).map(([key, [token, fallback]]) => {
|
|
84
|
+
const resolved = computed.getPropertyValue(token).trim();
|
|
85
|
+
return [key, resolved || fallback];
|
|
86
|
+
}),
|
|
87
|
+
) as ResolvedThemeColors<T>;
|
|
88
|
+
return {
|
|
89
|
+
theme: themeHint ?? effectiveTheme(),
|
|
90
|
+
colors,
|
|
91
|
+
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve CSS custom properties and refresh after theme/motion changes.
|
|
97
|
+
*
|
|
98
|
+
* Keep the spec object outside the component (or otherwise stable) so its
|
|
99
|
+
* intent is obvious and the hook does not need to re-subscribe each render.
|
|
100
|
+
*/
|
|
101
|
+
export function useThemeColors<const T extends ThemeColorMap>(
|
|
102
|
+
specs: T,
|
|
103
|
+
): ThemeColorsSnapshot<T> {
|
|
104
|
+
assertSpecs(specs);
|
|
105
|
+
const signature = JSON.stringify(specs);
|
|
106
|
+
const [current, setCurrent] = useState<StoredThemeColorsSnapshot<T>>(() => ({
|
|
107
|
+
signature,
|
|
108
|
+
value: unresolvedSnapshot(specs),
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const refresh = (themeHint?: BookTheme) => setCurrent({
|
|
113
|
+
signature,
|
|
114
|
+
value: snapshot(specs, themeHint),
|
|
115
|
+
});
|
|
116
|
+
const onThemeChange = (event: Event) => {
|
|
117
|
+
const detail = (event as CustomEvent<{ theme?: unknown }>).detail;
|
|
118
|
+
const theme = detail?.theme === 'light' || detail?.theme === 'dark'
|
|
119
|
+
? detail.theme
|
|
120
|
+
: undefined;
|
|
121
|
+
refresh(theme);
|
|
122
|
+
};
|
|
123
|
+
const onMediaChange = () => refresh();
|
|
124
|
+
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
|
|
125
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
126
|
+
|
|
127
|
+
refresh();
|
|
128
|
+
window.addEventListener('book:theme:change', onThemeChange);
|
|
129
|
+
colorScheme.addEventListener('change', onMediaChange);
|
|
130
|
+
reducedMotion.addEventListener('change', onMediaChange);
|
|
131
|
+
return () => {
|
|
132
|
+
window.removeEventListener('book:theme:change', onThemeChange);
|
|
133
|
+
colorScheme.removeEventListener('change', onMediaChange);
|
|
134
|
+
reducedMotion.removeEventListener('change', onMediaChange);
|
|
135
|
+
};
|
|
136
|
+
// signature tracks semantic changes while allowing an inline object whose
|
|
137
|
+
// identity changes but token/fallback pairs do not.
|
|
138
|
+
}, [signature]);
|
|
139
|
+
|
|
140
|
+
return current.signature === signature ? current.value : unresolvedSnapshot(specs);
|
|
141
|
+
}
|
package/dist/demo.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as preact from 'preact';
|
|
2
|
+
import { ComponentChildren } from 'preact';
|
|
3
|
+
|
|
4
|
+
declare const DEMO_HEADING_LEVELS: readonly [2, 3, 4, 5, 6];
|
|
5
|
+
type DemoHeadingLevel = (typeof DEMO_HEADING_LEVELS)[number];
|
|
6
|
+
interface DemoFrameProps {
|
|
7
|
+
/** Visible accessible name for the teaching figure. */
|
|
8
|
+
title: string;
|
|
9
|
+
/** Short explanation rendered directly below the title. */
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Figure caption rendered after the interactive body. */
|
|
12
|
+
caption?: ComponentChildren;
|
|
13
|
+
/** Consumer-owned controls and visualization. */
|
|
14
|
+
children?: ComponentChildren;
|
|
15
|
+
/** Optional stable root id for deep links and deterministic child ids. */
|
|
16
|
+
id?: string;
|
|
17
|
+
/** Additional class on the root figure. */
|
|
18
|
+
className?: string;
|
|
19
|
+
/** Reflect a consumer-owned recomputation/loading state to assistive tech. */
|
|
20
|
+
busy?: boolean;
|
|
21
|
+
/** Heading depth in the surrounding document outline. Defaults to 3. */
|
|
22
|
+
headingLevel?: DemoHeadingLevel;
|
|
23
|
+
}
|
|
24
|
+
declare function DemoFrame({ title, description, caption, children, id, className, busy, headingLevel, }: DemoFrameProps): preact.JSX.Element;
|
|
25
|
+
|
|
26
|
+
interface SliderProps {
|
|
27
|
+
/** Visible label associated with the native range input. */
|
|
28
|
+
label: string;
|
|
29
|
+
/** Controlled numeric value. */
|
|
30
|
+
value: number;
|
|
31
|
+
min: number;
|
|
32
|
+
max: number;
|
|
33
|
+
/** Positive increment. Defaults to the native range-input step of 1. */
|
|
34
|
+
step?: number;
|
|
35
|
+
/** Receives the parsed numeric value for every native input event. */
|
|
36
|
+
onValueChange: (value: number) => void;
|
|
37
|
+
/** Optional stable input id. A hydration-stable id is generated otherwise. */
|
|
38
|
+
id?: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
className?: string;
|
|
43
|
+
/** Formats the visible current-value output. */
|
|
44
|
+
formatValue?: (value: number) => string;
|
|
45
|
+
/** Optional spoken value text when the visible format is not descriptive. */
|
|
46
|
+
getValueText?: (value: number) => string;
|
|
47
|
+
}
|
|
48
|
+
declare function Slider(props: SliderProps): preact.JSX.Element;
|
|
49
|
+
|
|
50
|
+
/** Semantic metric-card list for interactive teaching figures (#143). */
|
|
51
|
+
declare const STAT_CARD_TONES: readonly ["neutral", "accent", "positive", "warning"];
|
|
52
|
+
type StatCardTone = (typeof STAT_CARD_TONES)[number];
|
|
53
|
+
interface StatCardItem {
|
|
54
|
+
/** Optional stable key. Defaults to the label, which must then be unique. */
|
|
55
|
+
id?: string;
|
|
56
|
+
label: string;
|
|
57
|
+
value: string | number;
|
|
58
|
+
/** Optional unit rendered beside the primary value. */
|
|
59
|
+
unit?: string;
|
|
60
|
+
/** Secondary explanatory line. */
|
|
61
|
+
detail?: string;
|
|
62
|
+
tone?: StatCardTone;
|
|
63
|
+
}
|
|
64
|
+
interface StatCardsProps {
|
|
65
|
+
items: readonly StatCardItem[];
|
|
66
|
+
/** Accessible name for the definition list. */
|
|
67
|
+
label?: string;
|
|
68
|
+
/** Opt-in announcements for values that change independently of a control. */
|
|
69
|
+
live?: 'off' | 'polite';
|
|
70
|
+
className?: string;
|
|
71
|
+
}
|
|
72
|
+
declare function StatCards({ items, label, live, className, }: StatCardsProps): preact.JSX.Element;
|
|
73
|
+
|
|
74
|
+
type BookTheme = 'light' | 'dark';
|
|
75
|
+
type ThemeColorToken = `--${string}`;
|
|
76
|
+
type ThemeColorSpec = readonly [token: ThemeColorToken, fallback: string];
|
|
77
|
+
type ThemeColorMap = Readonly<Record<string, ThemeColorSpec>>;
|
|
78
|
+
type ResolvedThemeColors<T extends ThemeColorMap> = {
|
|
79
|
+
readonly [Key in keyof T]: string;
|
|
80
|
+
};
|
|
81
|
+
interface ThemeColorsSnapshot<T extends ThemeColorMap> {
|
|
82
|
+
/** Null during SSR; resolved on the first client effect. */
|
|
83
|
+
theme: BookTheme | null;
|
|
84
|
+
colors: ResolvedThemeColors<T>;
|
|
85
|
+
/** Null until the client preference is known; animate only when explicitly false. */
|
|
86
|
+
reducedMotion: boolean | null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve CSS custom properties and refresh after theme/motion changes.
|
|
90
|
+
*
|
|
91
|
+
* Keep the spec object outside the component (or otherwise stable) so its
|
|
92
|
+
* intent is obvious and the hook does not need to re-subscribe each render.
|
|
93
|
+
*/
|
|
94
|
+
declare function useThemeColors<const T extends ThemeColorMap>(specs: T): ThemeColorsSnapshot<T>;
|
|
95
|
+
|
|
96
|
+
export { type BookTheme, DEMO_HEADING_LEVELS, DemoFrame, type DemoFrameProps, type DemoHeadingLevel, type ResolvedThemeColors, STAT_CARD_TONES, Slider, type SliderProps, type StatCardItem, type StatCardTone, StatCards, type StatCardsProps, type ThemeColorMap, type ThemeColorSpec, type ThemeColorToken, type ThemeColorsSnapshot, useThemeColors };
|