@unpunnyfuns/swatchbook-blocks 1.2.0 → 2.0.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,791 @@
1
+ import './style.css';
2
+ import { COLOR_FORMATS } from "@unpunnyfuns/swatchbook-core/color-formats";
3
+ import { formatColor } from "@unpunnyfuns/swatchbook-core/format-color";
4
+ import { createContext, useContext, useEffect, useState, useSyncExternalStore } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { formatTokenValue } from "@unpunnyfuns/swatchbook-core/token-value-css";
7
+ import cx from "clsx";
8
+ //#region src/internal/value-to-css.ts
9
+ /**
10
+ * Formats a DTCG dimension-shaped raw value (`number`, pre-formatted
11
+ * string, or `{ value, unit }`) as a CSS length. Shared by the border and
12
+ * shadow presenters, whose `$value` shapes both carry raw dimension fields.
13
+ */
14
+ function formatLength(raw) {
15
+ if (typeof raw === "number") return `${raw}px`;
16
+ if (typeof raw === "string") return raw;
17
+ if (raw && typeof raw === "object") {
18
+ const v = raw;
19
+ if (typeof v.value === "number" && typeof v.unit === "string") return `${v.value}${v.unit}`;
20
+ }
21
+ return "0px";
22
+ }
23
+ /**
24
+ * Maps a display `ColorFormat` to one CSS can actually render as a color
25
+ * value. `raw` renders a JSON debug string (core's `formatColor`), which
26
+ * isn't valid CSS: the border and shadow presenters substitute `hex` for
27
+ * that one format so the swatch still paints; every other format
28
+ * (rgb/hsl/oklch/hex) is already valid CSS.
29
+ */
30
+ function cssColorFormat(format) {
31
+ return format === "raw" ? "hex" : format;
32
+ }
33
+ //#endregion
34
+ //#region src/border-preview/BorderSample.tsx
35
+ /** Pure presentation for a single border token's sample. Renders from plain props. */
36
+ function BorderSampleView({ cssVar }) {
37
+ return /* @__PURE__ */ jsx("div", {
38
+ className: "sb-border-sample",
39
+ style: { border: cssVar },
40
+ "aria-hidden": true
41
+ });
42
+ }
43
+ /**
44
+ * Builds a valid `border` shorthand value straight from a realised
45
+ * `border.$value`. Distinct from core's `formatTokenValue`, whose `raw`
46
+ * color branch emits a display-only JSON string that the browser would
47
+ * reject as a border color.
48
+ */
49
+ function borderValueToCss(value, colorFormat) {
50
+ if (!value || typeof value !== "object") return "";
51
+ return [
52
+ formatLength(value.width),
53
+ typeof value.style === "string" ? value.style : "solid",
54
+ formatColor(value.color, cssColorFormat(colorFormat)).value
55
+ ].join(" ");
56
+ }
57
+ function BorderSample({ token, cssVar, colorFormat }) {
58
+ return /* @__PURE__ */ jsx(BorderSampleView, { cssVar: cssVar ?? borderValueToCss(token.$value, colorFormat) });
59
+ }
60
+ //#endregion
61
+ //#region src/presenters/ColorSwatch.tsx
62
+ function leafOf(path) {
63
+ return path.split(".").at(-1) ?? path;
64
+ }
65
+ /**
66
+ * Presenter for `$type: color` tokens: a color chip, leaf label, and
67
+ * formatted value with an out-of-gamut marker. The chip's background is
68
+ * `cssVar` when supplied (R3); otherwise it is computed straight from
69
+ * `token.$value`, with `raw` guarded to `hex` (`cssColorFormat`) so the chip
70
+ * always paints a real color even when the display format is a JSON debug
71
+ * string. The value text below the chip uses the actual `colorFormat`,
72
+ * `raw` included. `options.label` lets a grouped consumer (e.g.
73
+ * `ColorPalette`) supply a group-relative label instead of the bare leaf
74
+ * segment, so context lost to grouping (`blue` in `color.palette.blue.50`)
75
+ * still reads on the swatch; standalone usage falls back to `leafOf(path)`.
76
+ */
77
+ function ColorSwatch({ path, token, cssVar, colorFormat, options }) {
78
+ const chipBackground = cssVar ?? formatColor(token.$value, cssColorFormat(colorFormat)).value;
79
+ const { value, outOfGamut } = formatColor(token.$value, colorFormat);
80
+ const label = typeof options?.["label"] === "string" ? options["label"] : leafOf(path);
81
+ return /* @__PURE__ */ jsxs("div", {
82
+ className: "sb-color-swatch",
83
+ children: [/* @__PURE__ */ jsx("div", {
84
+ className: "sb-color-swatch__chip",
85
+ style: { background: chipBackground },
86
+ "aria-hidden": true
87
+ }), /* @__PURE__ */ jsxs("div", {
88
+ className: "sb-color-swatch__meta",
89
+ children: [/* @__PURE__ */ jsx("span", {
90
+ className: "sb-color-swatch__leaf",
91
+ children: label
92
+ }), /* @__PURE__ */ jsxs("span", {
93
+ className: "sb-color-swatch__value",
94
+ children: [value, outOfGamut && /* @__PURE__ */ jsxs("span", {
95
+ title: "Out of sRGB gamut for this format",
96
+ "aria-label": "out of gamut",
97
+ className: "sb-color-swatch__gamut-warn",
98
+ children: [" ", "⚠"]
99
+ })]
100
+ })]
101
+ })]
102
+ });
103
+ }
104
+ //#endregion
105
+ //#region src/dimension-scale/dimension-px.ts
106
+ /**
107
+ * Convert a DTCG dimension `$value` (`{ value, unit }`) to pixels for the
108
+ * purpose of deciding whether to cap the rendered size. `rootFontSizePx`
109
+ * scales `rem` against the rendering context's actual root font-size
110
+ * (default 16 for the no-DOM / SSR path); the bar paints at the same
111
+ * context's `var()`, so passing the measured root keeps the cap decision
112
+ * aligned with what's drawn. Returns `NaN` for anything other than `px` /
113
+ * `rem` — `ex` / `ch` / `%`, and the non-DTCG `em` (the `dimension` type
114
+ * permits only `px | rem`) — which the caller treats as "render at cssVar
115
+ * but don't cap".
116
+ */
117
+ function toPixels(raw, rootFontSizePx = 16) {
118
+ if (raw == null || typeof raw !== "object") return NaN;
119
+ const v = raw;
120
+ if (typeof v.value !== "number" || typeof v.unit !== "string") return NaN;
121
+ switch (v.unit) {
122
+ case "px": return v.value;
123
+ case "rem": return v.value * rootFontSizePx;
124
+ default: return NaN;
125
+ }
126
+ }
127
+ //#endregion
128
+ //#region src/internal/use-root-font-size.ts
129
+ function readRootFontSize() {
130
+ if (typeof document === "undefined") return 16;
131
+ const fontSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
132
+ return Number.isFinite(fontSize) && fontSize > 0 ? fontSize : 16;
133
+ }
134
+ function subscribe$1(onChange) {
135
+ if (typeof window === "undefined") return () => {};
136
+ window.addEventListener("resize", onChange);
137
+ return () => window.removeEventListener("resize", onChange);
138
+ }
139
+ /**
140
+ * Root font-size (px) of the context a block renders in, tracked across
141
+ * viewport changes. `rem` dimension values resolve their `var()` against this
142
+ * root, so the cap math (`toPixels`) and value sort (`sortTokens`) must scale
143
+ * `rem` by it rather than a literal 16: a Storybook Docs page, or a responsive
144
+ * desktop/tablet/mobile breakpoint, can apply a different root to the same
145
+ * token. Falls back to 16 with no DOM.
146
+ */
147
+ function useRootFontSize() {
148
+ return useSyncExternalStore(subscribe$1, readRootFontSize, () => 16);
149
+ }
150
+ //#endregion
151
+ //#region src/dimension-scale/DimensionSample.tsx
152
+ function asDimensionVisual(raw) {
153
+ return raw === "length" || raw === "radius" || raw === "size" ? raw : "length";
154
+ }
155
+ /**
156
+ * Pure derivation of a single dimension token's sample geometry from its
157
+ * realised `$value`. The cap decision always reads the numeric `$value` (so
158
+ * it holds regardless of whether `cssVar` is present); `cssVar`, when given,
159
+ * wins as the rendered value up to that cap.
160
+ */
161
+ function deriveDimensionSample(props, rootFontSizePx) {
162
+ const realised = formatTokenValue(props.token.$value, "dimension", props.colorFormat);
163
+ const cssVar = props.cssVar ?? realised;
164
+ const pxValue = toPixels(props.token.$value, rootFontSizePx);
165
+ const capped = Number.isFinite(pxValue) && pxValue > 480;
166
+ return {
167
+ cssVar,
168
+ pxValue,
169
+ capped,
170
+ cappedValue: capped ? `480px` : cssVar
171
+ };
172
+ }
173
+ function withCap(visual) {
174
+ return /* @__PURE__ */ jsxs("span", {
175
+ className: "sb-dimension-sample sb-dimension-sample--capped",
176
+ title: `capped at 480px`,
177
+ children: [visual, /* @__PURE__ */ jsx("span", {
178
+ className: "sb-dimension-sample__cap",
179
+ "aria-hidden": true,
180
+ children: "…"
181
+ })]
182
+ });
183
+ }
184
+ /** Pure presentation for a single dimension token's bar/sample. Renders from plain props. */
185
+ function DimensionSampleView({ cssVar, capped, cappedValue, visual }) {
186
+ switch (visual) {
187
+ case "radius": return /* @__PURE__ */ jsx("div", {
188
+ className: "sb-dimension-sample__radius-sample",
189
+ style: { borderRadius: cssVar },
190
+ "aria-hidden": true
191
+ });
192
+ case "size": {
193
+ const sample = /* @__PURE__ */ jsx("div", {
194
+ className: "sb-dimension-sample__size-sample",
195
+ style: {
196
+ width: cappedValue,
197
+ height: cappedValue
198
+ },
199
+ "aria-hidden": true
200
+ });
201
+ return capped ? withCap(sample) : sample;
202
+ }
203
+ default: {
204
+ const bar = /* @__PURE__ */ jsx("div", {
205
+ className: "sb-dimension-sample__bar",
206
+ style: { width: cappedValue },
207
+ "aria-hidden": true
208
+ });
209
+ return capped ? withCap(bar) : bar;
210
+ }
211
+ }
212
+ }
213
+ /** Connected block: renders a realised dimension token's sample, capped for oversized values. */
214
+ function DimensionSample({ token, cssVar, colorFormat, options }) {
215
+ const visual = asDimensionVisual(options?.["visual"]);
216
+ const rootFontSize = useRootFontSize();
217
+ const derived = deriveDimensionSample({
218
+ token,
219
+ cssVar,
220
+ colorFormat
221
+ }, rootFontSize);
222
+ return /* @__PURE__ */ jsx(DimensionSampleView, {
223
+ cssVar: derived.cssVar,
224
+ capped: derived.capped,
225
+ cappedValue: derived.cappedValue,
226
+ visual
227
+ });
228
+ }
229
+ //#endregion
230
+ //#region src/presenters/FontFamilySpecimen.tsx
231
+ const DEFAULT_SAMPLE$2 = "The quick brown fox jumps over the lazy dog.";
232
+ function stackString(raw) {
233
+ if (typeof raw === "string") return raw;
234
+ if (Array.isArray(raw)) return raw.map(String).join(", ");
235
+ return "";
236
+ }
237
+ /**
238
+ * Presenter for `$type: fontFamily` tokens: path, resolved stack, a sample
239
+ * line styled per R3, and the css var in use.
240
+ */
241
+ function FontFamilySpecimen({ path, token, cssVar, options }) {
242
+ const rawSample = options?.["sample"];
243
+ const sample = typeof rawSample === "string" ? rawSample : DEFAULT_SAMPLE$2;
244
+ const stack = stackString(token.$value);
245
+ return /* @__PURE__ */ jsxs("div", {
246
+ className: "sb-font-family-specimen__row",
247
+ children: [
248
+ /* @__PURE__ */ jsxs("div", {
249
+ className: "sb-font-family-specimen__meta",
250
+ children: [/* @__PURE__ */ jsx("span", {
251
+ className: "sb-font-family-specimen__path",
252
+ children: path
253
+ }), /* @__PURE__ */ jsx("span", {
254
+ className: "sb-font-family-specimen__stack",
255
+ children: stack
256
+ })]
257
+ }),
258
+ /* @__PURE__ */ jsx("div", {
259
+ className: "sb-font-family-specimen__sample",
260
+ style: { fontFamily: cssVar ?? stack },
261
+ children: sample
262
+ }),
263
+ cssVar && /* @__PURE__ */ jsx("span", {
264
+ className: "sb-font-family-specimen__css-var",
265
+ children: cssVar
266
+ })
267
+ ]
268
+ });
269
+ }
270
+ //#endregion
271
+ //#region src/internal/css-var-style.ts
272
+ /**
273
+ * Coerce a CSS `var(--…)` reference into the numeric slot of a React
274
+ * inline-style property.
275
+ *
276
+ * React's `CSSProperties` types unitless properties (`fontWeight`,
277
+ * `lineHeight`, `zIndex`, …) as `number`. The DOM accepts a string at
278
+ * runtime — the rendered stylesheet just receives whatever React passes —
279
+ * so a `var(--font-weight)` reference works functionally. TypeScript still
280
+ * complains. Centralising the type assertion in one named helper keeps
281
+ * the gap visible (and greppable) instead of scattering casts across
282
+ * block components that want CSS-var-driven typography or layout values.
283
+ */
284
+ const cssVarAsNumber = (varRef) => varRef;
285
+ //#endregion
286
+ //#region src/presenters/FontWeightSpecimen.tsx
287
+ const DEFAULT_SAMPLE$1 = "Aa";
288
+ /**
289
+ * Presenter for `$type: fontWeight` tokens: path, the raw weight value, a
290
+ * sample glyph styled per R3, and the css var in use.
291
+ */
292
+ function FontWeightSpecimen({ path, token, cssVar, options }) {
293
+ const rawSample = options?.["sample"];
294
+ const sample = typeof rawSample === "string" ? rawSample : DEFAULT_SAMPLE$1;
295
+ const display = token.$value == null ? "" : String(token.$value);
296
+ const sampleStyle = { fontWeight: cssVar ? cssVarAsNumber(cssVar) : display };
297
+ return /* @__PURE__ */ jsxs("div", {
298
+ className: "sb-font-weight-specimen__row",
299
+ children: [
300
+ /* @__PURE__ */ jsxs("div", {
301
+ className: "sb-font-weight-specimen__meta",
302
+ children: [/* @__PURE__ */ jsx("span", {
303
+ className: "sb-font-weight-specimen__path",
304
+ children: path
305
+ }), /* @__PURE__ */ jsx("span", {
306
+ className: "sb-font-weight-specimen__value",
307
+ children: display
308
+ })]
309
+ }),
310
+ /* @__PURE__ */ jsx("div", {
311
+ className: "sb-font-weight-specimen__sample",
312
+ style: sampleStyle,
313
+ children: sample
314
+ }),
315
+ cssVar && /* @__PURE__ */ jsx("span", {
316
+ className: "sb-font-weight-specimen__css-var",
317
+ children: cssVar
318
+ })
319
+ ]
320
+ });
321
+ }
322
+ //#endregion
323
+ //#region src/presenters/GradientSwatch.tsx
324
+ /**
325
+ * Builds a valid `linear-gradient()` value straight from a realised
326
+ * `gradient.$value` stop list. Distinct from core's `formatTokenValue`,
327
+ * whose `raw` color branch emits a display-only JSON string per stop that
328
+ * the browser would reject as a gradient color.
329
+ */
330
+ function gradientValueToCss(stops, colorFormat) {
331
+ const format = cssColorFormat(colorFormat);
332
+ return `linear-gradient(90deg, ${stops.map((stop) => {
333
+ return `${formatColor(stop.color, format).value} ${((typeof stop.position === "number" ? stop.position : 0) * 100).toFixed(0)}%`;
334
+ }).join(", ")})`;
335
+ }
336
+ /**
337
+ * Presenter for `$type: gradient` tokens: a gradient chip. Unlike the
338
+ * other composite presenters, a gradient's css var (per Terrazzo's
339
+ * `transformGradient`) resolves to a bare `<color> <pct>, ...` stop list,
340
+ * not a complete property value: DTCG gradients carry no direction, so
341
+ * both branches need one supplied at render time. `90deg` (`to right`)
342
+ * matches the direction the standalone block previously hard-coded.
343
+ */
344
+ function GradientSwatch({ token, cssVar, colorFormat }) {
345
+ const stops = Array.isArray(token.$value) ? token.$value : [];
346
+ return /* @__PURE__ */ jsx("div", {
347
+ className: "sb-gradient-swatch__chip",
348
+ style: { background: cssVar ? `linear-gradient(90deg, ${cssVar})` : gradientValueToCss(stops, colorFormat) },
349
+ "aria-hidden": true
350
+ });
351
+ }
352
+ //#endregion
353
+ //#region src/internal/prefers-reduced-motion.ts
354
+ function isChromatic() {
355
+ if (typeof navigator === "undefined") return false;
356
+ return navigator.userAgent.includes("Chromatic");
357
+ }
358
+ /**
359
+ * Reactive `prefers-reduced-motion: reduce` detector. Returns the current
360
+ * match and updates if the user toggles the OS-level preference. Also
361
+ * returns `true` under Chromatic to keep animated samples deterministic
362
+ * during visual regression capture.
363
+ */
364
+ function usePrefersReducedMotion() {
365
+ const [reduced, setReduced] = useState(false);
366
+ useEffect(() => {
367
+ if (typeof window === "undefined") return;
368
+ if (isChromatic()) {
369
+ setReduced(true);
370
+ return;
371
+ }
372
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
373
+ setReduced(query.matches);
374
+ const onChange = (e) => setReduced(e.matches);
375
+ query.addEventListener("change", onChange);
376
+ return () => query.removeEventListener("change", onChange);
377
+ }, []);
378
+ return reduced;
379
+ }
380
+ //#endregion
381
+ //#region src/motion-preview/MotionSample.tsx
382
+ const DEFAULT_DURATION_MS = 300;
383
+ const DEFAULT_EASING = "cubic-bezier(0.2, 0, 0, 1)";
384
+ function extractDurationMs(raw) {
385
+ if (raw == null) return NaN;
386
+ if (typeof raw === "object") {
387
+ const v = raw;
388
+ if (typeof v.value === "number" && typeof v.unit === "string") {
389
+ if (v.unit === "ms") return v.value;
390
+ if (v.unit === "s") return v.value * 1e3;
391
+ }
392
+ }
393
+ return NaN;
394
+ }
395
+ function extractCubicBezier(raw) {
396
+ if (Array.isArray(raw) && raw.length === 4 && raw.every((n) => typeof n === "number")) return `cubic-bezier(${raw.map((n) => Number(n).toFixed(3)).join(", ")})`;
397
+ return null;
398
+ }
399
+ function asDuration(raw, themeTokens, fallback) {
400
+ const direct = extractDurationMs(raw);
401
+ if (Number.isFinite(direct)) return direct;
402
+ if (typeof raw === "string") {
403
+ const match = raw.match(/^\{([^}]+)\}$/);
404
+ if (match && match[1]) {
405
+ const referenced = themeTokens[match[1]];
406
+ const resolved = extractDurationMs(referenced?.$value);
407
+ if (Number.isFinite(resolved)) return resolved;
408
+ }
409
+ }
410
+ return fallback;
411
+ }
412
+ function asEasing(raw, themeTokens, fallback) {
413
+ const direct = extractCubicBezier(raw);
414
+ if (direct) return direct;
415
+ if (typeof raw === "string") {
416
+ const match = raw.match(/^\{([^}]+)\}$/);
417
+ if (match && match[1]) {
418
+ const referenced = themeTokens[match[1]];
419
+ const resolved = extractCubicBezier(referenced?.$value);
420
+ if (resolved) return resolved;
421
+ }
422
+ }
423
+ return fallback;
424
+ }
425
+ /**
426
+ * Extracts a duration/easing `Spec` from a token's `$value`, dispatching on
427
+ * `$type` (`transition` / `duration` / `cubicBezier`). `themeTokens` backs
428
+ * `{path}`-form sub-value alias resolution for callers still walking a raw
429
+ * (not-yet-realised) project map; pass `{}` when `token` is already a
430
+ * realised leaf (no alias strings survive resolution).
431
+ */
432
+ function resolveMotionSpec(token, themeTokens) {
433
+ if (!token) return null;
434
+ const type = token.$type;
435
+ if (type === "transition") {
436
+ const v = token.$value ?? {};
437
+ return {
438
+ durationMs: asDuration(v.duration, themeTokens, DEFAULT_DURATION_MS),
439
+ easing: asEasing(v.timingFunction, themeTokens, DEFAULT_EASING)
440
+ };
441
+ }
442
+ if (type === "duration") {
443
+ const durationMs = extractDurationMs(token.$value);
444
+ if (!Number.isFinite(durationMs)) return null;
445
+ return {
446
+ durationMs,
447
+ easing: DEFAULT_EASING
448
+ };
449
+ }
450
+ if (type === "cubicBezier") {
451
+ const easing = extractCubicBezier(token.$value);
452
+ if (!easing) return null;
453
+ return {
454
+ durationMs: DEFAULT_DURATION_MS,
455
+ easing
456
+ };
457
+ }
458
+ return null;
459
+ }
460
+ /** Pure presentation + animation for a single motion token's sample. Renders from plain props. */
461
+ function MotionSampleView({ spec, speed, runKey }) {
462
+ const reducedMotion = usePrefersReducedMotion();
463
+ const durationMs = spec?.durationMs ?? DEFAULT_DURATION_MS;
464
+ const easing = spec?.easing ?? DEFAULT_EASING;
465
+ const scaledDuration = Math.max(1, durationMs / speed);
466
+ const [phase, setPhase] = useState(0);
467
+ useEffect(() => {
468
+ if (reducedMotion) return;
469
+ setPhase(0);
470
+ const id = requestAnimationFrame(() => setPhase(1));
471
+ const loop = window.setInterval(() => {
472
+ setPhase((p) => p === 0 ? 1 : 0);
473
+ }, scaledDuration * 2);
474
+ return () => {
475
+ cancelAnimationFrame(id);
476
+ window.clearInterval(loop);
477
+ };
478
+ }, [
479
+ scaledDuration,
480
+ runKey,
481
+ reducedMotion
482
+ ]);
483
+ if (reducedMotion) return /* @__PURE__ */ jsxs("div", {
484
+ className: "sb-motion-sample__reduced-motion",
485
+ children: [
486
+ "Animation suppressed by ",
487
+ /* @__PURE__ */ jsx("code", { children: "prefers-reduced-motion: reduce" }),
488
+ "."
489
+ ]
490
+ });
491
+ return /* @__PURE__ */ jsx("div", {
492
+ className: "sb-motion-sample__track",
493
+ children: /* @__PURE__ */ jsx("div", {
494
+ className: cx("sb-motion-sample__ball", phase === 1 ? "sb-motion-sample__ball--end" : "sb-motion-sample__ball--start"),
495
+ style: { transition: `left ${scaledDuration}ms ${easing}` },
496
+ "aria-hidden": true
497
+ })
498
+ });
499
+ }
500
+ /**
501
+ * Connected block: renders a realised motion token's animated sample. Both
502
+ * `durationMs` and `easing` come from the realised `$value`; the ball's
503
+ * setInterval loop needs a JS-readable duration, and (see the comment below)
504
+ * `cssVar` cannot stand in for `easing` here. `cssVar` is still accepted, per
505
+ * the uniform `PresenterProps` contract every presenter shares, but this
506
+ * View has nowhere safe to apply it.
507
+ */
508
+ function MotionSample({ token, cssVar: _cssVar, options }) {
509
+ const rawSpeed = options?.["speed"];
510
+ const speed = typeof rawSpeed === "number" ? rawSpeed : 1;
511
+ const rawRunKey = options?.["runKey"];
512
+ const runKey = typeof rawRunKey === "number" ? rawRunKey : 0;
513
+ return /* @__PURE__ */ jsx(MotionSampleView, {
514
+ spec: resolveMotionSpec(token, {}),
515
+ speed,
516
+ runKey
517
+ });
518
+ }
519
+ //#endregion
520
+ //#region src/presenters/OpacitySwatch.tsx
521
+ const DEFAULT_SAMPLE_COLOR_VAR = "var(--swatchbook-accent-bg)";
522
+ function opacityValueToCss(value) {
523
+ return typeof value === "number" ? String(value) : "1";
524
+ }
525
+ /** Pure presentation for a single opacity token's chip. Renders from plain props. */
526
+ function OpacitySwatchView({ opacity, sampleColorVar }) {
527
+ return /* @__PURE__ */ jsx("div", {
528
+ className: "sb-opacity-swatch",
529
+ children: /* @__PURE__ */ jsx("div", {
530
+ className: "sb-opacity-swatch__chip",
531
+ style: {
532
+ opacity,
533
+ background: sampleColorVar
534
+ },
535
+ "aria-hidden": true
536
+ })
537
+ });
538
+ }
539
+ /**
540
+ * Presenter for `$type: number` opacity tokens: a colored chip over a
541
+ * checkerboard backdrop, with the chip's `opacity` set from the token per
542
+ * R3. The number alone (`0.4`) doesn't convey what the token looks like
543
+ * applied to a surface; the chip does.
544
+ */
545
+ function OpacitySwatch({ token, cssVar, options }) {
546
+ const opacity = cssVar ?? opacityValueToCss(token.$value);
547
+ const rawSampleColorVar = options?.["sampleColorVar"];
548
+ return /* @__PURE__ */ jsx(OpacitySwatchView, {
549
+ opacity,
550
+ sampleColorVar: typeof rawSampleColorVar === "string" ? rawSampleColorVar : DEFAULT_SAMPLE_COLOR_VAR
551
+ });
552
+ }
553
+ //#endregion
554
+ //#region src/presenters/StrokeSample.tsx
555
+ const STRING_STYLES = new Set([
556
+ "solid",
557
+ "dashed",
558
+ "dotted",
559
+ "double",
560
+ "groove",
561
+ "ridge",
562
+ "outset",
563
+ "inset"
564
+ ]);
565
+ function isCssStyleKeyword(value) {
566
+ return typeof value === "string" && STRING_STYLES.has(value);
567
+ }
568
+ /** Pure presentation for a single stroke-style token's line. Renders from plain props. */
569
+ function StrokeSampleView({ cssStyle }) {
570
+ if (!cssStyle) return /* @__PURE__ */ jsx("span", {
571
+ className: "sb-stroke-sample__object-fallback",
572
+ children: "Object-form (dashArray + lineCap) — no pure CSS `border-style` equivalent."
573
+ });
574
+ return /* @__PURE__ */ jsx("div", {
575
+ className: "sb-stroke-sample__line",
576
+ style: { borderTopStyle: cssStyle },
577
+ "aria-hidden": true
578
+ });
579
+ }
580
+ /**
581
+ * Presenter for `$type: strokeStyle` tokens: a line styled with the
582
+ * token's `border-top-style` per R3, or the object-form fallback message
583
+ * when `$value` is a dash pattern with no pure CSS equivalent.
584
+ */
585
+ function StrokeSample({ token, cssVar }) {
586
+ return /* @__PURE__ */ jsx(StrokeSampleView, { cssStyle: isCssStyleKeyword(token.$value) ? cssVar ?? token.$value : null });
587
+ }
588
+ //#endregion
589
+ //#region src/presenters/TypeSpecimen.tsx
590
+ const DEFAULT_SAMPLE = "The quick brown fox jumps over the lazy dog.";
591
+ function asFontFamily(raw) {
592
+ if (typeof raw === "string") return raw;
593
+ if (Array.isArray(raw)) return raw.map(String).join(", ");
594
+ }
595
+ /**
596
+ * Builds the sample's inline style straight from a realised
597
+ * `typography.$value`. `fontSize`/`letterSpacing` share `formatLength` with
598
+ * the border/shadow presenters (same DTCG `{ value, unit }` dimension
599
+ * envelope); `fontWeight`/`lineHeight` are unitless and cast to strings
600
+ * directly.
601
+ */
602
+ function styleFromValue(value) {
603
+ const style = {};
604
+ const fontFamily = asFontFamily(value.fontFamily);
605
+ if (fontFamily) style.fontFamily = fontFamily;
606
+ if (value.fontSize != null) style.fontSize = formatLength(value.fontSize);
607
+ if (value.fontWeight != null) style.fontWeight = String(value.fontWeight);
608
+ if (value.lineHeight != null) style.lineHeight = String(value.lineHeight);
609
+ if (value.letterSpacing != null) style.letterSpacing = formatLength(value.letterSpacing);
610
+ return style;
611
+ }
612
+ function describeValue(value) {
613
+ return [
614
+ value.fontSize != null ? formatLength(value.fontSize) : void 0,
615
+ value.fontWeight != null ? `w${String(value.fontWeight)}` : void 0,
616
+ value.lineHeight != null ? `lh ${String(value.lineHeight)}` : void 0
617
+ ].filter(Boolean).join(" · ");
618
+ }
619
+ /**
620
+ * Presenter for `$type: typography` tokens: a spec summary and a sample line
621
+ * styled per R3. Token identity (the path) is the consuming block's
622
+ * responsibility, not this presenter's; see `TypographyScale`.
623
+ */
624
+ function TypeSpecimen({ token, cssVar: _cssVar, options }) {
625
+ const rawSample = options?.["sample"];
626
+ const sample = typeof rawSample === "string" ? rawSample : DEFAULT_SAMPLE;
627
+ const value = token.$value ?? {};
628
+ const sampleStyle = styleFromValue(value);
629
+ const specs = describeValue(value);
630
+ return /* @__PURE__ */ jsxs("div", {
631
+ className: "sb-type-specimen",
632
+ children: [specs && /* @__PURE__ */ jsx("span", {
633
+ className: "sb-type-specimen__description",
634
+ children: specs
635
+ }), /* @__PURE__ */ jsx("div", {
636
+ className: "sb-type-specimen__sample",
637
+ style: sampleStyle,
638
+ children: sample
639
+ })]
640
+ });
641
+ }
642
+ //#endregion
643
+ //#region src/shadow-preview/ShadowSample.tsx
644
+ /** Pure presentation for a single shadow token's sample. Renders from plain props. */
645
+ function ShadowSampleView({ cssVar }) {
646
+ return /* @__PURE__ */ jsx("div", {
647
+ className: "sb-shadow-sample",
648
+ style: { boxShadow: cssVar },
649
+ "aria-hidden": true
650
+ });
651
+ }
652
+ /**
653
+ * Builds a valid `box-shadow` value straight from a realised `shadow.$value`
654
+ * (single layer or array). Distinct from core's `formatTokenValue`, whose
655
+ * `raw` color branch emits a display-only JSON string that the browser
656
+ * would reject as a box-shadow color.
657
+ */
658
+ function shadowValueToCss(value, colorFormat) {
659
+ const layers = Array.isArray(value) ? value : [value];
660
+ const format = cssColorFormat(colorFormat);
661
+ return layers.map((layer) => {
662
+ if (!layer || typeof layer !== "object") return "";
663
+ const s = layer;
664
+ const pieces = [
665
+ formatLength(s.offsetX),
666
+ formatLength(s.offsetY),
667
+ formatLength(s.blur),
668
+ formatLength(s.spread),
669
+ formatColor(s.color, format).value
670
+ ];
671
+ if (s.inset) pieces.push("inset");
672
+ return pieces.join(" ");
673
+ }).filter((layer) => layer !== "").join(", ");
674
+ }
675
+ function ShadowSample({ token, cssVar, colorFormat }) {
676
+ return /* @__PURE__ */ jsx(ShadowSampleView, { cssVar: cssVar ?? shadowValueToCss(token.$value, colorFormat) });
677
+ }
678
+ //#endregion
679
+ //#region src/presenters/registry.ts
680
+ /**
681
+ * Built-in presenters, keyed by DTCG `$type`. Phase C registers each entry as
682
+ * its presenter lands. A consumer's `presenters` override merges over this.
683
+ */
684
+ const DEFAULT_PRESENTERS = {
685
+ color: ColorSwatch,
686
+ shadow: ShadowSample,
687
+ border: BorderSample,
688
+ dimension: DimensionSample,
689
+ gradient: GradientSwatch,
690
+ transition: MotionSample,
691
+ typography: TypeSpecimen,
692
+ fontFamily: FontFamilySpecimen,
693
+ fontWeight: FontWeightSpecimen,
694
+ number: OpacitySwatch,
695
+ strokeStyle: StrokeSample
696
+ };
697
+ /** Overrides win per `$type`; unlisted types keep the built-in. */
698
+ function mergePresenters(overrides) {
699
+ return overrides ? {
700
+ ...DEFAULT_PRESENTERS,
701
+ ...overrides
702
+ } : DEFAULT_PRESENTERS;
703
+ }
704
+ /** The active merged registry for the subtree. */
705
+ const PresenterContext = createContext(DEFAULT_PRESENTERS);
706
+ let ambientPresenters = DEFAULT_PRESENTERS;
707
+ /**
708
+ * Set the ambient presenter registry consulted by blocks that render with
709
+ * no `SwatchbookProvider` or `PresenterContext.Provider` above them (MDX
710
+ * doc blocks, autodocs). A host registers this once at preview-init; it is
711
+ * NOT reactive, so register before first render. Passing no argument (or
712
+ * `undefined`) resets to the built-ins. An explicit provider/context
713
+ * override still wins for its subtree; this only fills the provider-less
714
+ * gap.
715
+ */
716
+ function registerPresenters(overrides) {
717
+ ambientPresenters = mergePresenters(overrides);
718
+ }
719
+ /** The presenter for `type`, or `undefined` if none is registered. */
720
+ function usePresenter(type) {
721
+ const ctx = useContext(PresenterContext);
722
+ return (ctx === DEFAULT_PRESENTERS ? ambientPresenters : ctx)[type];
723
+ }
724
+ //#endregion
725
+ //#region src/host.ts
726
+ let source = {
727
+ axes: [],
728
+ presets: [],
729
+ diagnostics: [],
730
+ css: "",
731
+ cssVarPrefix: "",
732
+ indicators: {},
733
+ listing: {},
734
+ tokenGraph: {
735
+ nodes: {},
736
+ axes: [],
737
+ axisDefaults: {},
738
+ axisContexts: {}
739
+ },
740
+ defaultTuple: {},
741
+ defaultColorFormat: "hex",
742
+ activeAxes: null,
743
+ version: 0
744
+ };
745
+ const listeners = /* @__PURE__ */ new Set();
746
+ /**
747
+ * Host adapter API. Push a (partial) project snapshot into the ambient
748
+ * store. Omitted fields fall back to the current source, so a host can
749
+ * call this repeatedly with incremental patches (e.g. one per HMR event
750
+ * or axis flip) without re-sending the whole snapshot each time.
751
+ */
752
+ function registerProjectSource(patch) {
753
+ source = {
754
+ axes: patch.axes ?? source.axes,
755
+ presets: patch.presets ?? source.presets,
756
+ diagnostics: patch.diagnostics ?? source.diagnostics,
757
+ css: patch.css ?? source.css,
758
+ cssVarPrefix: patch.cssVarPrefix ?? source.cssVarPrefix,
759
+ indicators: patch.indicators ?? source.indicators,
760
+ listing: patch.listing ?? source.listing,
761
+ tokenGraph: patch.tokenGraph ?? source.tokenGraph,
762
+ defaultTuple: patch.defaultTuple ?? source.defaultTuple,
763
+ defaultColorFormat: patch.defaultColorFormat ?? source.defaultColorFormat,
764
+ activeAxes: "activeAxes" in patch ? patch.activeAxes ?? null : source.activeAxes,
765
+ version: source.version + 1
766
+ };
767
+ for (const cb of listeners) cb();
768
+ }
769
+ function subscribe(cb) {
770
+ listeners.add(cb);
771
+ return () => {
772
+ listeners.delete(cb);
773
+ };
774
+ }
775
+ function getSnapshot() {
776
+ return source;
777
+ }
778
+ function getServerSnapshot() {
779
+ return source;
780
+ }
781
+ /**
782
+ * Host adapter API. Read the live, ambient project source. Re-renders in
783
+ * place on each {@link registerProjectSource} call.
784
+ */
785
+ function useProjectSource() {
786
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
787
+ }
788
+ //#endregion
789
+ export { COLOR_FORMATS as C, BorderSample as S, cssVarAsNumber as _, mergePresenters as a, useRootFontSize as b, ShadowSample as c, OpacitySwatch as d, MotionSample as f, FontWeightSpecimen as g, GradientSwatch as h, PresenterContext as i, TypeSpecimen as l, usePrefersReducedMotion as m, useProjectSource as n, registerPresenters as o, resolveMotionSpec as p, DEFAULT_PRESENTERS as r, usePresenter as s, registerProjectSource as t, StrokeSample as u, FontFamilySpecimen as v, formatColor as w, ColorSwatch as x, DimensionSample as y };
790
+
791
+ //# sourceMappingURL=host-e2nSv-sA.mjs.map