@unpunnyfuns/swatchbook-blocks 0.61.0 → 0.62.1

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/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import './style.css';
2
- import Color from "colorjs.io";
2
+ import { COLOR_FORMATS } from "@unpunnyfuns/swatchbook-core/color-formats";
3
+ import { formatColor, parseColor } from "@unpunnyfuns/swatchbook-core/format-color";
3
4
  import { createContext, memo, useCallback, useContext, useDeferredValue, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
6
  import { getVariance, listPaths, resolveAllAt } from "@unpunnyfuns/swatchbook-core/graph";
@@ -7,183 +8,10 @@ import { makeCssVar } from "@unpunnyfuns/swatchbook-core/css-var";
7
8
  import { SWATCHBOOK_STYLE_ELEMENT_ID, ensureStyleElement } from "@unpunnyfuns/swatchbook-core/style-element";
8
9
  import { tupleToName } from "@unpunnyfuns/swatchbook-core/themes";
9
10
  import { addons } from "storybook/preview-api";
10
- import { axes, css, cssVarPrefix, defaultTuple, diagnostics, listing, presets, tokenGraph } from "virtual:swatchbook/tokens";
11
11
  import { dataAttr } from "@unpunnyfuns/swatchbook-core/data-attr";
12
12
  import { matchPath } from "@unpunnyfuns/swatchbook-core/match-path";
13
13
  import { fuzzyFilter } from "@unpunnyfuns/swatchbook-core/fuzzy";
14
14
  import cx from "clsx";
15
- //#region src/format-color.ts
16
- const COLOR_FORMATS = [
17
- "hex",
18
- "rgb",
19
- "hsl",
20
- "oklch",
21
- "raw"
22
- ];
23
- const DEFAULT_FALLBACK = "—";
24
- /**
25
- * Convert Terrazzo's normalized color payload into a display string in the
26
- * requested format. Pure function — never throws; returns `{ value: '—' }`
27
- * for unrecognized input so calling blocks don't need try/catch.
28
- */
29
- function formatColor(value, format, fallback = DEFAULT_FALLBACK) {
30
- const normalized = coerce(value);
31
- if (!normalized) return {
32
- value: stringifyFallback(value, fallback),
33
- outOfGamut: false
34
- };
35
- if (format === "raw") return {
36
- value: compactJson(normalized),
37
- outOfGamut: false
38
- };
39
- const color = toColor(normalized);
40
- if (!color) return {
41
- value: stringifyFallback(value, fallback),
42
- outOfGamut: false
43
- };
44
- const alpha = typeof normalized.alpha === "number" ? normalized.alpha : 1;
45
- if (format === "hex") return formatHex(color, alpha);
46
- if (format === "rgb") return formatRgb(color, alpha);
47
- if (format === "hsl") return formatHsl(color, alpha);
48
- return formatOklch(color, alpha);
49
- }
50
- function coerce(value) {
51
- if (!value || typeof value !== "object") return null;
52
- const v = value;
53
- const colorSpace = typeof v.colorSpace === "string" ? v.colorSpace : void 0;
54
- const components = Array.isArray(v.components) ? v.components : Array.isArray(v.channels) ? v.channels : void 0;
55
- if (!colorSpace || !components) {
56
- if (typeof v.hex === "string") return {
57
- colorSpace: "srgb",
58
- components: hexToComponents(v.hex)
59
- };
60
- return null;
61
- }
62
- const alpha = typeof v.alpha === "number" ? v.alpha : void 0;
63
- const hexVal = v["hex"];
64
- const hex = typeof hexVal === "string" ? hexVal : void 0;
65
- return {
66
- colorSpace,
67
- components,
68
- ...alpha !== void 0 && { alpha },
69
- ...hex !== void 0 && { hex }
70
- };
71
- }
72
- function hexToComponents(hex) {
73
- const h = hex.replace("#", "");
74
- const expanded = h.length === 3 || h.length === 4 ? h.split("").map((c) => c + c).join("") : h;
75
- return [
76
- parseInt(expanded.slice(0, 2), 16) / 255,
77
- parseInt(expanded.slice(2, 4), 16) / 255,
78
- parseInt(expanded.slice(4, 6), 16) / 255
79
- ];
80
- }
81
- const COLORJS_SPACE_ALIASES = {
82
- "display-p3": "p3",
83
- "a98-rgb": "a98rgb",
84
- "prophoto-rgb": "prophoto"
85
- };
86
- function toColor(normalized) {
87
- const source = normalized.components ?? normalized.channels ?? [];
88
- const coords = [
89
- numberOrZero(source[0]),
90
- numberOrZero(source[1]),
91
- numberOrZero(source[2])
92
- ];
93
- const space = COLORJS_SPACE_ALIASES[normalized.colorSpace] ?? normalized.colorSpace;
94
- try {
95
- return new Color(space, coords, normalized.alpha ?? 1);
96
- } catch {
97
- return null;
98
- }
99
- }
100
- function numberOrZero(n) {
101
- return typeof n === "number" && !Number.isNaN(n) ? n : 0;
102
- }
103
- function coord(color, i) {
104
- const c = color.coords[i];
105
- return typeof c === "number" && !Number.isNaN(c) ? c : 0;
106
- }
107
- function formatHex(color, alpha) {
108
- const srgb = color.to("srgb");
109
- if (!srgb.inGamut("srgb")) return {
110
- value: formatRgb(color, alpha).value,
111
- outOfGamut: true
112
- };
113
- const r = unitToByte(coord(srgb, 0));
114
- const g = unitToByte(coord(srgb, 1));
115
- const b = unitToByte(coord(srgb, 2));
116
- const base = `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
117
- if (alpha >= 1) return {
118
- value: base,
119
- outOfGamut: false
120
- };
121
- return {
122
- value: `${base}${toHexByte(unitToByte(alpha))}`,
123
- outOfGamut: false
124
- };
125
- }
126
- function formatRgb(color, alpha) {
127
- const srgb = color.to("srgb");
128
- const inGamut = srgb.inGamut("srgb");
129
- const body = `${Math.round(clampUnit(coord(srgb, 0)) * 255)} ${Math.round(clampUnit(coord(srgb, 1)) * 255)} ${Math.round(clampUnit(coord(srgb, 2)) * 255)}`;
130
- return {
131
- value: alpha >= 1 ? `rgb(${body})` : `rgb(${body} / ${roundAlpha(alpha)})`,
132
- outOfGamut: !inGamut
133
- };
134
- }
135
- function formatHsl(color, alpha) {
136
- const hsl = color.to("hsl");
137
- const inGamut = color.to("srgb").inGamut("srgb");
138
- const body = `${roundHue(coord(hsl, 0))} ${roundPercent(coord(hsl, 1))}% ${roundPercent(coord(hsl, 2))}%`;
139
- return {
140
- value: alpha >= 1 ? `hsl(${body})` : `hsl(${body} / ${roundAlpha(alpha)})`,
141
- outOfGamut: !inGamut
142
- };
143
- }
144
- function formatOklch(color, alpha) {
145
- const oklch = color.to("oklch");
146
- const body = `${roundTo(coord(oklch, 0), 3)} ${roundTo(coord(oklch, 1), 3)} ${roundTo(coord(oklch, 2), 2)}`;
147
- return {
148
- value: alpha >= 1 ? `oklch(${body})` : `oklch(${body} / ${roundAlpha(alpha)})`,
149
- outOfGamut: false
150
- };
151
- }
152
- function unitToByte(n) {
153
- return Math.max(0, Math.min(255, Math.round(n * 255)));
154
- }
155
- function clampUnit(n) {
156
- return Math.max(0, Math.min(1, n));
157
- }
158
- function toHexByte(n) {
159
- return n.toString(16).padStart(2, "0");
160
- }
161
- function roundTo(n, digits) {
162
- const f = 10 ** digits;
163
- return Math.round(n * f) / f;
164
- }
165
- function roundHue(h) {
166
- return roundTo((h % 360 + 360) % 360, 1);
167
- }
168
- function roundPercent(n) {
169
- return Math.round(n * 10) / 10;
170
- }
171
- function roundAlpha(a) {
172
- return roundTo(a, 3);
173
- }
174
- function compactJson(value) {
175
- const parts = [`"colorSpace":${JSON.stringify(value.colorSpace)}`];
176
- const components = value.components ?? value.channels;
177
- if (components) parts.push(`"components":[${components.map((c) => c === null ? "null" : c).join(", ")}]`);
178
- if (typeof value.alpha === "number" && value.alpha !== 1) parts.push(`"alpha":${value.alpha}`);
179
- return `{ ${parts.join(", ")} }`;
180
- }
181
- function stringifyFallback(value, fallback) {
182
- if (value == null) return fallback;
183
- if (typeof value === "string" || typeof value === "number") return String(value);
184
- return fallback;
185
- }
186
- //#endregion
187
15
  //#region src/internal/styles.tsx
188
16
  /**
189
17
  * Chrome-style primitives shared across every block. Kept as JS exports
@@ -319,49 +147,66 @@ function useColorFormat() {
319
147
  /**
320
148
  * Live token snapshot backed by the addon's preview dev-time HMR event.
321
149
  *
322
- * Blocks read the virtual module at module load; without a way to notice
323
- * changes, edits to the source token files would flow into the addon's
324
- * in-memory project but nowhere else the React tree would keep
325
- * rendering the old values until a full preview reload. This module
326
- * subscribes to `TOKENS_UPDATED_EVENT` on Storybook's channel (which the
327
- * addon preview re-broadcasts from its own HMR listener) and exposes
328
- * the latest snapshot via `useSyncExternalStore`, so hooks that read
329
- * through this module re-render in place on each token save.
150
+ * The initial snapshot is *injected* by the addon preview via
151
+ * {@link registerTokenSource} rather than imported from the addon's
152
+ * `virtual:swatchbook/tokens` build artifactso blocks carries no
153
+ * dependency on that module and imports cleanly standalone (outside
154
+ * Storybook, in unit tests, in the docs site). Until something registers
155
+ * a source, blocks render from empty defaults.
330
156
  *
331
- * Outside the preview iframe (the docs-site path, unit tests) the
332
- * channel never receives anything, and consumers keep seeing the
333
- * initial values baked into the virtual module at build time.
157
+ * For dev-time updates this module subscribes to `TOKENS_UPDATED_EVENT`
158
+ * on Storybook's channel (which the addon preview re-broadcasts from its
159
+ * own HMR listener) and exposes the latest snapshot via
160
+ * `useSyncExternalStore`, so hooks re-render in place on each token save.
334
161
  */
335
162
  const TOKENS_UPDATED_EVENT = "swatchbook/tokens-updated";
336
163
  let snapshot = {
337
- axes,
338
- presets,
339
- diagnostics,
340
- css,
341
- cssVarPrefix,
342
- listing: listing ?? {},
343
- tokenGraph,
344
- defaultTuple: defaultTuple ?? {},
164
+ axes: [],
165
+ presets: [],
166
+ diagnostics: [],
167
+ css: "",
168
+ cssVarPrefix: "",
169
+ listing: {},
170
+ tokenGraph: {
171
+ nodes: {},
172
+ axes: [],
173
+ axisDefaults: {},
174
+ axisContexts: {}
175
+ },
176
+ defaultTuple: {},
345
177
  version: 0
346
178
  };
347
179
  const listeners = /* @__PURE__ */ new Set();
348
180
  let subscribed = false;
181
+ function applyPatch(patch) {
182
+ snapshot = {
183
+ axes: patch.axes ?? snapshot.axes,
184
+ presets: patch.presets ?? snapshot.presets,
185
+ diagnostics: patch.diagnostics ?? snapshot.diagnostics,
186
+ css: patch.css ?? snapshot.css,
187
+ cssVarPrefix: patch.cssVarPrefix ?? snapshot.cssVarPrefix,
188
+ listing: patch.listing ?? snapshot.listing,
189
+ tokenGraph: patch.tokenGraph ?? snapshot.tokenGraph,
190
+ defaultTuple: patch.defaultTuple ?? snapshot.defaultTuple,
191
+ version: snapshot.version + 1
192
+ };
193
+ for (const cb of listeners) cb();
194
+ }
195
+ /**
196
+ * Seed the initial token snapshot. The addon preview calls this once at
197
+ * init with the build-time `virtual:swatchbook/tokens` data. Keeping the
198
+ * virtual-module read on the addon side (the package that owns it) lets
199
+ * blocks import cleanly without it. No-op fields fall back to the current
200
+ * snapshot, so a partial source is safe.
201
+ */
202
+ function registerTokenSource(source) {
203
+ applyPatch(source);
204
+ }
349
205
  function ensureSubscribed() {
350
206
  if (subscribed || typeof window === "undefined") return;
351
207
  subscribed = true;
352
208
  addons.getChannel().on(TOKENS_UPDATED_EVENT, (payload) => {
353
- snapshot = {
354
- axes: payload.axes ?? snapshot.axes,
355
- presets: payload.presets ?? snapshot.presets,
356
- diagnostics: payload.diagnostics ?? snapshot.diagnostics,
357
- css: payload.css ?? snapshot.css,
358
- cssVarPrefix: payload.cssVarPrefix ?? snapshot.cssVarPrefix,
359
- listing: payload.listing ?? snapshot.listing,
360
- tokenGraph: payload.tokenGraph ?? snapshot.tokenGraph,
361
- defaultTuple: payload.defaultTuple ?? snapshot.defaultTuple,
362
- version: snapshot.version + 1
363
- };
364
- for (const cb of listeners) cb();
209
+ applyPatch(payload);
365
210
  });
366
211
  }
367
212
  ensureSubscribed();
@@ -383,10 +228,16 @@ function useTokenSnapshot() {
383
228
  }
384
229
  //#endregion
385
230
  //#region src/internal/use-project.ts
231
+ function computeVarianceByPath(graph) {
232
+ if (!graph) return {};
233
+ const out = {};
234
+ for (const path of listPaths(graph)) out[path] = getVariance(graph, path);
235
+ return out;
236
+ }
386
237
  function ensureStylesheet(css) {
387
238
  ensureStyleElement(SWATCHBOOK_STYLE_ELEMENT_ID, css);
388
239
  }
389
- function defaultTuple$1(axes) {
240
+ function defaultTuple(axes) {
390
241
  const out = {};
391
242
  for (const axis of axes) out[axis.name] = axis.default;
392
243
  return out;
@@ -424,12 +275,7 @@ function useProject() {
424
275
  if (!snapshot) return null;
425
276
  return snapshotResolveAt(snapshot);
426
277
  }, [tokenGraph, activeTheme]);
427
- const derivedVarianceByPath = useMemo(() => {
428
- if (!tokenGraph) return {};
429
- const out = {};
430
- for (const path of listPaths(tokenGraph)) out[path] = getVariance(tokenGraph, path);
431
- return out;
432
- }, [tokenGraph]);
278
+ const derivedVarianceByPath = useMemo(() => computeVarianceByPath(tokenGraph), [tokenGraph]);
433
279
  const providerData = useMemo(() => {
434
280
  if (!snapshot || !resolveAt || !axes || !activeAxes) return null;
435
281
  return {
@@ -468,7 +314,7 @@ function useVirtualModuleFallback(enabled) {
468
314
  ensureStylesheet(tokens.css);
469
315
  }, [enabled, tokens.css]);
470
316
  const activeAxes = useMemo(() => {
471
- return Object.keys(contextAxes).length > 0 ? { ...contextAxes } : channelGlobals.axes ?? defaultTuple$1(tokens.axes);
317
+ return Object.keys(contextAxes).length > 0 ? { ...contextAxes } : channelGlobals.axes ?? defaultTuple(tokens.axes);
472
318
  }, [
473
319
  contextAxes,
474
320
  channelGlobals.axes,
@@ -476,13 +322,7 @@ function useVirtualModuleFallback(enabled) {
476
322
  ]);
477
323
  const activeTheme = contextThemeName || tupleToName(tokens.axes, activeAxes);
478
324
  const resolveAt = useMemo(() => makeResolveAt(tokens.tokenGraph), [tokens.tokenGraph]);
479
- const fallbackVarianceByPath = useMemo(() => {
480
- const graph = tokens.tokenGraph;
481
- if (!graph) return {};
482
- const out = {};
483
- for (const path of listPaths(graph)) out[path] = getVariance(graph, path);
484
- return out;
485
- }, [tokens.tokenGraph]);
325
+ const fallbackVarianceByPath = useMemo(() => computeVarianceByPath(tokens.tokenGraph), [tokens.tokenGraph]);
486
326
  return useMemo(() => ({
487
327
  activeTheme,
488
328
  activeAxes,
@@ -558,6 +398,32 @@ function BorderSample({ path }) {
558
398
  });
559
399
  }
560
400
  //#endregion
401
+ //#region src/internal/composite-sample-format.ts
402
+ /**
403
+ * Display a composite sub-field dimension (shadow offset / blur / spread,
404
+ * border width, …) in the preview tables. Renders `—` for a missing
405
+ * sub-field and falls back to JSON for shapes it doesn't recognize.
406
+ *
407
+ * Distinct from `format-token-value`'s internal `formatDimension`, which
408
+ * formats a token's top-level value and has no `—` placeholder — these are
409
+ * the per-layer sample formatters shared by `ShadowPreview` + `BorderPreview`.
410
+ */
411
+ function formatDimension$1(raw) {
412
+ if (raw == null) return "—";
413
+ if (typeof raw === "number") return String(raw);
414
+ if (typeof raw === "string") return raw;
415
+ if (typeof raw === "object") {
416
+ const v = raw;
417
+ if (typeof v.value === "number" && typeof v.unit === "string") return `${v.value}${v.unit}`;
418
+ }
419
+ return JSON.stringify(raw);
420
+ }
421
+ /** Display a composite sub-field color via the active format; `—` when absent. */
422
+ function formatSubColor(raw, format) {
423
+ if (raw == null) return "—";
424
+ return formatColor(raw, format).value;
425
+ }
426
+ //#endregion
561
427
  //#region src/internal/data-attr.ts
562
428
  /**
563
429
  * Marker attribute set on every block wrapper. Retained as a stable hook
@@ -692,20 +558,10 @@ function safeNumber(v) {
692
558
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
693
559
  }
694
560
  function colorKey(v) {
695
- if (!v || typeof v !== "object") return null;
561
+ const color = parseColor(v);
562
+ if (!color) return null;
696
563
  try {
697
- const c = v;
698
- let source;
699
- if (typeof c.hex === "string") source = c.hex;
700
- else if (typeof c.colorSpace === "string") {
701
- const channels = Array.isArray(c.components) ? c.components : Array.isArray(c.channels) ? c.channels : void 0;
702
- if (!channels) return null;
703
- source = {
704
- space: c.colorSpace,
705
- coords: channels
706
- };
707
- } else return null;
708
- const [l, chroma, h] = new Color(source).to("oklch").coords;
564
+ const [l, chroma, h] = color.to("oklch").coords;
709
565
  return {
710
566
  l: safeNumber(l),
711
567
  c: safeNumber(chroma),
@@ -723,20 +579,6 @@ function toDisplayable(v) {
723
579
  }
724
580
  //#endregion
725
581
  //#region src/BorderPreview.tsx
726
- function formatDimension$2(raw) {
727
- if (raw == null) return "—";
728
- if (typeof raw === "number") return String(raw);
729
- if (typeof raw === "string") return raw;
730
- if (typeof raw === "object") {
731
- const v = raw;
732
- if (typeof v.value === "number" && typeof v.unit === "string") return `${v.value}${v.unit}`;
733
- }
734
- return JSON.stringify(raw);
735
- }
736
- function formatSubColor$1(raw, format) {
737
- if (raw == null) return "—";
738
- return formatColor(raw, format).value;
739
- }
740
582
  function BorderPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
741
583
  const project = useProject();
742
584
  const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
@@ -797,7 +639,7 @@ function BorderPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
797
639
  className: "sb-border-preview__breakdown-key",
798
640
  children: "width"
799
641
  }),
800
- /* @__PURE__ */ jsx("span", { children: formatDimension$2(row.value.width) }),
642
+ /* @__PURE__ */ jsx("span", { children: formatDimension$1(row.value.width) }),
801
643
  /* @__PURE__ */ jsx("span", {
802
644
  className: "sb-border-preview__breakdown-key",
803
645
  children: "style"
@@ -807,7 +649,7 @@ function BorderPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
807
649
  className: "sb-border-preview__breakdown-key",
808
650
  children: "color"
809
651
  }),
810
- /* @__PURE__ */ jsx("span", { children: formatSubColor$1(row.value.color, colorFormat) })
652
+ /* @__PURE__ */ jsx("span", { children: formatSubColor(row.value.color, colorFormat) })
811
653
  ]
812
654
  })
813
655
  ]
@@ -1508,8 +1350,26 @@ function Diagnostics({ caption } = {}) {
1508
1350
  });
1509
1351
  }
1510
1352
  //#endregion
1353
+ //#region src/dimension-scale/dimension-px.ts
1354
+ /**
1355
+ * Convert a DTCG dimension `$value` (`{ value, unit }`) to pixels for the
1356
+ * purpose of deciding whether to cap the rendered size. Returns `NaN` for
1357
+ * units we can't reasonably approximate (ex / ch / %), which the caller
1358
+ * treats as "render at cssVar but don't cap".
1359
+ */
1360
+ function toPixels(raw) {
1361
+ if (raw == null || typeof raw !== "object") return NaN;
1362
+ const v = raw;
1363
+ if (typeof v.value !== "number" || typeof v.unit !== "string") return NaN;
1364
+ switch (v.unit) {
1365
+ case "px": return v.value;
1366
+ case "rem":
1367
+ case "em": return v.value * 16;
1368
+ default: return NaN;
1369
+ }
1370
+ }
1371
+ //#endregion
1511
1372
  //#region src/dimension-scale/DimensionBar.tsx
1512
- const MAX_RENDER_PX$1 = 480;
1513
1373
  const styles$1 = {
1514
1374
  bar: {
1515
1375
  height: 14,
@@ -1530,24 +1390,13 @@ const styles$1 = {
1530
1390
  minHeight: 1
1531
1391
  }
1532
1392
  };
1533
- function toPixels$1(raw) {
1534
- if (raw == null || typeof raw !== "object") return NaN;
1535
- const v = raw;
1536
- if (typeof v.value !== "number" || typeof v.unit !== "string") return NaN;
1537
- switch (v.unit) {
1538
- case "px": return v.value;
1539
- case "rem":
1540
- case "em": return v.value * 16;
1541
- default: return NaN;
1542
- }
1543
- }
1544
1393
  function DimensionBar({ path, visual = "length" }) {
1545
1394
  const project = useProject();
1546
1395
  const { resolved } = project;
1547
1396
  const cssVar = resolveCssVar(path, project);
1548
1397
  const token = resolved[path];
1549
- const pxValue = toPixels$1(token?.$value);
1550
- const cappedValue = Number.isFinite(pxValue) && pxValue > MAX_RENDER_PX$1 ? `${MAX_RENDER_PX$1}px` : cssVar;
1398
+ const pxValue = toPixels(token?.$value);
1399
+ const cappedValue = Number.isFinite(pxValue) && pxValue > 480 ? `480px` : cssVar;
1551
1400
  switch (visual) {
1552
1401
  case "radius": return /* @__PURE__ */ jsx("div", {
1553
1402
  style: {
@@ -1593,7 +1442,7 @@ function formatTokenValue(value, $type, colorFormat, listingEntry) {
1593
1442
  switch ($type) {
1594
1443
  case "color": return formatColor(value, colorFormat).value;
1595
1444
  case "dimension":
1596
- case "duration": return formatDimension$1(value);
1445
+ case "duration": return formatDimension(value);
1597
1446
  case "fontFamily": return formatFontFamily$1(value);
1598
1447
  case "fontWeight":
1599
1448
  case "lineHeight":
@@ -1607,7 +1456,7 @@ function formatTokenValue(value, $type, colorFormat, listingEntry) {
1607
1456
  default: return formatUnknown(value);
1608
1457
  }
1609
1458
  }
1610
- function formatDimension$1(v) {
1459
+ function formatDimension(v) {
1611
1460
  if (typeof v === "string" || typeof v === "number") return String(v);
1612
1461
  if (v && typeof v === "object") {
1613
1462
  const d = v;
@@ -1636,7 +1485,7 @@ function formatStrokeStyle(v) {
1636
1485
  if (v && typeof v === "object") {
1637
1486
  const s = v;
1638
1487
  const parts = ["dashed"];
1639
- if (Array.isArray(s.dashArray)) parts.push(s.dashArray.map((n) => formatDimension$1(n)).join(" "));
1488
+ if (Array.isArray(s.dashArray)) parts.push(s.dashArray.map((n) => formatDimension(n)).join(" "));
1640
1489
  if (typeof s.lineCap === "string") parts.push(s.lineCap);
1641
1490
  return parts.join(" · ");
1642
1491
  }
@@ -1647,10 +1496,10 @@ function formatShadow(v, colorFormat) {
1647
1496
  if (!layer || typeof layer !== "object") return formatUnknown(layer);
1648
1497
  const s = layer;
1649
1498
  const pieces = [
1650
- formatDimension$1(s.offsetX),
1651
- formatDimension$1(s.offsetY),
1652
- formatDimension$1(s.blur),
1653
- formatDimension$1(s.spread),
1499
+ formatDimension(s.offsetX),
1500
+ formatDimension(s.offsetY),
1501
+ formatDimension(s.blur),
1502
+ formatDimension(s.spread),
1654
1503
  formatColor(s.color, colorFormat).value
1655
1504
  ].filter((p) => p !== "");
1656
1505
  if (s.inset) pieces.push("inset");
@@ -1661,7 +1510,7 @@ function formatBorder(v, colorFormat) {
1661
1510
  if (!v || typeof v !== "object") return formatUnknown(v);
1662
1511
  const b = v;
1663
1512
  return [
1664
- formatDimension$1(b.width),
1513
+ formatDimension(b.width),
1665
1514
  formatPrimitive$1(b.style),
1666
1515
  formatColor(b.color, colorFormat).value
1667
1516
  ].filter((p) => p !== "").join(" ");
@@ -1677,18 +1526,6 @@ function formatUnknown(v) {
1677
1526
  }
1678
1527
  //#endregion
1679
1528
  //#region src/DimensionScale.tsx
1680
- const MAX_RENDER_PX = 480;
1681
- function toPixels(raw) {
1682
- if (raw == null || typeof raw !== "object") return NaN;
1683
- const v = raw;
1684
- if (typeof v.value !== "number" || typeof v.unit !== "string") return NaN;
1685
- switch (v.unit) {
1686
- case "px": return v.value;
1687
- case "rem":
1688
- case "em": return v.value * 16;
1689
- default: return NaN;
1690
- }
1691
- }
1692
1529
  function DimensionScale({ filter, visual = "length", caption, sortBy = "value", sortDir = "asc" }) {
1693
1530
  const project = useProject();
1694
1531
  const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
@@ -1706,7 +1543,7 @@ function DimensionScale({ filter, visual = "length", caption, sortBy = "value",
1706
1543
  cssVar: resolveCssVar(path, project),
1707
1544
  displayValue: formatTokenValue(token.$value, token.$type, "raw", project.listing[path]),
1708
1545
  pxValue,
1709
- capped: Number.isFinite(pxValue) && pxValue > MAX_RENDER_PX
1546
+ capped: Number.isFinite(pxValue) && pxValue > 480
1710
1547
  };
1711
1548
  });
1712
1549
  }, [
@@ -1751,7 +1588,7 @@ function DimensionScale({ filter, visual = "length", caption, sortBy = "value",
1751
1588
  className: "sb-dimension-scale__cap",
1752
1589
  children: [
1753
1590
  "capped at ",
1754
- MAX_RENDER_PX,
1591
+ 480,
1755
1592
  "px"
1756
1593
  ]
1757
1594
  })]
@@ -1925,14 +1762,10 @@ function asStops(raw) {
1925
1762
  if (!Array.isArray(raw)) return [];
1926
1763
  return raw;
1927
1764
  }
1928
- const pct = (n) => `${(n * 100).toFixed(3)}%`;
1929
1765
  function stopCssColor(stop) {
1930
- const color = stop.color;
1931
- if (!color || !Array.isArray(color.components) || color.components.length < 3) return "transparent";
1932
- const [r, g, b] = color.components;
1933
- if (r === void 0 || g === void 0 || b === void 0) return "transparent";
1934
- const alpha = color.alpha ?? 1;
1935
- return alpha === 1 ? `rgb(${pct(r)} ${pct(g)} ${pct(b)})` : `rgb(${pct(r)} ${pct(g)} ${pct(b)} / ${alpha})`;
1766
+ const color = parseColor(stop.color);
1767
+ if (!color) return "transparent";
1768
+ return color.toString();
1936
1769
  }
1937
1770
  function stopKey(path, stop, fallback) {
1938
1771
  return `${path}|${stop.position ?? fallback}|${stopCssColor(stop)}`;
@@ -2445,27 +2278,13 @@ function ShadowSample({ path }) {
2445
2278
  }
2446
2279
  //#endregion
2447
2280
  //#region src/ShadowPreview.tsx
2448
- function formatDimension(raw) {
2449
- if (raw == null) return "—";
2450
- if (typeof raw === "number") return String(raw);
2451
- if (typeof raw === "string") return raw;
2452
- if (typeof raw === "object") {
2453
- const v = raw;
2454
- if (typeof v.value === "number" && typeof v.unit === "string") return `${v.value}${v.unit}`;
2455
- }
2456
- return JSON.stringify(raw);
2457
- }
2458
- function formatSubColor(raw, format) {
2459
- if (raw == null) return "—";
2460
- return formatColor(raw, format).value;
2461
- }
2462
2281
  function asLayers(raw) {
2463
2282
  if (Array.isArray(raw)) return raw;
2464
2283
  if (raw && typeof raw === "object") return [raw];
2465
2284
  return [];
2466
2285
  }
2467
2286
  function layerKey(path, layer, fallback) {
2468
- return `${path}|${`${formatDimension(layer.offsetX)},${formatDimension(layer.offsetY)}`}|${formatDimension(layer.blur)}|${formatDimension(layer.spread)}|${fallback}`;
2287
+ return `${path}|${`${formatDimension$1(layer.offsetX)},${formatDimension$1(layer.offsetY)}`}|${formatDimension$1(layer.blur)}|${formatDimension$1(layer.spread)}|${fallback}`;
2469
2288
  }
2470
2289
  function ShadowPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
2471
2290
  const project = useProject();
@@ -2536,9 +2355,9 @@ function ShadowPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
2536
2355
  function renderLayer(layer, format) {
2537
2356
  if (!layer) return [];
2538
2357
  const entries = [
2539
- ["offset", `${formatDimension(layer.offsetX)} ${formatDimension(layer.offsetY)}`],
2540
- ["blur", formatDimension(layer.blur)],
2541
- ["spread", formatDimension(layer.spread)],
2358
+ ["offset", `${formatDimension$1(layer.offsetX)} ${formatDimension$1(layer.offsetY)}`],
2359
+ ["blur", formatDimension$1(layer.blur)],
2360
+ ["spread", formatDimension$1(layer.spread)],
2542
2361
  ["color", formatSubColor(layer.color, format)]
2543
2362
  ];
2544
2363
  if (layer.inset) entries.push(["inset", String(layer.inset)]);
@@ -2744,15 +2563,13 @@ function AliasedByRow({ node, depth }) {
2744
2563
  function buildAliasedByTree(rootPath, resolved) {
2745
2564
  const direct = resolved[rootPath]?.aliasedBy;
2746
2565
  if (!direct || direct.length === 0) return [];
2747
- const visited = new Set([rootPath]);
2748
- return sortPaths(direct).map((p) => walk(p, resolved, visited, 1));
2566
+ return sortPaths(direct).map((p) => walk(p, resolved, new Set([rootPath]), 1));
2749
2567
  }
2750
- function walk(path, resolved, visited, depth) {
2751
- if (visited.has(path)) return {
2568
+ function walk(path, resolved, ancestors, depth) {
2569
+ if (ancestors.has(path)) return {
2752
2570
  path,
2753
2571
  children: []
2754
2572
  };
2755
- visited.add(path);
2756
2573
  const parents = resolved[path]?.aliasedBy;
2757
2574
  if (!parents || parents.length === 0) return {
2758
2575
  path,
@@ -2763,9 +2580,10 @@ function walk(path, resolved, visited, depth) {
2763
2580
  children: [],
2764
2581
  truncated: true
2765
2582
  };
2583
+ const childAncestors = new Set(ancestors).add(path);
2766
2584
  return {
2767
2585
  path,
2768
- children: sortPaths(parents).map((p) => walk(p, resolved, visited, depth + 1))
2586
+ children: sortPaths(parents).map((p) => walk(p, resolved, childAncestors, depth + 1))
2769
2587
  };
2770
2588
  }
2771
2589
  function sortPaths(paths) {
@@ -3194,6 +3012,35 @@ function gradientStopKey(stop, fallback) {
3194
3012
  return `stop|${stop.position ?? fallback}|${JSON.stringify(stop.color)}`;
3195
3013
  }
3196
3014
  //#endregion
3015
+ //#region src/token-detail/transition-duration.ts
3016
+ /**
3017
+ * Numeric duration (ms) the motion preview should animate over for a given
3018
+ * token type, read from its resolved `$value`. Lets the sample's toggle loop
3019
+ * match the token's real duration instead of a fixed cadence — a long token
3020
+ * (say 2s) otherwise reverses mid-move under the old hardcoded interval.
3021
+ * Returns `undefined` for types that carry no duration, so the caller can
3022
+ * fall back to its default.
3023
+ */
3024
+ function transitionDurationMs(type, rawValue) {
3025
+ if (type === "cubicBezier") return 800;
3026
+ if (type === "duration") return parseDurationMs(rawValue);
3027
+ if (type === "transition" && rawValue !== null && typeof rawValue === "object") return parseDurationMs(rawValue.duration);
3028
+ }
3029
+ function parseDurationMs(v) {
3030
+ if (typeof v === "number") return v;
3031
+ if (typeof v === "string") {
3032
+ const m = /^([\d.]+)(ms|s)?$/.exec(v.trim());
3033
+ if (!m?.[1]) return void 0;
3034
+ const n = Number(m[1]);
3035
+ if (Number.isNaN(n)) return void 0;
3036
+ return m[2] === "s" ? n * 1e3 : n;
3037
+ }
3038
+ if (v !== null && typeof v === "object") {
3039
+ const d = v;
3040
+ if (typeof d.value === "number") return d.unit === "s" ? d.value * 1e3 : d.value;
3041
+ }
3042
+ }
3043
+ //#endregion
3197
3044
  //#region src/token-detail/CompositePreview.tsx
3198
3045
  const PANGRAM = "Sphinx of black quartz, judge my vow.";
3199
3046
  const STROKE_STYLE_STRINGS = new Set([
@@ -3240,7 +3087,10 @@ function CompositePreviewContent({ type, cssVar, rawValue }) {
3240
3087
  style: { border: cssVar },
3241
3088
  "aria-hidden": true
3242
3089
  });
3243
- if (type === "transition") return /* @__PURE__ */ jsx(TransitionSample, { transition: cssVar });
3090
+ if (type === "transition") return /* @__PURE__ */ jsx(TransitionSample, {
3091
+ transition: cssVar,
3092
+ durationMs: transitionDurationMs(type, rawValue)
3093
+ });
3244
3094
  if (type === "dimension") return /* @__PURE__ */ jsx("div", {
3245
3095
  className: "sb-token-detail__dimension-track",
3246
3096
  children: /* @__PURE__ */ jsx("div", {
@@ -3249,7 +3099,10 @@ function CompositePreviewContent({ type, cssVar, rawValue }) {
3249
3099
  "aria-hidden": true
3250
3100
  })
3251
3101
  });
3252
- if (type === "duration") return /* @__PURE__ */ jsx(TransitionSample, { transition: `left ${cssVar} ease` });
3102
+ if (type === "duration") return /* @__PURE__ */ jsx(TransitionSample, {
3103
+ transition: `left ${cssVar} ease`,
3104
+ durationMs: transitionDurationMs(type, rawValue)
3105
+ });
3253
3106
  if (type === "fontFamily") return /* @__PURE__ */ jsx("div", {
3254
3107
  className: "sb-token-detail__font-family-sample",
3255
3108
  style: { fontFamily: cssVar },
@@ -3260,7 +3113,10 @@ function CompositePreviewContent({ type, cssVar, rawValue }) {
3260
3113
  style: { fontWeight: cssVarAsNumber(cssVar) },
3261
3114
  children: "Aa"
3262
3115
  });
3263
- if (type === "cubicBezier") return /* @__PURE__ */ jsx(TransitionSample, { transition: `left 800ms ${cssVar}` });
3116
+ if (type === "cubicBezier") return /* @__PURE__ */ jsx(TransitionSample, {
3117
+ transition: `left 800ms ${cssVar}`,
3118
+ durationMs: transitionDurationMs(type, rawValue)
3119
+ });
3264
3120
  if (type === "gradient") return /* @__PURE__ */ jsx("div", {
3265
3121
  className: "sb-token-detail__gradient-sample",
3266
3122
  style: { background: `linear-gradient(to right, ${cssVar})` },
@@ -3331,20 +3187,23 @@ function asDashLengths(raw) {
3331
3187
  }
3332
3188
  return out;
3333
3189
  }
3334
- function TransitionSample({ transition }) {
3190
+ const DEFAULT_LOOP_MS = 1200;
3191
+ const MOTION_HOLD_MS = 400;
3192
+ function TransitionSample({ transition, durationMs }) {
3335
3193
  const reduced = usePrefersReducedMotion();
3336
3194
  const [phase, setPhase] = useState(0);
3337
3195
  useEffect(() => {
3338
3196
  if (reduced) return;
3197
+ const loopMs = durationMs === void 0 ? DEFAULT_LOOP_MS : durationMs + MOTION_HOLD_MS;
3339
3198
  const id = requestAnimationFrame(() => setPhase(1));
3340
3199
  const loop = window.setInterval(() => {
3341
3200
  setPhase((p) => p === 0 ? 1 : 0);
3342
- }, 1200);
3201
+ }, loopMs);
3343
3202
  return () => {
3344
3203
  cancelAnimationFrame(id);
3345
3204
  window.clearInterval(loop);
3346
3205
  };
3347
- }, [reduced]);
3206
+ }, [reduced, durationMs]);
3348
3207
  if (reduced) return /* @__PURE__ */ jsx("div", {
3349
3208
  className: "sb-token-detail__reduced-motion",
3350
3209
  children: "Animation suppressed by `prefers-reduced-motion: reduce`."
@@ -3519,6 +3378,7 @@ function TokenUsageSnippet({ path }) {
3519
3378
  function TokenDetail({ path, heading }) {
3520
3379
  const { token, cssVar, activeTheme, activeAxes, cssVarPrefix } = useTokenDetailData(path);
3521
3380
  const colorFormat = useColorFormat();
3381
+ const { listing } = useProject();
3522
3382
  const wrapperAttrs = blockWrapperAttrs(cssVarPrefix, activeAxes);
3523
3383
  if (!token) return /* @__PURE__ */ jsx("div", {
3524
3384
  ...wrapperAttrs,
@@ -3534,7 +3394,6 @@ function TokenDetail({ path, heading }) {
3534
3394
  ]
3535
3395
  })
3536
3396
  });
3537
- const { listing } = useProject();
3538
3397
  const isColor = token.$type === "color";
3539
3398
  const gamut = isColor ? formatColor(token.$value, colorFormat) : null;
3540
3399
  const value = formatTokenValue(token.$value, token.$type, colorFormat, listing[path]);
@@ -4460,6 +4319,6 @@ function TypographyScale({ filter, sample = "The quick brown fox jumps over the
4460
4319
  });
4461
4320
  }
4462
4321
  //#endregion
4463
- export { AliasChain, AliasedBy, AxesContext, AxisVariance, BorderPreview, BorderSample, COLOR_FORMATS, ColorFormatContext, ColorPalette, ColorTable, CompositeBreakdown, CompositePreview, ConsumerOutput, Diagnostics, DimensionBar, DimensionScale, FontFamilyPreview, FontWeightScale, GradientPalette, MotionPreview, MotionSample, OpacityScale, ShadowPreview, ShadowSample, StrokeStylePreview, SwatchbookContext, SwatchbookProvider, ThemeContext, TokenDetail, TokenHeader, TokenNavigator, TokenTable, TokenUsageSnippet, TypographyScale, formatColor, useActiveAxes, useActiveTheme, useColorFormat, useOptionalSwatchbookData, useSwatchbookData };
4322
+ export { AliasChain, AliasedBy, AxesContext, AxisVariance, BorderPreview, BorderSample, COLOR_FORMATS, ColorFormatContext, ColorPalette, ColorTable, CompositeBreakdown, CompositePreview, ConsumerOutput, Diagnostics, DimensionBar, DimensionScale, FontFamilyPreview, FontWeightScale, GradientPalette, MotionPreview, MotionSample, OpacityScale, ShadowPreview, ShadowSample, StrokeStylePreview, SwatchbookContext, SwatchbookProvider, TOKENS_UPDATED_EVENT, ThemeContext, TokenDetail, TokenHeader, TokenNavigator, TokenTable, TokenUsageSnippet, TypographyScale, formatColor, registerTokenSource, useActiveAxes, useActiveTheme, useChannelGlobals, useColorFormat, useOptionalSwatchbookData, useSwatchbookData, useTokenSnapshot };
4464
4323
 
4465
4324
  //# sourceMappingURL=index.mjs.map