@unpunnyfuns/swatchbook-blocks 0.67.0 → 0.69.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/dist/index.mjs CHANGED
@@ -2,39 +2,37 @@ import './style.css';
2
2
  import { COLOR_FORMATS } from "@unpunnyfuns/swatchbook-core/color-formats";
3
3
  import { formatColor, parseColor } from "@unpunnyfuns/swatchbook-core/format-color";
4
4
  import { createContext, memo, useCallback, useContext, useDeferredValue, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
5
  import { getVariance, listPaths, resolveAllWithProvenanceAt } from "@unpunnyfuns/swatchbook-core/graph";
7
6
  import { makeCssVar } from "@unpunnyfuns/swatchbook-core/css-var";
8
7
  import { SWATCHBOOK_STYLE_ELEMENT_ID, ensureStyleElement } from "@unpunnyfuns/swatchbook-core/style-element";
9
8
  import { tupleToName } from "@unpunnyfuns/swatchbook-core/themes";
10
- import { addons } from "storybook/preview-api";
9
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
11
10
  import { dataAttr } from "@unpunnyfuns/swatchbook-core/data-attr";
12
11
  import { matchPath } from "@unpunnyfuns/swatchbook-core/match-path";
13
12
  import { fuzzyFilter } from "@unpunnyfuns/swatchbook-core/fuzzy";
14
13
  import cx from "clsx";
15
- //#region src/internal/styles.tsx
14
+ //#region src/internal/channel.ts
15
+ let channel = null;
16
+ const pending = /* @__PURE__ */ new Set();
16
17
  /**
17
- * Chrome-style primitives shared across every block. Kept as JS exports
18
- * for the inline-style sites that still compose them into per-block style
19
- * objects (e.g. TokenNavigator's `typePill` that builds on the shared
20
- * pill base). The pure direct-reference chrome — surface wrapper, caption,
21
- * empty-state — lives in `styles.css` and is applied via class names.
18
+ * Inject the host channel. Called once by the addon preview at init. Runs any
19
+ * subscribers that registered before the channel existed, then clears the
20
+ * queue. Idempotent for consumers: each subscriber guards its own attach, so a
21
+ * re-register (HMR) does not double-wire.
22
22
  */
23
- const TEXT_MUTED = "var(--swatchbook-text-muted, CanvasText)";
24
- const SURFACE_RAISED = "var(--swatchbook-surface-raised, Canvas)";
25
- const SURFACE_MUTED = "var(--swatchbook-surface-muted, rgba(128,128,128,0.15))";
26
- const BORDER_FAINT = `1px solid var(--swatchbook-border-default, rgba(128,128,128,0.15))`;
27
- const BORDER_STRONG = `1px solid var(--swatchbook-border-default, rgba(128,128,128,0.3))`;
23
+ function registerChannel(next) {
24
+ channel = next;
25
+ for (const attach of pending) attach(next);
26
+ pending.clear();
27
+ }
28
28
  /**
29
- * Inner content for a block's "nothing to render" state. Call sites wrap
30
- * it in their own block wrapper (which already carries `blockWrapperAttrs`), so
31
- * the message itself just needs the muted type.
29
+ * Attach a subscriber to the host channel now if one is registered, else queue
30
+ * it to run the moment {@link registerChannel} fires. Blocks call this at
31
+ * module load; the queue absorbs the gap until the addon injects the channel.
32
32
  */
33
- function EmptyState({ children }) {
34
- return /* @__PURE__ */ jsx("div", {
35
- className: "sb-block__empty",
36
- children
37
- });
33
+ function onChannel(attach) {
34
+ if (channel) attach(channel);
35
+ else pending.add(attach);
38
36
  }
39
37
  //#endregion
40
38
  //#region src/internal/channel-globals.ts
@@ -49,10 +47,9 @@ let subscribed$1 = false;
49
47
  function isColorFormat(value) {
50
48
  return typeof value === "string" && COLOR_FORMATS.includes(value);
51
49
  }
52
- function ensureSubscribed$1() {
53
- if (subscribed$1 || typeof window === "undefined") return;
50
+ function attach$1(channel) {
51
+ if (subscribed$1) return;
54
52
  subscribed$1 = true;
55
- const channel = addons.getChannel();
56
53
  let lastFingerprint = "";
57
54
  const onGlobals = (payload) => {
58
55
  const globals = payload.globals;
@@ -74,9 +71,8 @@ function ensureSubscribed$1() {
74
71
  channel.on("updateGlobals", onGlobals);
75
72
  channel.on("setGlobals", onGlobals);
76
73
  }
77
- ensureSubscribed$1();
74
+ onChannel(attach$1);
78
75
  function subscribe$2(cb) {
79
- ensureSubscribed$1();
80
76
  listeners$1.add(cb);
81
77
  return () => {
82
78
  listeners$1.delete(cb);
@@ -204,16 +200,15 @@ function applyPatch(patch) {
204
200
  function registerTokenSource(source) {
205
201
  applyPatch(source);
206
202
  }
207
- function ensureSubscribed() {
208
- if (subscribed || typeof window === "undefined") return;
203
+ function attach(channel) {
204
+ if (subscribed) return;
209
205
  subscribed = true;
210
- addons.getChannel().on(TOKENS_UPDATED_EVENT, (payload) => {
206
+ channel.on(TOKENS_UPDATED_EVENT, (payload) => {
211
207
  applyPatch(payload);
212
208
  });
213
209
  }
214
- ensureSubscribed();
210
+ onChannel(attach);
215
211
  function subscribe$1(cb) {
216
- ensureSubscribed();
217
212
  listeners.add(cb);
218
213
  return () => {
219
214
  listeners.delete(cb);
@@ -260,9 +255,9 @@ function snapshotResolveAt(snapshot) {
260
255
  *
261
256
  * The provider-less path is what makes the hook safe to call from MDX
262
257
  * doc blocks and autodocs renders where no story is active. It
263
- * self-mounts the virtual module's per-theme CSS and tracks the active
264
- * tuple via the `globalsUpdated` channel event; {@link useGlobals} from
265
- * `storybook/preview-api` would throw outside a story render.
258
+ * self-mounts the injected snapshot's per-theme CSS and tracks the active
259
+ * tuple via the injected channel's `globalsUpdated` event, since a
260
+ * story-scoped globals hook only works while a story is rendering.
266
261
  */
267
262
  function useProject() {
268
263
  const snapshot = useOptionalSwatchbookData();
@@ -388,22 +383,25 @@ function resolveColorValue(path, raw, colorFormat, project) {
388
383
  }
389
384
  //#endregion
390
385
  //#region src/border-preview/BorderSample.tsx
391
- const sampleStyle$1 = {
392
- width: 120,
393
- height: 56,
394
- background: SURFACE_RAISED,
395
- borderRadius: 6
396
- };
397
- function BorderSample({ path }) {
398
- const cssVar = resolveCssVar(path, useProject());
386
+ /**
387
+ * Pure derivation of a single border token's sample data from resolved
388
+ * project data. Extracted so it is unit-testable without React or a store.
389
+ */
390
+ function deriveBorderSample(path, project) {
391
+ return { cssVar: resolveCssVar(path, project) };
392
+ }
393
+ /** Pure presentation for a single border token's sample. Renders from plain props. */
394
+ function BorderSampleView({ cssVar }) {
399
395
  return /* @__PURE__ */ jsx("div", {
400
- style: {
401
- ...sampleStyle$1,
402
- border: cssVar
403
- },
396
+ className: "sb-border-sample",
397
+ style: { border: cssVar },
404
398
  "aria-hidden": true
405
399
  });
406
400
  }
401
+ function BorderSample({ path }) {
402
+ const { cssVar } = deriveBorderSample(path, useProject());
403
+ return /* @__PURE__ */ jsx(BorderSampleView, { cssVar });
404
+ }
407
405
  //#endregion
408
406
  //#region src/internal/composite-sample-format.ts
409
407
  /**
@@ -586,29 +584,34 @@ function toDisplayable(v) {
586
584
  }
587
585
  //#endregion
588
586
  //#region src/BorderPreview.tsx
589
- function BorderPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
590
- const project = useProject();
591
- const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
592
- const colorFormat = useColorFormat();
593
- const rows = useMemo(() => {
594
- return sortTokens(Object.entries(resolved).filter(([path, token]) => {
595
- if (token.$type !== "border") return false;
596
- return matchPath(path, filter);
597
- }), {
598
- by: sortBy,
599
- dir: sortDir
600
- }).map(([path, token]) => ({
587
+ /**
588
+ * Pure derivation of the preview's display rows from resolved project data.
589
+ * Extracted so it is unit-testable without React or a store.
590
+ */
591
+ function deriveBorderRows(resolved, project, { filter, sortBy, sortDir, colorFormat }) {
592
+ return sortTokens(Object.entries(resolved).filter(([path, token]) => {
593
+ if (token.$type !== "border") return false;
594
+ return matchPath(path, filter);
595
+ }), {
596
+ by: sortBy,
597
+ dir: sortDir
598
+ }).map(([path, token]) => {
599
+ const value = token.$value ?? {};
600
+ return {
601
601
  path,
602
602
  cssVar: resolveCssVar(path, project),
603
- value: token.$value ?? {}
604
- }));
605
- }, [
606
- resolved,
607
- filter,
608
- project,
609
- sortBy,
610
- sortDir
611
- ]);
603
+ width: formatDimension$1(value.width),
604
+ style: value.style != null ? String(value.style) : "—",
605
+ color: formatSubColor(value.color, colorFormat)
606
+ };
607
+ });
608
+ }
609
+ /**
610
+ * Pure presentation for the border preview. Renders from plain props;
611
+ * composes the connected `BorderSample` as a child (that child reads the
612
+ * project itself).
613
+ */
614
+ function BorderPreviewView({ rows, activeTheme, cssVarPrefix, activeAxes, filter, caption }) {
612
615
  const captionText = caption ?? `${rows.length} border${rows.length === 1 ? "" : "s"}${filter ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
613
616
  if (rows.length === 0) return /* @__PURE__ */ jsx("div", {
614
617
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -646,23 +649,48 @@ function BorderPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
646
649
  className: "sb-border-preview__breakdown-key",
647
650
  children: "width"
648
651
  }),
649
- /* @__PURE__ */ jsx("span", { children: formatDimension$1(row.value.width) }),
652
+ /* @__PURE__ */ jsx("span", { children: row.width }),
650
653
  /* @__PURE__ */ jsx("span", {
651
654
  className: "sb-border-preview__breakdown-key",
652
655
  children: "style"
653
656
  }),
654
- /* @__PURE__ */ jsx("span", { children: row.value.style != null ? String(row.value.style) : "—" }),
657
+ /* @__PURE__ */ jsx("span", { children: row.style }),
655
658
  /* @__PURE__ */ jsx("span", {
656
659
  className: "sb-border-preview__breakdown-key",
657
660
  children: "color"
658
661
  }),
659
- /* @__PURE__ */ jsx("span", { children: formatSubColor(row.value.color, colorFormat) })
662
+ /* @__PURE__ */ jsx("span", { children: row.color })
660
663
  ]
661
664
  })
662
665
  ]
663
666
  }, row.path))]
664
667
  });
665
668
  }
669
+ function BorderPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
670
+ const project = useProject();
671
+ const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
672
+ const colorFormat = useColorFormat();
673
+ return /* @__PURE__ */ jsx(BorderPreviewView, {
674
+ rows: useMemo(() => deriveBorderRows(resolved, project, {
675
+ filter,
676
+ sortBy,
677
+ sortDir,
678
+ colorFormat
679
+ }), [
680
+ resolved,
681
+ project,
682
+ filter,
683
+ sortBy,
684
+ sortDir,
685
+ colorFormat
686
+ ]),
687
+ activeTheme,
688
+ cssVarPrefix,
689
+ activeAxes,
690
+ filter,
691
+ caption
692
+ });
693
+ }
666
694
  //#endregion
667
695
  //#region src/ColorPalette.tsx
668
696
  function fixedPrefixLength(filter) {
@@ -675,51 +703,48 @@ function fixedPrefixLength(filter) {
675
703
  }
676
704
  return fixed;
677
705
  }
678
- function ColorPalette({ filter, groupBy, caption, sortBy = "path", sortDir = "asc" }) {
679
- const { resolved, activeTheme, activeAxes, cssVarPrefix, listing } = useProject();
680
- const colorFormat = useColorFormat();
681
- const groups = useMemo(() => {
682
- const projectFields = {
683
- listing,
684
- cssVarPrefix
685
- };
686
- const entries = sortTokens(Object.entries(resolved).filter(([path, token]) => {
687
- if (token.$type !== "color") return false;
688
- return matchPath(path, filter);
689
- }), {
690
- by: sortBy,
691
- dir: sortDir
692
- });
693
- const maxDepth = entries.reduce((m, [p]) => Math.max(m, p.split(".").length), 0);
694
- const effectiveGroupBy = groupBy ?? Math.min(fixedPrefixLength(filter) + 1, Math.max(maxDepth - 1, 1));
695
- const bucket = /* @__PURE__ */ new Map();
696
- for (const [path, token] of entries) {
697
- const segments = path.split(".");
698
- const groupKey = segments.slice(0, effectiveGroupBy).join(".");
699
- const leaf = segments.slice(effectiveGroupBy).join(".") || segments.at(-1) || path;
700
- const list = bucket.get(groupKey) ?? [];
701
- const formatted = resolveColorValue(path, token.$value, colorFormat, projectFields);
702
- list.push({
703
- path,
704
- leaf,
705
- cssVar: resolveCssVar(path, projectFields),
706
- value: formatted.value,
707
- outOfGamut: formatted.outOfGamut
708
- });
709
- bucket.set(groupKey, list);
710
- }
711
- return [...bucket.entries()].toSorted(([a], [b]) => a.localeCompare(b, void 0, { numeric: true }));
712
- }, [
713
- resolved,
706
+ /**
707
+ * Pure derivation of the palette's grouped swatch rows from resolved project
708
+ * data. Extracted so it is unit-testable without React or a store.
709
+ */
710
+ function deriveColorPaletteGroups(resolved, listing, cssVarPrefix, { filter, groupBy, sortBy, sortDir, colorFormat }) {
711
+ const projectFields = {
714
712
  listing,
715
- cssVarPrefix,
716
- filter,
717
- groupBy,
718
- colorFormat,
719
- sortBy,
720
- sortDir
721
- ]);
722
- const totalCount = groups.reduce((acc, [, swatches]) => acc + swatches.length, 0);
713
+ cssVarPrefix
714
+ };
715
+ const entries = sortTokens(Object.entries(resolved).filter(([path, token]) => {
716
+ if (token.$type !== "color") return false;
717
+ return matchPath(path, filter);
718
+ }), {
719
+ by: sortBy,
720
+ dir: sortDir
721
+ });
722
+ const maxDepth = entries.reduce((m, [p]) => Math.max(m, p.split(".").length), 0);
723
+ const effectiveGroupBy = groupBy ?? Math.min(fixedPrefixLength(filter) + 1, Math.max(maxDepth - 1, 1));
724
+ const bucket = /* @__PURE__ */ new Map();
725
+ for (const [path, token] of entries) {
726
+ const segments = path.split(".");
727
+ const groupKey = segments.slice(0, effectiveGroupBy).join(".");
728
+ const leaf = segments.slice(effectiveGroupBy).join(".") || segments.at(-1) || path;
729
+ const list = bucket.get(groupKey) ?? [];
730
+ const formatted = resolveColorValue(path, token.$value, colorFormat, projectFields);
731
+ list.push({
732
+ path,
733
+ leaf,
734
+ cssVar: resolveCssVar(path, projectFields),
735
+ value: formatted.value,
736
+ outOfGamut: formatted.outOfGamut
737
+ });
738
+ bucket.set(groupKey, list);
739
+ }
740
+ return [...bucket.entries()].toSorted(([a], [b]) => a.localeCompare(b, void 0, { numeric: true })).map(([group, swatches]) => ({
741
+ group,
742
+ swatches
743
+ }));
744
+ }
745
+ /** Pure presentation for the color palette. Renders from plain props. */
746
+ function ColorPaletteView({ groups, activeTheme, cssVarPrefix, activeAxes, filter, caption }) {
747
+ const totalCount = groups.reduce((acc, { swatches }) => acc + swatches.length, 0);
723
748
  const captionText = caption ?? `${totalCount} color${totalCount === 1 ? "" : "s"}${filter ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
724
749
  if (totalCount === 0) return /* @__PURE__ */ jsx("div", {
725
750
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -733,7 +758,7 @@ function ColorPalette({ filter, groupBy, caption, sortBy = "path", sortDir = "as
733
758
  children: [/* @__PURE__ */ jsx("div", {
734
759
  className: "sb-block__caption",
735
760
  children: captionText
736
- }), groups.map(([group, swatches]) => /* @__PURE__ */ jsxs("section", {
761
+ }), groups.map(({ group, swatches }) => /* @__PURE__ */ jsxs("section", {
737
762
  className: "sb-color-palette__group",
738
763
  children: [/* @__PURE__ */ jsx("div", {
739
764
  className: "sb-color-palette__group-header",
@@ -766,6 +791,33 @@ function ColorPalette({ filter, groupBy, caption, sortBy = "path", sortDir = "as
766
791
  }, group))]
767
792
  });
768
793
  }
794
+ function ColorPalette({ filter, groupBy, caption, sortBy = "path", sortDir = "asc" }) {
795
+ const { resolved, activeTheme, activeAxes, cssVarPrefix, listing } = useProject();
796
+ const colorFormat = useColorFormat();
797
+ return /* @__PURE__ */ jsx(ColorPaletteView, {
798
+ groups: useMemo(() => deriveColorPaletteGroups(resolved, listing, cssVarPrefix, {
799
+ filter,
800
+ groupBy,
801
+ sortBy,
802
+ sortDir,
803
+ colorFormat
804
+ }), [
805
+ resolved,
806
+ listing,
807
+ cssVarPrefix,
808
+ filter,
809
+ groupBy,
810
+ sortBy,
811
+ sortDir,
812
+ colorFormat
813
+ ]),
814
+ activeTheme,
815
+ cssVarPrefix,
816
+ activeAxes,
817
+ filter,
818
+ caption
819
+ });
820
+ }
769
821
  //#endregion
770
822
  //#region src/indicators/resolve.ts
771
823
  /** Established-on set; `description` and `composes` are opt-in. */
@@ -1057,12 +1109,9 @@ function CopyButton$1({ value, label, variant = "icon", className }) {
1057
1109
  timerRef.current = setTimeout(() => setCopied(false), 1500);
1058
1110
  }, [value]);
1059
1111
  const ariaLabel = label ?? `Copy ${value}`;
1060
- const classes = ["sb-copy-button", `sb-copy-button--${variant}`];
1061
- if (copied) classes.push("sb-copy-button--copied");
1062
- if (className) classes.push(className);
1063
1112
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
1064
1113
  type: "button",
1065
- className: classes.join(" "),
1114
+ className: cx("sb-copy-button", `sb-copy-button--${variant}`, copied && "sb-copy-button--copied", className),
1066
1115
  onClick: handleClick,
1067
1116
  "aria-label": ariaLabel,
1068
1117
  title: ariaLabel,
@@ -1139,88 +1188,81 @@ function usePersistedState(key, initial) {
1139
1188
  const NOOP_REFERENCE = () => {};
1140
1189
  const BASE_LABEL = "base";
1141
1190
  const COLUMN_COUNT = 6;
1142
- function ColorTable({ filter, caption, sortBy = "path", sortDir = "asc", searchable = true, onSelect, variants, id, indicators }) {
1143
- const { resolved, activeTheme, activeAxes, cssVarPrefix, listing, varianceByPath } = useProject();
1144
- const colorFormat = useColorFormat();
1145
- const enabledIndicators = useMemo(() => ({
1146
- ...resolveIndicators(indicators),
1147
- gamut: false
1148
- }), [indicators]);
1149
- const blockKey = useBlockKey("ColorTable", [
1150
- filter,
1151
- caption,
1152
- id
1153
- ]);
1191
+ /**
1192
+ * Pure derivation of the table's display groups from resolved project data.
1193
+ * Carries per-variant `isDeprecated` / `token` / `variance` so the View
1194
+ * renders the indicator strip and deprecation state without reaching back
1195
+ * into the project. Extracted so it is unit-testable without React or a
1196
+ * store.
1197
+ */
1198
+ function deriveColorGroups(resolved, listing, cssVarPrefix, varianceByPath, { filter, variants, sortBy, sortDir, colorFormat }) {
1199
+ const defs = buildVariantDefs(variants);
1200
+ const projectFields = {
1201
+ listing,
1202
+ cssVarPrefix
1203
+ };
1204
+ const sorted = sortTokens(Object.entries(resolved).filter(([path, token]) => {
1205
+ if (token.$type !== "color") return false;
1206
+ return matchPath(path, filter);
1207
+ }), {
1208
+ by: sortBy,
1209
+ dir: sortDir
1210
+ });
1211
+ const groupMap = /* @__PURE__ */ new Map();
1212
+ for (const [path, token] of sorted) {
1213
+ const raw = token.$value;
1214
+ const hex = resolveColorValue(path, raw, "hex", projectFields);
1215
+ const hsl = formatColor(raw, "hsl");
1216
+ const oklch = formatColor(raw, "oklch");
1217
+ const active = pickActiveFormat(raw, colorFormat, hex, hsl, oklch);
1218
+ const match = matchVariant(path, defs.matchOrder);
1219
+ const dep = token.$deprecated;
1220
+ const variant = {
1221
+ label: match?.label ?? BASE_LABEL,
1222
+ path,
1223
+ token,
1224
+ variance: varianceByPath[path],
1225
+ cssVar: resolveCssVar(path, projectFields),
1226
+ value: active.value,
1227
+ outOfGamut: active.outOfGamut,
1228
+ hex: hex.value,
1229
+ hsl: hsl.value,
1230
+ oklch: oklch.value,
1231
+ isDeprecated: dep === true || typeof dep === "string" && dep.length > 0,
1232
+ ...token.$description !== void 0 && { description: token.$description },
1233
+ ...token.aliasOf !== void 0 && { aliasOf: token.aliasOf },
1234
+ ...token.aliasChain !== void 0 && { aliasChain: token.aliasChain }
1235
+ };
1236
+ const basePath = match?.basePath ?? path;
1237
+ const existing = groupMap.get(basePath);
1238
+ if (existing) existing.variants.push(variant);
1239
+ else groupMap.set(basePath, {
1240
+ base: basePath,
1241
+ variants: [variant]
1242
+ });
1243
+ }
1244
+ const out = [];
1245
+ for (const { base, variants: vs } of groupMap.values()) {
1246
+ vs.sort((a, b) => orderIndex(a.label, defs) - orderIndex(b.label, defs));
1247
+ const searchText = vs.map((v) => `${v.path} ${v.value}`).join(" ");
1248
+ out.push({
1249
+ base,
1250
+ variants: vs,
1251
+ searchText
1252
+ });
1253
+ }
1254
+ return out;
1255
+ }
1256
+ /**
1257
+ * Pure presentation for the color table. Owns its own search, variant
1258
+ * selection, and row-expansion UI state; renders from the derived `groups`
1259
+ * view-model.
1260
+ */
1261
+ function ColorTableView({ groups, activeTheme, cssVarPrefix, activeAxes, colorFormat, enabledIndicators, blockKey, filter, caption, searchable = true, onSelect }) {
1154
1262
  const [query, setQuery] = usePersistedState(`${blockKey}::query`, "");
1155
1263
  const deferredQuery = useDeferredValue(query);
1156
1264
  const [selectedByBase, setSelectedByBase] = usePersistedState(`${blockKey}::selected`, {});
1157
1265
  const [expandedByBase, setExpandedByBase] = usePersistedState(`${blockKey}::expanded`, () => /* @__PURE__ */ new Set());
1158
- const defs = useMemo(() => buildVariantDefs(variants), [variants]);
1159
- const groups = useMemo(() => {
1160
- const projectFields = {
1161
- listing,
1162
- cssVarPrefix
1163
- };
1164
- const sorted = sortTokens(Object.entries(resolved).filter(([path, token]) => {
1165
- if (token.$type !== "color") return false;
1166
- return matchPath(path, filter);
1167
- }), {
1168
- by: sortBy,
1169
- dir: sortDir
1170
- });
1171
- const groupMap = /* @__PURE__ */ new Map();
1172
- for (const [path, token] of sorted) {
1173
- const raw = token.$value;
1174
- const hex = resolveColorValue(path, raw, "hex", projectFields);
1175
- const hsl = formatColor(raw, "hsl");
1176
- const oklch = formatColor(raw, "oklch");
1177
- const active = pickActiveFormat(raw, colorFormat, hex, hsl, oklch);
1178
- const match = matchVariant(path, defs.matchOrder);
1179
- const variant = {
1180
- label: match?.label ?? BASE_LABEL,
1181
- path,
1182
- token,
1183
- variance: varianceByPath[path],
1184
- cssVar: resolveCssVar(path, projectFields),
1185
- value: active.value,
1186
- outOfGamut: active.outOfGamut,
1187
- hex: hex.value,
1188
- hsl: hsl.value,
1189
- oklch: oklch.value,
1190
- ...token.$description !== void 0 && { description: token.$description },
1191
- ...token.aliasOf !== void 0 && { aliasOf: token.aliasOf },
1192
- ...token.aliasChain !== void 0 && { aliasChain: token.aliasChain }
1193
- };
1194
- const basePath = match?.basePath ?? path;
1195
- const existing = groupMap.get(basePath);
1196
- if (existing) existing.variants.push(variant);
1197
- else groupMap.set(basePath, {
1198
- base: basePath,
1199
- variants: [variant]
1200
- });
1201
- }
1202
- const out = [];
1203
- for (const { base, variants: vs } of groupMap.values()) {
1204
- vs.sort((a, b) => orderIndex(a.label, defs) - orderIndex(b.label, defs));
1205
- const searchText = vs.map((v) => `${v.path} ${v.value}`).join(" ");
1206
- out.push({
1207
- base,
1208
- variants: vs,
1209
- searchText
1210
- });
1211
- }
1212
- return out;
1213
- }, [
1214
- resolved,
1215
- listing,
1216
- cssVarPrefix,
1217
- varianceByPath,
1218
- filter,
1219
- sortBy,
1220
- sortDir,
1221
- defs,
1222
- colorFormat
1223
- ]);
1224
1266
  const visibleGroups = useMemo(() => {
1225
1267
  if (!searchable || deferredQuery.trim() === "") return groups;
1226
1268
  return fuzzyFilter(groups, deferredQuery, (g) => g.searchText);
@@ -1333,12 +1375,59 @@ function ColorTable({ filter, caption, sortBy = "path", sortDir = "asc", searcha
1333
1375
  })]
1334
1376
  });
1335
1377
  }
1378
+ /**
1379
+ * A grouped, searchable table of `$type: color` tokens. Suffix-matched
1380
+ * variants (see `variants` prop) collapse into a single row with a pill
1381
+ * selector; clicking a row expands it in place to show the description,
1382
+ * alias chain, and (for grouped rows) every variant's hex/HSL/OKLCH values.
1383
+ */
1384
+ function ColorTable({ filter, caption, sortBy = "path", sortDir = "asc", searchable = true, onSelect, variants, id, indicators }) {
1385
+ const { resolved, activeTheme, activeAxes, cssVarPrefix, listing, varianceByPath } = useProject();
1386
+ const colorFormat = useColorFormat();
1387
+ const enabledIndicators = useMemo(() => ({
1388
+ ...resolveIndicators(indicators),
1389
+ gamut: false
1390
+ }), [indicators]);
1391
+ const blockKey = useBlockKey("ColorTable", [
1392
+ filter,
1393
+ caption,
1394
+ id
1395
+ ]);
1396
+ return /* @__PURE__ */ jsx(ColorTableView, {
1397
+ groups: useMemo(() => deriveColorGroups(resolved, listing, cssVarPrefix, varianceByPath, {
1398
+ filter,
1399
+ variants,
1400
+ sortBy,
1401
+ sortDir,
1402
+ colorFormat
1403
+ }), [
1404
+ resolved,
1405
+ listing,
1406
+ cssVarPrefix,
1407
+ varianceByPath,
1408
+ filter,
1409
+ variants,
1410
+ sortBy,
1411
+ sortDir,
1412
+ colorFormat
1413
+ ]),
1414
+ activeTheme,
1415
+ cssVarPrefix,
1416
+ activeAxes,
1417
+ colorFormat,
1418
+ enabledIndicators,
1419
+ blockKey,
1420
+ filter,
1421
+ caption,
1422
+ searchable,
1423
+ onSelect
1424
+ });
1425
+ }
1336
1426
  const GroupRow = memo(function GroupRow({ group, selectedLabel, expanded, onToggleExpand, onSelectVariant, onSelect, enabled, colorFormat }) {
1337
1427
  const multi = group.variants.length > 1;
1338
1428
  const active = group.variants.find((v) => v.label === selectedLabel) ?? group.variants[0];
1339
1429
  const nameText = multi ? group.base : active.path;
1340
- const dep = active.token.$deprecated;
1341
- const isDeprecated = dep === true || typeof dep === "string" && dep.length > 0;
1430
+ const isDeprecated = active.isDeprecated;
1342
1431
  const handleRowActivate = () => {
1343
1432
  if (onSelect) onSelect(active.path);
1344
1433
  else onToggleExpand(group.base);
@@ -1627,6 +1716,7 @@ const severityLabel = {
1627
1716
  warn: "WARN",
1628
1717
  info: "INFO"
1629
1718
  };
1719
+ /** Aggregate diagnostics into a summary line + severity variant for the header. */
1630
1720
  function summarize(diagnostics) {
1631
1721
  if (diagnostics.length === 0) return {
1632
1722
  text: "✔ OK · no diagnostics",
@@ -1653,18 +1743,8 @@ function summarize(diagnostics) {
1653
1743
  function diagnosticKey(d, i) {
1654
1744
  return `${d.severity}:${d.group}:${d.filename ?? ""}:${d.line ?? ""}:${d.message}:${i}`;
1655
1745
  }
1656
- /**
1657
- * Render the project's load diagnostics parser errors, resolver warnings,
1658
- * disabled-axes validation issues, etc. — as a collapsible list. Auto-opens
1659
- * when the project carries errors or warnings; stays collapsed for clean
1660
- * loads and info-only loads.
1661
- *
1662
- * Replaces the diagnostics section from the addon's (now-retired) Design
1663
- * Tokens panel. Consumers compose it alongside TokenNavigator / TokenTable
1664
- * on their own MDX pages.
1665
- */
1666
- function Diagnostics({ caption } = {}) {
1667
- const { activeAxes, cssVarPrefix, diagnostics } = useProject();
1746
+ /** Pure presentation for the diagnostics list. Renders from plain props. */
1747
+ function DiagnosticsView({ caption, diagnostics, cssVarPrefix, activeAxes }) {
1668
1748
  const summary = useMemo(() => summarize(diagnostics), [diagnostics]);
1669
1749
  const headingText = caption ?? `Diagnostics · ${summary.text}`;
1670
1750
  return /* @__PURE__ */ jsx("div", {
@@ -1698,6 +1778,25 @@ function Diagnostics({ caption } = {}) {
1698
1778
  })
1699
1779
  });
1700
1780
  }
1781
+ /**
1782
+ * Render the project's load diagnostics — parser errors, resolver warnings,
1783
+ * disabled-axes validation issues, etc. — as a collapsible list. Auto-opens
1784
+ * when the project carries errors or warnings; stays collapsed for clean
1785
+ * loads and info-only loads.
1786
+ *
1787
+ * Replaces the diagnostics section from the addon's (now-retired) Design
1788
+ * Tokens panel. Consumers compose it alongside TokenNavigator / TokenTable
1789
+ * on their own MDX pages.
1790
+ */
1791
+ function Diagnostics({ caption } = {}) {
1792
+ const { activeAxes, cssVarPrefix, diagnostics } = useProject();
1793
+ return /* @__PURE__ */ jsx(DiagnosticsView, {
1794
+ caption,
1795
+ diagnostics,
1796
+ cssVarPrefix,
1797
+ activeAxes
1798
+ });
1799
+ }
1701
1800
  //#endregion
1702
1801
  //#region src/dimension-scale/dimension-px.ts
1703
1802
  /**
@@ -1746,44 +1845,25 @@ function useRootFontSize() {
1746
1845
  }
1747
1846
  //#endregion
1748
1847
  //#region src/dimension-scale/DimensionBar.tsx
1749
- const styles$1 = {
1750
- cappedWrap: {
1751
- display: "inline-flex",
1752
- alignItems: "center",
1753
- gap: "var(--swatchbook-space-3xs)",
1754
- maxWidth: "100%",
1755
- minWidth: 0
1756
- },
1757
- cap: {
1758
- color: "var(--swatchbook-text-muted)",
1759
- fontSize: 11,
1760
- lineHeight: 1,
1761
- userSelect: "none"
1762
- },
1763
- bar: {
1764
- height: 14,
1765
- background: "var(--swatchbook-accent-bg, #3b82f6)",
1766
- borderRadius: 2,
1767
- minWidth: 1,
1768
- maxWidth: "100%"
1769
- },
1770
- radiusSample: {
1771
- width: 56,
1772
- height: 56,
1773
- background: "var(--swatchbook-accent-bg, #3b82f6)",
1774
- border: BORDER_STRONG
1775
- },
1776
- sizeSample: {
1777
- background: "var(--swatchbook-accent-bg, #3b82f6)",
1778
- border: BORDER_STRONG,
1779
- minWidth: 1,
1780
- minHeight: 1
1781
- }
1782
- };
1848
+ /**
1849
+ * Pure derivation of a single dimension token's bar geometry from resolved
1850
+ * project data. Extracted so it is unit-testable without React or a store.
1851
+ */
1852
+ function deriveDimensionBar(path, project, rootFontSizePx) {
1853
+ const cssVar = resolveCssVar(path, project);
1854
+ const token = project.resolved[path];
1855
+ const pxValue = toPixels(token?.$value, rootFontSizePx);
1856
+ const capped = Number.isFinite(pxValue) && pxValue > 480;
1857
+ return {
1858
+ cssVar,
1859
+ pxValue,
1860
+ capped,
1861
+ cappedValue: capped ? `480px` : cssVar
1862
+ };
1863
+ }
1783
1864
  function withCap(visual) {
1784
1865
  return /* @__PURE__ */ jsxs("span", {
1785
1866
  className: "sb-dimension-bar sb-dimension-bar--capped",
1786
- style: styles$1.cappedWrap,
1787
1867
  title: `capped at 480px`,
1788
1868
  children: [visual, /* @__PURE__ */ jsx("span", {
1789
1869
  className: "sb-dimension-bar__cap",
@@ -1792,27 +1872,18 @@ function withCap(visual) {
1792
1872
  })]
1793
1873
  });
1794
1874
  }
1795
- function DimensionBar({ path, visual = "length" }) {
1796
- const project = useProject();
1797
- const { resolved } = project;
1798
- const rootFontSize = useRootFontSize();
1799
- const cssVar = resolveCssVar(path, project);
1800
- const token = resolved[path];
1801
- const pxValue = toPixels(token?.$value, rootFontSize);
1802
- const capped = Number.isFinite(pxValue) && pxValue > 480;
1803
- const cappedValue = capped ? `480px` : cssVar;
1875
+ /** Pure presentation for a single dimension token's bar/sample. Renders from plain props. */
1876
+ function DimensionBarView({ cssVar, capped, cappedValue, visual }) {
1804
1877
  switch (visual) {
1805
1878
  case "radius": return /* @__PURE__ */ jsx("div", {
1806
- style: {
1807
- ...styles$1.radiusSample,
1808
- borderRadius: cssVar
1809
- },
1879
+ className: "sb-dimension-bar__radius-sample",
1880
+ style: { borderRadius: cssVar },
1810
1881
  "aria-hidden": true
1811
1882
  });
1812
1883
  case "size": {
1813
1884
  const sample = /* @__PURE__ */ jsx("div", {
1885
+ className: "sb-dimension-bar__size-sample",
1814
1886
  style: {
1815
- ...styles$1.sizeSample,
1816
1887
  width: cappedValue,
1817
1888
  height: cappedValue
1818
1889
  },
@@ -1822,16 +1893,23 @@ function DimensionBar({ path, visual = "length" }) {
1822
1893
  }
1823
1894
  default: {
1824
1895
  const bar = /* @__PURE__ */ jsx("div", {
1825
- style: {
1826
- ...styles$1.bar,
1827
- width: cappedValue
1828
- },
1896
+ className: "sb-dimension-bar__bar",
1897
+ style: { width: cappedValue },
1829
1898
  "aria-hidden": true
1830
1899
  });
1831
1900
  return capped ? withCap(bar) : bar;
1832
1901
  }
1833
1902
  }
1834
1903
  }
1904
+ function DimensionBar({ path, visual = "length" }) {
1905
+ const { cssVar, capped, cappedValue } = deriveDimensionBar(path, useProject(), useRootFontSize());
1906
+ return /* @__PURE__ */ jsx(DimensionBarView, {
1907
+ cssVar,
1908
+ capped,
1909
+ cappedValue,
1910
+ visual
1911
+ });
1912
+ }
1835
1913
  //#endregion
1836
1914
  //#region src/internal/format-token-value.ts
1837
1915
  /**
@@ -1936,31 +2014,30 @@ function formatUnknown(v) {
1936
2014
  }
1937
2015
  //#endregion
1938
2016
  //#region src/DimensionScale.tsx
1939
- function DimensionScale({ filter, visual = "length", caption, sortBy = "value", sortDir = "asc" }) {
1940
- const project = useProject();
1941
- const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
1942
- const rootFontSize = useRootFontSize();
1943
- const rows = useMemo(() => {
1944
- return sortTokens(Object.entries(resolved).filter(([path, token]) => {
1945
- if (token.$type !== "dimension") return false;
1946
- return matchPath(path, filter);
1947
- }), {
1948
- by: sortBy,
1949
- dir: sortDir,
1950
- rootFontSizePx: rootFontSize
1951
- }).map(([path, token]) => ({
1952
- path,
1953
- cssVar: resolveCssVar(path, project),
1954
- displayValue: formatTokenValue(token.$value, token.$type, "raw", project.listing[path])
1955
- }));
1956
- }, [
1957
- resolved,
1958
- filter,
1959
- project,
1960
- sortBy,
1961
- sortDir,
1962
- rootFontSize
1963
- ]);
2017
+ /**
2018
+ * Pure derivation of the scale's display rows from resolved project data.
2019
+ * Extracted so it is unit-testable without React or a store.
2020
+ */
2021
+ function deriveDimensionRows(resolved, project, { filter, sortBy, sortDir, rootFontSizePx }) {
2022
+ return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2023
+ if (token.$type !== "dimension") return false;
2024
+ return matchPath(path, filter);
2025
+ }), {
2026
+ by: sortBy,
2027
+ dir: sortDir,
2028
+ rootFontSizePx
2029
+ }).map(([path, token]) => ({
2030
+ path,
2031
+ cssVar: resolveCssVar(path, project),
2032
+ displayValue: formatTokenValue(token.$value, token.$type, "raw", project.listing[path])
2033
+ }));
2034
+ }
2035
+ /**
2036
+ * Pure presentation for the dimension scale. Renders from plain props;
2037
+ * composes the connected `DimensionBar` as a child (that child reads the
2038
+ * project itself).
2039
+ */
2040
+ function DimensionScaleView({ rows, activeTheme, cssVarPrefix, activeAxes, visual, filter, caption }) {
1964
2041
  const captionText = caption ?? `${rows.length} dimension${rows.length === 1 ? "" : "s"}${filter ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
1965
2042
  if (rows.length === 0) return /* @__PURE__ */ jsx("div", {
1966
2043
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -2002,6 +2079,32 @@ function DimensionScale({ filter, visual = "length", caption, sortBy = "value",
2002
2079
  }, row.path))]
2003
2080
  });
2004
2081
  }
2082
+ function DimensionScale({ filter, visual = "length", caption, sortBy = "value", sortDir = "asc" }) {
2083
+ const project = useProject();
2084
+ const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2085
+ const rootFontSize = useRootFontSize();
2086
+ return /* @__PURE__ */ jsx(DimensionScaleView, {
2087
+ rows: useMemo(() => deriveDimensionRows(resolved, project, {
2088
+ filter,
2089
+ sortBy,
2090
+ sortDir,
2091
+ rootFontSizePx: rootFontSize
2092
+ }), [
2093
+ resolved,
2094
+ project,
2095
+ filter,
2096
+ sortBy,
2097
+ sortDir,
2098
+ rootFontSize
2099
+ ]),
2100
+ activeTheme,
2101
+ cssVarPrefix,
2102
+ activeAxes,
2103
+ visual,
2104
+ filter,
2105
+ caption
2106
+ });
2107
+ }
2005
2108
  //#endregion
2006
2109
  //#region src/FontFamilyPreview.tsx
2007
2110
  function stackString(raw) {
@@ -2009,28 +2112,25 @@ function stackString(raw) {
2009
2112
  if (Array.isArray(raw)) return raw.map(String).join(", ");
2010
2113
  return "";
2011
2114
  }
2012
- function FontFamilyPreview({ filter, sample = "The quick brown fox jumps over the lazy dog.", caption, sortBy = "path", sortDir = "asc" }) {
2013
- const project = useProject();
2014
- const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2015
- const rows = useMemo(() => {
2016
- return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2017
- if (token.$type !== "fontFamily") return false;
2018
- return matchPath(path, filter);
2019
- }), {
2020
- by: sortBy,
2021
- dir: sortDir
2022
- }).map(([path, token]) => ({
2023
- path,
2024
- cssVar: resolveCssVar(path, project),
2025
- stack: stackString(token.$value)
2026
- }));
2027
- }, [
2028
- resolved,
2029
- filter,
2030
- project,
2031
- sortBy,
2032
- sortDir
2033
- ]);
2115
+ /**
2116
+ * Pure derivation of the preview's display rows from resolved project data.
2117
+ * Extracted so it is unit-testable without React or a store.
2118
+ */
2119
+ function deriveFontFamilyRows(resolved, project, { filter, sortBy, sortDir }) {
2120
+ return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2121
+ if (token.$type !== "fontFamily") return false;
2122
+ return matchPath(path, filter);
2123
+ }), {
2124
+ by: sortBy,
2125
+ dir: sortDir
2126
+ }).map(([path, token]) => ({
2127
+ path,
2128
+ cssVar: resolveCssVar(path, project),
2129
+ stack: stackString(token.$value)
2130
+ }));
2131
+ }
2132
+ /** Pure presentation for the font-family preview. Renders from plain props. */
2133
+ function FontFamilyPreviewView({ rows, activeTheme, cssVarPrefix, activeAxes, sample, filter, caption }) {
2034
2134
  const captionText = caption ?? `${rows.length} fontFamily token${rows.length === 1 ? "" : "s"}${filter && filter !== "fontFamily" ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
2035
2135
  if (rows.length === 0) return /* @__PURE__ */ jsx("div", {
2036
2136
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -2070,6 +2170,29 @@ function FontFamilyPreview({ filter, sample = "The quick brown fox jumps over th
2070
2170
  }, row.path))]
2071
2171
  });
2072
2172
  }
2173
+ function FontFamilyPreview({ filter, sample = "The quick brown fox jumps over the lazy dog.", caption, sortBy = "path", sortDir = "asc" }) {
2174
+ const project = useProject();
2175
+ const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2176
+ return /* @__PURE__ */ jsx(FontFamilyPreviewView, {
2177
+ rows: useMemo(() => deriveFontFamilyRows(resolved, project, {
2178
+ filter,
2179
+ sortBy,
2180
+ sortDir
2181
+ }), [
2182
+ resolved,
2183
+ project,
2184
+ filter,
2185
+ sortBy,
2186
+ sortDir
2187
+ ]),
2188
+ activeTheme,
2189
+ cssVarPrefix,
2190
+ activeAxes,
2191
+ sample,
2192
+ filter,
2193
+ caption
2194
+ });
2195
+ }
2073
2196
  //#endregion
2074
2197
  //#region src/internal/css-var-style.ts
2075
2198
  /**
@@ -2095,29 +2218,26 @@ function toWeight(raw) {
2095
2218
  }
2096
2219
  return NaN;
2097
2220
  }
2098
- function FontWeightScale({ filter, sample = "Aa", caption, sortBy = "value", sortDir = "asc" }) {
2099
- const project = useProject();
2100
- const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2101
- const rows = useMemo(() => {
2102
- return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2103
- if (token.$type !== "fontWeight") return false;
2104
- return matchPath(path, filter);
2105
- }), {
2106
- by: sortBy,
2107
- dir: sortDir
2108
- }).map(([path, token]) => ({
2109
- path,
2110
- cssVar: resolveCssVar(path, project),
2111
- display: token.$value == null ? "" : String(token.$value),
2112
- weight: toWeight(token.$value)
2113
- }));
2114
- }, [
2115
- resolved,
2116
- filter,
2117
- project,
2118
- sortBy,
2119
- sortDir
2120
- ]);
2221
+ /**
2222
+ * Pure derivation of the scale's display rows from resolved project data.
2223
+ * Extracted so it is unit-testable without React or a store.
2224
+ */
2225
+ function deriveFontWeightRows(resolved, project, { filter, sortBy, sortDir }) {
2226
+ return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2227
+ if (token.$type !== "fontWeight") return false;
2228
+ return matchPath(path, filter);
2229
+ }), {
2230
+ by: sortBy,
2231
+ dir: sortDir
2232
+ }).map(([path, token]) => ({
2233
+ path,
2234
+ cssVar: resolveCssVar(path, project),
2235
+ display: token.$value == null ? "" : String(token.$value),
2236
+ weight: toWeight(token.$value)
2237
+ }));
2238
+ }
2239
+ /** Pure presentation for the font-weight scale. Renders from plain props. */
2240
+ function FontWeightScaleView({ rows, activeTheme, cssVarPrefix, activeAxes, sample, filter, caption }) {
2121
2241
  const captionText = caption ?? `${rows.length} fontWeight token${rows.length === 1 ? "" : "s"}${filter && filter !== "fontWeight" ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
2122
2242
  if (rows.length === 0) return /* @__PURE__ */ jsx("div", {
2123
2243
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -2157,6 +2277,29 @@ function FontWeightScale({ filter, sample = "Aa", caption, sortBy = "value", sor
2157
2277
  }, row.path))]
2158
2278
  });
2159
2279
  }
2280
+ function FontWeightScale({ filter, sample = "Aa", caption, sortBy = "value", sortDir = "asc" }) {
2281
+ const project = useProject();
2282
+ const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2283
+ return /* @__PURE__ */ jsx(FontWeightScaleView, {
2284
+ rows: useMemo(() => deriveFontWeightRows(resolved, project, {
2285
+ filter,
2286
+ sortBy,
2287
+ sortDir
2288
+ }), [
2289
+ resolved,
2290
+ project,
2291
+ filter,
2292
+ sortBy,
2293
+ sortDir
2294
+ ]),
2295
+ activeTheme,
2296
+ cssVarPrefix,
2297
+ activeAxes,
2298
+ sample,
2299
+ filter,
2300
+ caption
2301
+ });
2302
+ }
2160
2303
  //#endregion
2161
2304
  //#region src/GradientPalette.tsx
2162
2305
  function asStops(raw) {
@@ -2171,33 +2314,37 @@ function stopCssColor(stop) {
2171
2314
  function stopKey(path, stop, fallback) {
2172
2315
  return `${path}|${stop.position ?? fallback}|${stopCssColor(stop)}`;
2173
2316
  }
2174
- function GradientPalette({ filter, caption, sortBy = "path", sortDir = "asc" }) {
2175
- const { resolved, activeTheme, activeAxes, cssVarPrefix, listing } = useProject();
2176
- const colorFormat = useColorFormat();
2177
- const rows = useMemo(() => {
2178
- const projectFields = {
2179
- listing,
2180
- cssVarPrefix
2181
- };
2182
- return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2183
- if (token.$type !== "gradient") return false;
2184
- return matchPath(path, filter);
2185
- }), {
2186
- by: sortBy,
2187
- dir: sortDir
2188
- }).map(([path, token]) => ({
2317
+ /**
2318
+ * Pure derivation of the palette's gradient rows from resolved project
2319
+ * data. Extracted so it is unit-testable without React or a store.
2320
+ */
2321
+ function deriveGradientRows(resolved, listing, cssVarPrefix, { filter, sortBy, sortDir, colorFormat }) {
2322
+ const projectFields = {
2323
+ listing,
2324
+ cssVarPrefix
2325
+ };
2326
+ return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2327
+ if (token.$type !== "gradient") return false;
2328
+ return matchPath(path, filter);
2329
+ }), {
2330
+ by: sortBy,
2331
+ dir: sortDir
2332
+ }).map(([path, token]) => {
2333
+ const stops = asStops(token.$value);
2334
+ return {
2189
2335
  path,
2190
2336
  cssVar: resolveCssVar(path, projectFields),
2191
- stops: asStops(token.$value)
2192
- }));
2193
- }, [
2194
- resolved,
2195
- listing,
2196
- cssVarPrefix,
2197
- filter,
2198
- sortBy,
2199
- sortDir
2200
- ]);
2337
+ stops: stops.map((stop, i) => ({
2338
+ key: stopKey(path, stop, i),
2339
+ cssColor: stopCssColor(stop),
2340
+ value: formatColor(stop.color, colorFormat).value,
2341
+ positionPercent: ((stop.position ?? 0) * 100).toFixed(0)
2342
+ }))
2343
+ };
2344
+ });
2345
+ }
2346
+ /** Pure presentation for the gradient palette. Renders from plain props. */
2347
+ function GradientPaletteView({ rows, activeTheme, cssVarPrefix, activeAxes, filter, caption }) {
2201
2348
  const captionText = caption ?? `${rows.length} gradient${rows.length === 1 ? "" : "s"}${filter ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
2202
2349
  if (rows.length === 0) return /* @__PURE__ */ jsx("div", {
2203
2350
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -2231,30 +2378,55 @@ function GradientPalette({ filter, caption, sortBy = "path", sortDir = "asc" })
2231
2378
  }),
2232
2379
  /* @__PURE__ */ jsx("div", {
2233
2380
  className: "sb-gradient-palette__stops",
2234
- children: row.stops.map((stop, i) => /* @__PURE__ */ jsxs("div", {
2381
+ children: row.stops.map((stop) => /* @__PURE__ */ jsxs("div", {
2235
2382
  className: "sb-gradient-palette__stop-row",
2236
2383
  children: [
2237
2384
  /* @__PURE__ */ jsx("span", {
2238
2385
  className: "sb-gradient-palette__stop-swatch",
2239
- style: { background: stopCssColor(stop) },
2386
+ style: { background: stop.cssColor },
2240
2387
  "aria-hidden": true
2241
2388
  }),
2242
- /* @__PURE__ */ jsx("span", { children: formatColor(stop.color, colorFormat).value }),
2389
+ /* @__PURE__ */ jsx("span", { children: stop.value }),
2243
2390
  /* @__PURE__ */ jsxs("span", {
2244
2391
  className: "sb-gradient-palette__stop-position",
2245
2392
  children: [
2246
2393
  "@ ",
2247
- ((stop.position ?? 0) * 100).toFixed(0),
2394
+ stop.positionPercent,
2248
2395
  "%"
2249
2396
  ]
2250
2397
  })
2251
2398
  ]
2252
- }, stopKey(row.path, stop, i)))
2399
+ }, stop.key))
2253
2400
  })
2254
2401
  ]
2255
2402
  }, row.path))]
2256
2403
  });
2257
2404
  }
2405
+ function GradientPalette({ filter, caption, sortBy = "path", sortDir = "asc" }) {
2406
+ const { resolved, activeTheme, activeAxes, cssVarPrefix, listing } = useProject();
2407
+ const colorFormat = useColorFormat();
2408
+ return /* @__PURE__ */ jsx(GradientPaletteView, {
2409
+ rows: useMemo(() => deriveGradientRows(resolved, listing, cssVarPrefix, {
2410
+ filter,
2411
+ sortBy,
2412
+ sortDir,
2413
+ colorFormat
2414
+ }), [
2415
+ resolved,
2416
+ listing,
2417
+ cssVarPrefix,
2418
+ filter,
2419
+ sortBy,
2420
+ sortDir,
2421
+ colorFormat
2422
+ ]),
2423
+ activeTheme,
2424
+ cssVarPrefix,
2425
+ activeAxes,
2426
+ filter,
2427
+ caption
2428
+ });
2429
+ }
2258
2430
  //#endregion
2259
2431
  //#region src/internal/prefers-reduced-motion.ts
2260
2432
  function isChromatic() {
@@ -2287,29 +2459,6 @@ function usePrefersReducedMotion() {
2287
2459
  //#region src/motion-preview/MotionSample.tsx
2288
2460
  const DEFAULT_DURATION_MS = 300;
2289
2461
  const DEFAULT_EASING = "cubic-bezier(0.2, 0, 0, 1)";
2290
- const styles = {
2291
- track: {
2292
- position: "relative",
2293
- height: 36,
2294
- background: SURFACE_MUTED,
2295
- borderRadius: 18,
2296
- overflow: "hidden"
2297
- },
2298
- ball: {
2299
- position: "absolute",
2300
- top: "50%",
2301
- width: 28,
2302
- height: 28,
2303
- marginTop: -14,
2304
- borderRadius: "50%",
2305
- background: "var(--swatchbook-accent-bg, #3b82f6)"
2306
- },
2307
- reducedMotion: {
2308
- fontSize: 11,
2309
- color: TEXT_MUTED,
2310
- fontStyle: "italic"
2311
- }
2312
- };
2313
2462
  function extractDurationMs(raw) {
2314
2463
  if (raw == null) return NaN;
2315
2464
  if (typeof raw === "object") {
@@ -2379,10 +2528,16 @@ function resolveMotionSpec(token, themeTokens) {
2379
2528
  }
2380
2529
  return null;
2381
2530
  }
2382
- function MotionSample({ path, speed = 1, runKey = 0 }) {
2383
- const { resolved } = useProject();
2531
+ /**
2532
+ * Pure derivation of a single motion token's animation spec from resolved
2533
+ * project data. Extracted so it is unit-testable without React or a store.
2534
+ */
2535
+ function deriveMotionSample(path, project) {
2536
+ return { spec: resolveMotionSpec(project.resolved[path], project.resolved) };
2537
+ }
2538
+ /** Pure presentation + animation for a single motion token's sample. Renders from plain props. */
2539
+ function MotionSampleView({ spec, speed, runKey }) {
2384
2540
  const reducedMotion = usePrefersReducedMotion();
2385
- const spec = useMemo(() => resolveMotionSpec(resolved[path], resolved), [resolved, path]);
2386
2541
  const durationMs = spec?.durationMs ?? DEFAULT_DURATION_MS;
2387
2542
  const easing = spec?.easing ?? DEFAULT_EASING;
2388
2543
  const scaledDuration = Math.max(1, durationMs / speed);
@@ -2404,7 +2559,7 @@ function MotionSample({ path, speed = 1, runKey = 0 }) {
2404
2559
  reducedMotion
2405
2560
  ]);
2406
2561
  if (reducedMotion) return /* @__PURE__ */ jsxs("div", {
2407
- style: styles.reducedMotion,
2562
+ className: "sb-motion-sample__reduced-motion",
2408
2563
  children: [
2409
2564
  "Animation suppressed by ",
2410
2565
  /* @__PURE__ */ jsx("code", { children: "prefers-reduced-motion: reduce" }),
@@ -2412,17 +2567,22 @@ function MotionSample({ path, speed = 1, runKey = 0 }) {
2412
2567
  ]
2413
2568
  });
2414
2569
  return /* @__PURE__ */ jsx("div", {
2415
- style: styles.track,
2570
+ className: "sb-motion-sample__track",
2416
2571
  children: /* @__PURE__ */ jsx("div", {
2417
- style: {
2418
- ...styles.ball,
2419
- left: phase === 1 ? "calc(100% - 32px)" : "4px",
2420
- transition: `left ${scaledDuration}ms ${easing}`
2421
- },
2572
+ className: cx("sb-motion-sample__ball", phase === 1 ? "sb-motion-sample__ball--end" : "sb-motion-sample__ball--start"),
2573
+ style: { transition: `left ${scaledDuration}ms ${easing}` },
2422
2574
  "aria-hidden": true
2423
2575
  })
2424
2576
  });
2425
2577
  }
2578
+ function MotionSample({ path, speed = 1, runKey = 0 }) {
2579
+ const { spec } = deriveMotionSample(path, useProject());
2580
+ return /* @__PURE__ */ jsx(MotionSampleView, {
2581
+ spec,
2582
+ speed,
2583
+ runKey
2584
+ });
2585
+ }
2426
2586
  //#endregion
2427
2587
  //#region src/MotionPreview.tsx
2428
2588
  const SPEEDS = [
@@ -2438,43 +2598,48 @@ function formatSpec(row) {
2438
2598
  case "cubicBezier": return `cubicBezier · ${row.easing}`;
2439
2599
  }
2440
2600
  }
2441
- function MotionPreview({ filter, caption }) {
2442
- const project = useProject();
2443
- const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2601
+ /**
2602
+ * Pure derivation of the preview's display rows from resolved project data.
2603
+ * Extracted so it is unit-testable without React or a store.
2604
+ */
2605
+ function deriveMotionRows(resolved, project, { filter }) {
2606
+ const collected = [];
2607
+ for (const [path, token] of Object.entries(resolved)) {
2608
+ if (filter && !matchPath(path, filter)) continue;
2609
+ if (!filter && ![
2610
+ "transition",
2611
+ "duration",
2612
+ "cubicBezier"
2613
+ ].includes(token.$type ?? "")) continue;
2614
+ const kind = token.$type;
2615
+ if (!kind) continue;
2616
+ const spec = resolveMotionSpec(token, resolved);
2617
+ if (!spec) continue;
2618
+ collected.push({
2619
+ path,
2620
+ cssVar: resolveCssVar(path, project),
2621
+ durationMs: spec.durationMs,
2622
+ easing: spec.easing,
2623
+ kind
2624
+ });
2625
+ }
2626
+ collected.sort((a, b) => {
2627
+ if (a.kind !== b.kind) return a.kind.localeCompare(b.kind);
2628
+ return a.path.localeCompare(b.path, void 0, { numeric: true });
2629
+ });
2630
+ return collected;
2631
+ }
2632
+ /**
2633
+ * Pure presentation for the motion preview. Owns the speed/replay controls'
2634
+ * local UI state and the `prefers-reduced-motion` read (a browser-environment
2635
+ * concern, not project data); renders from the derived `rows` view-model.
2636
+ * Composes the connected `MotionSample` as a child (that child reads the
2637
+ * project itself).
2638
+ */
2639
+ function MotionPreviewView({ rows, activeTheme, cssVarPrefix, activeAxes, filter, caption }) {
2444
2640
  const [speed, setSpeed] = useState(1);
2445
2641
  const [run, setRun] = useState(0);
2446
2642
  const reducedMotion = usePrefersReducedMotion();
2447
- const rows = useMemo(() => {
2448
- const collected = [];
2449
- for (const [path, token] of Object.entries(resolved)) {
2450
- if (filter && !matchPath(path, filter)) continue;
2451
- if (!filter && ![
2452
- "transition",
2453
- "duration",
2454
- "cubicBezier"
2455
- ].includes(token.$type ?? "")) continue;
2456
- const kind = token.$type;
2457
- if (!kind) continue;
2458
- const spec = resolveMotionSpec(token, resolved);
2459
- if (!spec) continue;
2460
- collected.push({
2461
- path,
2462
- cssVar: resolveCssVar(path, project),
2463
- durationMs: spec.durationMs,
2464
- easing: spec.easing,
2465
- kind
2466
- });
2467
- }
2468
- collected.sort((a, b) => {
2469
- if (a.kind !== b.kind) return a.kind.localeCompare(b.kind);
2470
- return a.path.localeCompare(b.path, void 0, { numeric: true });
2471
- });
2472
- return collected;
2473
- }, [
2474
- resolved,
2475
- filter,
2476
- project
2477
- ]);
2478
2643
  const captionText = caption ?? `${rows.length} motion token${rows.length === 1 ? "" : "s"}${filter ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
2479
2644
  if (rows.length === 0) return /* @__PURE__ */ jsx("div", {
2480
2645
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -2540,6 +2705,22 @@ function MotionPreview({ filter, caption }) {
2540
2705
  ]
2541
2706
  });
2542
2707
  }
2708
+ function MotionPreview({ filter, caption }) {
2709
+ const project = useProject();
2710
+ const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2711
+ return /* @__PURE__ */ jsx(MotionPreviewView, {
2712
+ rows: useMemo(() => deriveMotionRows(resolved, project, { filter }), [
2713
+ resolved,
2714
+ project,
2715
+ filter
2716
+ ]),
2717
+ activeTheme,
2718
+ cssVarPrefix,
2719
+ activeAxes,
2720
+ filter,
2721
+ caption
2722
+ });
2723
+ }
2543
2724
  //#endregion
2544
2725
  //#region src/OpacityScale.tsx
2545
2726
  function toOpacity(raw) {
@@ -2547,44 +2728,33 @@ function toOpacity(raw) {
2547
2728
  return NaN;
2548
2729
  }
2549
2730
  /**
2550
- * Render each opacity token as a colored sample at that opacity over a
2551
- * checkerboard backdrop, so the transparency is visually readable. The
2552
- * number by itself (`0.4`) doesn't convey what the token looks like
2553
- * applied to a surface; the sample does.
2554
- *
2555
- * Only tokens whose `$value` is a finite number between 0 and 1
2556
- * inclusive are rendered — non-opacity `number` siblings (`line-height`,
2557
- * `z-index`) fall out naturally.
2731
+ * Pure derivation of the scale's display rows from resolved project data.
2732
+ * Only tokens whose `$value` is a finite number between 0 and 1 inclusive
2733
+ * are included non-opacity `number` siblings (`line-height`, `z-index`)
2734
+ * fall out naturally. Extracted so it is unit-testable without React or
2735
+ * a store.
2558
2736
  */
2559
- function OpacityScale({ filter, type = "number", sampleColor = "color.accent.bg", caption, sortBy = "value", sortDir = "asc" }) {
2560
- const project = useProject();
2561
- const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2562
- const rows = useMemo(() => {
2563
- return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2564
- if (token.$type !== type) return false;
2565
- const v = toOpacity(token.$value);
2566
- if (!Number.isFinite(v) || v < 0 || v > 1) return false;
2567
- return matchPath(path, filter);
2568
- }), {
2569
- by: sortBy,
2570
- dir: sortDir
2571
- }).map(([path, token]) => {
2572
- const opacity = toOpacity(token.$value);
2573
- return {
2574
- path,
2575
- cssVar: resolveCssVar(path, project),
2576
- opacity,
2577
- displayValue: String(opacity)
2578
- };
2579
- });
2580
- }, [
2581
- resolved,
2582
- filter,
2583
- type,
2584
- project,
2585
- sortBy,
2586
- sortDir
2587
- ]);
2737
+ function deriveOpacityRows(resolved, project, { filter, type, sortBy, sortDir }) {
2738
+ return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2739
+ if (token.$type !== type) return false;
2740
+ const v = toOpacity(token.$value);
2741
+ if (!Number.isFinite(v) || v < 0 || v > 1) return false;
2742
+ return matchPath(path, filter);
2743
+ }), {
2744
+ by: sortBy,
2745
+ dir: sortDir
2746
+ }).map(([path, token]) => {
2747
+ const opacity = toOpacity(token.$value);
2748
+ return {
2749
+ path,
2750
+ cssVar: resolveCssVar(path, project),
2751
+ opacity,
2752
+ displayValue: String(opacity)
2753
+ };
2754
+ });
2755
+ }
2756
+ /** Pure presentation for the opacity scale. Renders from plain props. */
2757
+ function OpacityScaleView({ rows, activeTheme, cssVarPrefix, activeAxes, sampleColorVar, filter, caption }) {
2588
2758
  const captionText = caption ?? `${rows.length} opacity token${rows.length === 1 ? "" : "s"}${filter ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
2589
2759
  if (rows.length === 0) return /* @__PURE__ */ jsx("div", {
2590
2760
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -2593,7 +2763,6 @@ function OpacityScale({ filter, type = "number", sampleColor = "color.accent.bg"
2593
2763
  children: "No opacity tokens match this filter."
2594
2764
  })
2595
2765
  });
2596
- const sampleColorVar = resolveCssVar(sampleColor, project);
2597
2766
  return /* @__PURE__ */ jsxs("div", {
2598
2767
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
2599
2768
  children: [/* @__PURE__ */ jsx("div", {
@@ -2631,6 +2800,37 @@ function OpacityScale({ filter, type = "number", sampleColor = "color.accent.bg"
2631
2800
  })]
2632
2801
  });
2633
2802
  }
2803
+ /**
2804
+ * Render each opacity token as a colored sample at that opacity over a
2805
+ * checkerboard backdrop, so the transparency is visually readable. The
2806
+ * number by itself (`0.4`) doesn't convey what the token looks like
2807
+ * applied to a surface; the sample does.
2808
+ */
2809
+ function OpacityScale({ filter, type = "number", sampleColor = "color.accent.bg", caption, sortBy = "value", sortDir = "asc" }) {
2810
+ const project = useProject();
2811
+ const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2812
+ return /* @__PURE__ */ jsx(OpacityScaleView, {
2813
+ rows: useMemo(() => deriveOpacityRows(resolved, project, {
2814
+ filter,
2815
+ type,
2816
+ sortBy,
2817
+ sortDir
2818
+ }), [
2819
+ resolved,
2820
+ project,
2821
+ filter,
2822
+ type,
2823
+ sortBy,
2824
+ sortDir
2825
+ ]),
2826
+ activeTheme,
2827
+ cssVarPrefix,
2828
+ activeAxes,
2829
+ sampleColorVar: resolveCssVar(sampleColor, project),
2830
+ filter,
2831
+ caption
2832
+ });
2833
+ }
2634
2834
  //#endregion
2635
2835
  //#region src/provider.tsx
2636
2836
  /**
@@ -2660,56 +2860,67 @@ function useSwatchbookData() {
2660
2860
  }
2661
2861
  //#endregion
2662
2862
  //#region src/shadow-preview/ShadowSample.tsx
2663
- const sampleStyle = {
2664
- width: 120,
2665
- height: 56,
2666
- background: SURFACE_RAISED,
2667
- border: BORDER_FAINT,
2668
- borderRadius: 6
2669
- };
2670
- function ShadowSample({ path }) {
2671
- const cssVar = resolveCssVar(path, useProject());
2863
+ /**
2864
+ * Pure derivation of a single shadow token's sample data from resolved
2865
+ * project data. Extracted so it is unit-testable without React or a store.
2866
+ */
2867
+ function deriveShadowSample(path, project) {
2868
+ return { cssVar: resolveCssVar(path, project) };
2869
+ }
2870
+ /** Pure presentation for a single shadow token's sample. Renders from plain props. */
2871
+ function ShadowSampleView({ cssVar }) {
2672
2872
  return /* @__PURE__ */ jsx("div", {
2673
- style: {
2674
- ...sampleStyle,
2675
- boxShadow: cssVar
2676
- },
2873
+ className: "sb-shadow-sample",
2874
+ style: { boxShadow: cssVar },
2677
2875
  "aria-hidden": true
2678
2876
  });
2679
2877
  }
2878
+ function ShadowSample({ path }) {
2879
+ const { cssVar } = deriveShadowSample(path, useProject());
2880
+ return /* @__PURE__ */ jsx(ShadowSampleView, { cssVar });
2881
+ }
2680
2882
  //#endregion
2681
2883
  //#region src/ShadowPreview.tsx
2884
+ function layerKey(path, layer, fallback) {
2885
+ return `${path}|${layer.offset}|${layer.blur}|${layer.spread}|${fallback}`;
2886
+ }
2682
2887
  function asLayers(raw) {
2683
2888
  if (Array.isArray(raw)) return raw;
2684
2889
  if (raw && typeof raw === "object") return [raw];
2685
2890
  return [];
2686
2891
  }
2687
- function layerKey(path, layer, fallback) {
2688
- return `${path}|${`${formatDimension$1(layer.offsetX)},${formatDimension$1(layer.offsetY)}`}|${formatDimension$1(layer.blur)}|${formatDimension$1(layer.spread)}|${fallback}`;
2892
+ function formatLayer(layer, colorFormat) {
2893
+ return {
2894
+ offset: `${formatDimension$1(layer.offsetX)} ${formatDimension$1(layer.offsetY)}`,
2895
+ blur: formatDimension$1(layer.blur),
2896
+ spread: formatDimension$1(layer.spread),
2897
+ color: formatSubColor(layer.color, colorFormat),
2898
+ inset: layer.inset ? String(layer.inset) : void 0
2899
+ };
2689
2900
  }
2690
- function ShadowPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
2691
- const project = useProject();
2692
- const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2693
- const colorFormat = useColorFormat();
2694
- const rows = useMemo(() => {
2695
- return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2696
- if (token.$type !== "shadow") return false;
2697
- return matchPath(path, filter);
2698
- }), {
2699
- by: sortBy,
2700
- dir: sortDir
2701
- }).map(([path, token]) => ({
2702
- path,
2703
- cssVar: resolveCssVar(path, project),
2704
- layers: asLayers(token.$value)
2705
- }));
2706
- }, [
2707
- resolved,
2708
- filter,
2709
- project,
2710
- sortBy,
2711
- sortDir
2712
- ]);
2901
+ /**
2902
+ * Pure derivation of the preview's display rows from resolved project data.
2903
+ * Extracted so it is unit-testable without React or a store.
2904
+ */
2905
+ function deriveShadowRows(resolved, project, { filter, sortBy, sortDir, colorFormat }) {
2906
+ return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2907
+ if (token.$type !== "shadow") return false;
2908
+ return matchPath(path, filter);
2909
+ }), {
2910
+ by: sortBy,
2911
+ dir: sortDir
2912
+ }).map(([path, token]) => ({
2913
+ path,
2914
+ cssVar: resolveCssVar(path, project),
2915
+ layers: asLayers(token.$value).map((layer) => formatLayer(layer, colorFormat))
2916
+ }));
2917
+ }
2918
+ /**
2919
+ * Pure presentation for the shadow preview. Renders from plain props;
2920
+ * composes the connected `ShadowSample` as a child (that child reads the
2921
+ * project itself).
2922
+ */
2923
+ function ShadowPreviewView({ rows, activeTheme, cssVarPrefix, activeAxes, filter, caption }) {
2713
2924
  const captionText = caption ?? `${rows.length} shadow${rows.length === 1 ? "" : "s"}${filter ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
2714
2925
  if (rows.length === 0) return /* @__PURE__ */ jsx("div", {
2715
2926
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -2742,32 +2953,31 @@ function ShadowPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
2742
2953
  }),
2743
2954
  /* @__PURE__ */ jsx("div", {
2744
2955
  className: "sb-shadow-preview__breakdown",
2745
- children: row.layers.length === 1 ? renderLayer(row.layers[0], colorFormat) : row.layers.map((layer, i) => /* @__PURE__ */ jsx(Layer, {
2956
+ children: row.layers.length === 1 ? renderLayerEntries(row.layers[0]) : row.layers.map((layer, i) => /* @__PURE__ */ jsx(Layer, {
2746
2957
  layer,
2747
2958
  index: i,
2748
- total: row.layers.length,
2749
- colorFormat
2959
+ total: row.layers.length
2750
2960
  }, layerKey(row.path, layer, i)))
2751
2961
  })
2752
2962
  ]
2753
2963
  }, row.path))]
2754
2964
  });
2755
2965
  }
2756
- function renderLayer(layer, format) {
2966
+ function renderLayerEntries(layer) {
2757
2967
  if (!layer) return [];
2758
2968
  const entries = [
2759
- ["offset", `${formatDimension$1(layer.offsetX)} ${formatDimension$1(layer.offsetY)}`],
2760
- ["blur", formatDimension$1(layer.blur)],
2761
- ["spread", formatDimension$1(layer.spread)],
2762
- ["color", formatSubColor(layer.color, format)]
2969
+ ["offset", layer.offset],
2970
+ ["blur", layer.blur],
2971
+ ["spread", layer.spread],
2972
+ ["color", layer.color]
2763
2973
  ];
2764
- if (layer.inset) entries.push(["inset", String(layer.inset)]);
2974
+ if (layer.inset) entries.push(["inset", layer.inset]);
2765
2975
  return entries.flatMap(([k, v]) => [/* @__PURE__ */ jsx("span", {
2766
2976
  className: "sb-shadow-preview__breakdown-key",
2767
2977
  children: k
2768
2978
  }, `k-${k}`), /* @__PURE__ */ jsx("span", { children: v }, `v-${k}`)]);
2769
2979
  }
2770
- function Layer({ layer, index, total, colorFormat }) {
2980
+ function Layer({ layer, index, total }) {
2771
2981
  return /* @__PURE__ */ jsxs("div", {
2772
2982
  className: "sb-shadow-preview__layer",
2773
2983
  children: [/* @__PURE__ */ jsxs("div", {
@@ -2780,10 +2990,35 @@ function Layer({ layer, index, total, colorFormat }) {
2780
2990
  ]
2781
2991
  }), /* @__PURE__ */ jsx("div", {
2782
2992
  className: cx("sb-shadow-preview__breakdown", "sb-shadow-preview__layer-breakdown"),
2783
- children: renderLayer(layer, colorFormat)
2993
+ children: renderLayerEntries(layer)
2784
2994
  })]
2785
2995
  });
2786
2996
  }
2997
+ function ShadowPreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
2998
+ const project = useProject();
2999
+ const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
3000
+ const colorFormat = useColorFormat();
3001
+ return /* @__PURE__ */ jsx(ShadowPreviewView, {
3002
+ rows: useMemo(() => deriveShadowRows(resolved, project, {
3003
+ filter,
3004
+ sortBy,
3005
+ sortDir,
3006
+ colorFormat
3007
+ }), [
3008
+ resolved,
3009
+ project,
3010
+ filter,
3011
+ sortBy,
3012
+ sortDir,
3013
+ colorFormat
3014
+ ]),
3015
+ activeTheme,
3016
+ cssVarPrefix,
3017
+ activeAxes,
3018
+ filter,
3019
+ caption
3020
+ });
3021
+ }
2787
3022
  //#endregion
2788
3023
  //#region src/StrokeStylePreview.tsx
2789
3024
  const STRING_STYLES = new Set([
@@ -2800,29 +3035,26 @@ function extractCssStyle(value) {
2800
3035
  if (typeof value === "string" && STRING_STYLES.has(value)) return value;
2801
3036
  return null;
2802
3037
  }
2803
- function StrokeStylePreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
2804
- const project = useProject();
2805
- const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
2806
- const rows = useMemo(() => {
2807
- return sortTokens(Object.entries(resolved).filter(([path, token]) => {
2808
- if (token.$type !== "strokeStyle") return false;
2809
- return matchPath(path, filter);
2810
- }), {
2811
- by: sortBy,
2812
- dir: sortDir
2813
- }).map(([path, token]) => ({
2814
- path,
2815
- cssVar: resolveCssVar(path, project),
2816
- displayValue: formatTokenValue(token.$value, token.$type, "raw", project.listing[path]),
2817
- cssStyle: extractCssStyle(token.$value)
2818
- }));
2819
- }, [
2820
- resolved,
2821
- filter,
2822
- project,
2823
- sortBy,
2824
- sortDir
2825
- ]);
3038
+ /**
3039
+ * Pure derivation of the preview's display rows from resolved project data.
3040
+ * Extracted so it is unit-testable without React or a store.
3041
+ */
3042
+ function deriveStrokeStyleRows(resolved, project, { filter, sortBy, sortDir }) {
3043
+ return sortTokens(Object.entries(resolved).filter(([path, token]) => {
3044
+ if (token.$type !== "strokeStyle") return false;
3045
+ return matchPath(path, filter);
3046
+ }), {
3047
+ by: sortBy,
3048
+ dir: sortDir
3049
+ }).map(([path, token]) => ({
3050
+ path,
3051
+ cssVar: resolveCssVar(path, project),
3052
+ displayValue: formatTokenValue(token.$value, token.$type, "raw", project.listing[path]),
3053
+ cssStyle: extractCssStyle(token.$value)
3054
+ }));
3055
+ }
3056
+ /** Pure presentation for the stroke-style preview. Renders from plain props. */
3057
+ function StrokeStylePreviewView({ rows, activeTheme, cssVarPrefix, activeAxes, filter, caption }) {
2826
3058
  const captionText = caption ?? `${rows.length} strokeStyle token${rows.length === 1 ? "" : "s"}${filter && filter !== "strokeStyle" ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
2827
3059
  if (rows.length === 0) return /* @__PURE__ */ jsx("div", {
2828
3060
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -2865,6 +3097,28 @@ function StrokeStylePreview({ filter, caption, sortBy = "path", sortDir = "asc"
2865
3097
  }, row.path))]
2866
3098
  });
2867
3099
  }
3100
+ function StrokeStylePreview({ filter, caption, sortBy = "path", sortDir = "asc" }) {
3101
+ const project = useProject();
3102
+ const { resolved, activeTheme, activeAxes, cssVarPrefix } = project;
3103
+ return /* @__PURE__ */ jsx(StrokeStylePreviewView, {
3104
+ rows: useMemo(() => deriveStrokeStyleRows(resolved, project, {
3105
+ filter,
3106
+ sortBy,
3107
+ sortDir
3108
+ }), [
3109
+ resolved,
3110
+ project,
3111
+ filter,
3112
+ sortBy,
3113
+ sortDir
3114
+ ]),
3115
+ activeTheme,
3116
+ cssVarPrefix,
3117
+ activeAxes,
3118
+ filter,
3119
+ caption
3120
+ });
3121
+ }
2868
3122
  //#endregion
2869
3123
  //#region src/token-detail/internal.ts
2870
3124
  function useTokenDetailData(path) {
@@ -3048,10 +3302,7 @@ function AxisVariance({ path }) {
3048
3302
  }),
3049
3303
  value,
3050
3304
  /* @__PURE__ */ jsx("span", {
3051
- style: {
3052
- opacity: .6,
3053
- marginLeft: 8
3054
- },
3305
+ className: "sb-token-detail__constant-label-note",
3055
3306
  children: "same across every axis"
3056
3307
  })
3057
3308
  ]
@@ -3085,8 +3336,7 @@ function AxisVariance({ path }) {
3085
3336
  "data-axis": axisName,
3086
3337
  "data-context": row.ctx,
3087
3338
  children: [/* @__PURE__ */ jsx("td", {
3088
- className: "sb-token-detail__theme-cell",
3089
- style: { width: "30%" },
3339
+ className: "sb-token-detail__theme-cell sb-token-detail__theme-cell--label",
3090
3340
  children: row.ctx
3091
3341
  }), /* @__PURE__ */ jsxs("td", {
3092
3342
  className: "sb-token-detail__theme-cell",
@@ -3113,22 +3363,14 @@ function AxisVariance({ path }) {
3113
3363
  children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", {
3114
3364
  className: "sb-token-detail__theme-row",
3115
3365
  children: [/* @__PURE__ */ jsxs("th", {
3116
- className: "sb-token-detail__theme-cell",
3117
- style: {
3118
- textAlign: "left",
3119
- opacity: .7
3120
- },
3366
+ className: "sb-token-detail__theme-cell sb-token-detail__theme-cell--header",
3121
3367
  children: [
3122
3368
  rowAxis.name,
3123
3369
  " \\ ",
3124
3370
  colAxis.name
3125
3371
  ]
3126
3372
  }), colAxis.contexts.map((col) => /* @__PURE__ */ jsx("th", {
3127
- className: "sb-token-detail__theme-cell",
3128
- style: {
3129
- textAlign: "left",
3130
- opacity: .7
3131
- },
3373
+ className: "sb-token-detail__theme-cell sb-token-detail__theme-cell--header",
3132
3374
  children: col
3133
3375
  }, col))]
3134
3376
  }) }), /* @__PURE__ */ jsx("tbody", { children: rowAxis.contexts.map((row) => /* @__PURE__ */ jsxs("tr", {
@@ -3158,8 +3400,7 @@ function AxisVariance({ path }) {
3158
3400
  }, row)) })]
3159
3401
  }),
3160
3402
  extra.length > 0 && /* @__PURE__ */ jsxs("div", {
3161
- className: "sb-token-detail__aliased-by-truncated",
3162
- style: { marginTop: 6 },
3403
+ className: "sb-token-detail__aliased-by-truncated sb-token-detail__aliased-by-truncated--axis-note",
3163
3404
  children: [
3164
3405
  "Values also vary with ",
3165
3406
  extra.map((a) => a.name).join(", "),
@@ -3270,7 +3511,7 @@ function CompositeBreakdownContent({ type, rawValue, partialAliasOf, resolved, c
3270
3511
  children: layers.map((layer, i) => {
3271
3512
  const v = layer;
3272
3513
  return /* @__PURE__ */ jsxs("div", {
3273
- style: { display: "contents" },
3514
+ className: "sb-token-detail__shadow-layer",
3274
3515
  children: [
3275
3516
  multi && /* @__PURE__ */ jsxs("div", {
3276
3517
  className: "sb-token-detail__breakdown-layer-header",
@@ -3612,24 +3853,30 @@ function TransitionSample({ transition, durationMs }) {
3612
3853
  return /* @__PURE__ */ jsx("div", {
3613
3854
  className: "sb-token-detail__motion-track",
3614
3855
  children: /* @__PURE__ */ jsx("div", {
3615
- className: "sb-token-detail__motion-ball",
3616
- style: {
3617
- left: phase === 1 ? "calc(100% - 28px)" : "4px",
3618
- transition
3619
- },
3856
+ className: cx("sb-token-detail__motion-ball", phase === 1 ? "sb-token-detail__motion-ball--end" : "sb-token-detail__motion-ball--start"),
3857
+ style: { transition },
3620
3858
  "aria-hidden": true
3621
3859
  })
3622
3860
  });
3623
3861
  }
3624
3862
  //#endregion
3625
3863
  //#region src/token-detail/ConsumerOutput.tsx
3626
- function ConsumerOutput({ path }) {
3627
- const { token, cssVar, activeAxes } = useTokenDetailData(path);
3628
- const { listing } = useProject();
3629
- if (!token) return null;
3630
- const tupleLabel = Object.entries(activeAxes).map(([k, v]) => `${k}=${v}`).join(", ");
3864
+ /**
3865
+ * Pure derivation of the consumer-output platform rows from the Token
3866
+ * Listing. Extracted so it is unit-testable without React or a store.
3867
+ */
3868
+ function deriveConsumerOutput(path, listing) {
3631
3869
  const names = listing[path]?.names ?? {};
3632
- const extraPlatforms = Object.keys(names).filter((platform) => platform !== "css" && names[platform]).toSorted();
3870
+ return { extraPlatforms: Object.keys(names).filter((platform) => platform !== "css" && names[platform]).toSorted().map((platform) => ({
3871
+ platform,
3872
+ label: formatPlatformLabel(platform),
3873
+ value: names[platform]
3874
+ })) };
3875
+ }
3876
+ /** Pure presentation for the consumer-output section. Renders from plain props; owns the copy-button's local feedback state. */
3877
+ function ConsumerOutputView({ path, cssVar, activeAxes, hasToken, extraPlatforms }) {
3878
+ if (!hasToken) return null;
3879
+ const tupleLabel = Object.entries(activeAxes).map(([k, v]) => `${k}=${v}`).join(", ");
3633
3880
  return /* @__PURE__ */ jsxs(Fragment, { children: [
3634
3881
  /* @__PURE__ */ jsx("div", {
3635
3882
  className: "sb-token-detail__section-header",
@@ -3649,13 +3896,25 @@ function ConsumerOutput({ path }) {
3649
3896
  value: cssVar,
3650
3897
  testId: "consumer-output-css"
3651
3898
  }),
3652
- extraPlatforms.map((platform) => /* @__PURE__ */ jsx(OutputRow, {
3653
- label: formatPlatformLabel(platform),
3654
- value: names[platform],
3655
- testId: `consumer-output-${platform}`
3656
- }, platform))
3899
+ extraPlatforms.map((entry) => /* @__PURE__ */ jsx(OutputRow, {
3900
+ label: entry.label,
3901
+ value: entry.value,
3902
+ testId: `consumer-output-${entry.platform}`
3903
+ }, entry.platform))
3657
3904
  ] });
3658
3905
  }
3906
+ function ConsumerOutput({ path }) {
3907
+ const { token, cssVar, activeAxes } = useTokenDetailData(path);
3908
+ const { listing } = useProject();
3909
+ const { extraPlatforms } = deriveConsumerOutput(path, listing);
3910
+ return /* @__PURE__ */ jsx(ConsumerOutputView, {
3911
+ path,
3912
+ cssVar,
3913
+ activeAxes,
3914
+ hasToken: Boolean(token),
3915
+ extraPlatforms
3916
+ });
3917
+ }
3659
3918
  function formatPlatformLabel(platform) {
3660
3919
  if (platform.length === 0) return platform;
3661
3920
  return platform[0].toUpperCase() + platform.slice(1);
@@ -3776,10 +4035,43 @@ function TokenUsageSnippet({ path }) {
3776
4035
  }
3777
4036
  //#endregion
3778
4037
  //#region src/TokenDetail.tsx
3779
- function TokenDetail({ path, heading }) {
3780
- const { token, cssVar, activeTheme, activeAxes, cssVarPrefix } = useTokenDetailData(path);
3781
- const colorFormat = useColorFormat();
3782
- const { listing } = useProject();
4038
+ const EMPTY_DERIVED = {
4039
+ value: "",
4040
+ outOfGamut: false,
4041
+ isColor: false,
4042
+ isDeprecated: false,
4043
+ deprecationMessage: void 0
4044
+ };
4045
+ /**
4046
+ * Pure derivation of the token's display value and status flags. Mirrors
4047
+ * `formatTokenValue` + `formatColor`'s gamut check, both of which need the
4048
+ * resolved token's raw `$value`/`$type` — the listing alone (no `$value`)
4049
+ * can't drive this. Extracted so it is unit-testable without React or a
4050
+ * store; the View renders purely from this shape plus the token's presence.
4051
+ */
4052
+ function deriveTokenDetail(path, token, listing, colorFormat) {
4053
+ if (!token) return EMPTY_DERIVED;
4054
+ const isColor = token.$type === "color";
4055
+ const gamut = isColor ? formatColor(token.$value, colorFormat) : null;
4056
+ const value = formatTokenValue(token.$value, token.$type, colorFormat, listing[path]);
4057
+ const outOfGamut = gamut?.outOfGamut ?? false;
4058
+ const dep = token.$deprecated;
4059
+ return {
4060
+ value,
4061
+ outOfGamut,
4062
+ isColor,
4063
+ isDeprecated: dep === true || typeof dep === "string" && dep.length > 0,
4064
+ deprecationMessage: typeof dep === "string" ? dep : void 0
4065
+ };
4066
+ }
4067
+ /**
4068
+ * Pure presentation for the token detail panel. Renders from plain props;
4069
+ * composes the connected `TokenHeader` / `CompositePreview` /
4070
+ * `CompositeBreakdown` / `AliasChain` / `AliasedBy` / `TokenUsageSnippet` /
4071
+ * `ConsumerOutput` / `AxisVariance` as children (each reads the project
4072
+ * itself for `path`).
4073
+ */
4074
+ function TokenDetailView({ path, heading, token, cssVar, activeTheme, activeAxes, cssVarPrefix, value, outOfGamut, isColor, isDeprecated, deprecationMessage }) {
3783
4075
  const wrapperAttrs = blockWrapperAttrs(cssVarPrefix, activeAxes);
3784
4076
  if (!token) return /* @__PURE__ */ jsx("div", {
3785
4077
  ...wrapperAttrs,
@@ -3795,12 +4087,6 @@ function TokenDetail({ path, heading }) {
3795
4087
  ]
3796
4088
  })
3797
4089
  });
3798
- const isColor = token.$type === "color";
3799
- const gamut = isColor ? formatColor(token.$value, colorFormat) : null;
3800
- const value = formatTokenValue(token.$value, token.$type, colorFormat, listing[path]);
3801
- const outOfGamut = gamut?.outOfGamut ?? false;
3802
- const dep = token.$deprecated;
3803
- const isDeprecated = dep === true || typeof dep === "string" && dep.length > 0;
3804
4090
  return /* @__PURE__ */ jsxs("div", {
3805
4091
  ...wrapperAttrs,
3806
4092
  className: cx(wrapperAttrs["className"], "sb-token-detail"),
@@ -3819,7 +4105,7 @@ function TokenDetail({ path, heading }) {
3819
4105
  children: "⚠ "
3820
4106
  }),
3821
4107
  "Deprecated",
3822
- typeof dep === "string" ? `: ${dep}` : ""
4108
+ deprecationMessage ? `: ${deprecationMessage}` : ""
3823
4109
  ]
3824
4110
  }),
3825
4111
  /* @__PURE__ */ jsxs("div", {
@@ -3840,7 +4126,7 @@ function TokenDetail({ path, heading }) {
3840
4126
  outOfGamut && /* @__PURE__ */ jsx("span", {
3841
4127
  title: "Out of sRGB gamut for this format",
3842
4128
  "aria-label": "out of gamut",
3843
- style: { marginLeft: 6 },
4129
+ className: "sb-token-detail__out-of-gamut-icon",
3844
4130
  children: "⚠"
3845
4131
  }),
3846
4132
  /* @__PURE__ */ jsx(CopyButton$1, {
@@ -3857,6 +4143,22 @@ function TokenDetail({ path, heading }) {
3857
4143
  ]
3858
4144
  });
3859
4145
  }
4146
+ function TokenDetail({ path, heading }) {
4147
+ const { token, cssVar, activeTheme, activeAxes, cssVarPrefix } = useTokenDetailData(path);
4148
+ const colorFormat = useColorFormat();
4149
+ const { listing } = useProject();
4150
+ const derived = deriveTokenDetail(path, token, listing, colorFormat);
4151
+ return /* @__PURE__ */ jsx(TokenDetailView, {
4152
+ path,
4153
+ ...heading !== void 0 && { heading },
4154
+ token,
4155
+ cssVar,
4156
+ activeTheme,
4157
+ activeAxes,
4158
+ cssVarPrefix,
4159
+ ...derived
4160
+ });
4161
+ }
3860
4162
  //#endregion
3861
4163
  //#region src/internal/DetailOverlay.tsx
3862
4164
  const FOCUSABLE_SELECTOR = [
@@ -3985,6 +4287,13 @@ function isInView(path, ctx) {
3985
4287
  }
3986
4288
  //#endregion
3987
4289
  //#region src/TokenNavigator.tsx
4290
+ /**
4291
+ * Pure derivation of the navigator's tree from the resolved token map:
4292
+ * groups paths by dotted segment (scoped to `root` when set, restricted to
4293
+ * `typeFilter`'s `$type`s when set), producing nested `GroupNode`s down to
4294
+ * `LeafNode`s, sorted groups-before-leaves and alphanumeric within a level.
4295
+ * Extracted so it is unit-testable without React or a store.
4296
+ */
3988
4297
  function buildTree(resolved, root, typeFilter) {
3989
4298
  const rootPrefix = root && root.length > 0 ? `${root}.` : "";
3990
4299
  const rootSegments = root ? root.split(".") : [];
@@ -4061,6 +4370,12 @@ function collectLeafPaths(nodes, out) {
4061
4370
  for (const node of nodes) if (node.kind === "leaf") out.push(node.path);
4062
4371
  else collectLeafPaths(node.children, out);
4063
4372
  }
4373
+ /**
4374
+ * Pure derivation of the flattened, currently-visible treeitem order from a
4375
+ * tree plus the set of expanded group paths — depth-first, only descending
4376
+ * into expanded groups. Appends to `out` (caller passes `[]`). Extracted so
4377
+ * it is unit-testable without React or a store.
4378
+ */
4064
4379
  function flattenVisible(nodes, expanded, parentPath, out) {
4065
4380
  for (const node of nodes) {
4066
4381
  out.push({
@@ -4087,18 +4402,18 @@ function pruneTreeForMatches(nodes, matches, expandOut) {
4087
4402
  }
4088
4403
  return out;
4089
4404
  }
4090
- function TokenNavigator({ root, type, initiallyExpanded = 1, searchable = true, onSelect, id, indicators }) {
4091
- const { resolved, activeTheme, activeAxes, cssVarPrefix, indicators: indicatorBaseline } = useProject();
4092
- const blockKey = useBlockKey("TokenNavigator", [
4093
- root,
4094
- type === void 0 ? "" : typeof type === "string" ? type : type.join(","),
4095
- id
4096
- ]);
4405
+ /**
4406
+ * Pure presentation for the token navigator. Owns its own expand/collapse,
4407
+ * selection, search, and roving-tabindex keyboard-nav state; renders the
4408
+ * tree from the `resolved` view-model. Composes the connected `RowIndicators`
4409
+ * / `DetailOverlay` / sample previews as children (those read the project
4410
+ * themselves).
4411
+ */
4412
+ function TokenNavigatorView({ resolved, root, enabledIndicators, cssVarPrefix, activeAxes, activeTheme, blockKey, type, initiallyExpanded = 1, searchable = true, onSelect }) {
4097
4413
  const typeFilter = useMemo(() => {
4098
4414
  if (type === void 0) return void 0;
4099
4415
  return new Set(Array.isArray(type) ? type : [type]);
4100
4416
  }, [type]);
4101
- const enabledIndicators = useMemo(() => resolveIndicators(indicators, indicatorBaseline), [indicators, indicatorBaseline]);
4102
4417
  const tree = useMemo(() => buildTree(resolved, root, typeFilter), [
4103
4418
  resolved,
4104
4419
  root,
@@ -4314,7 +4629,10 @@ function TokenNavigator({ root, type, initiallyExpanded = 1, searchable = true,
4314
4629
  }, [visibleTree, searchExpanded]);
4315
4630
  if (tree.length === 0) return /* @__PURE__ */ jsx("div", {
4316
4631
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
4317
- children: /* @__PURE__ */ jsx(EmptyState, { children: root ? `No tokens under "${root}"${typeFilter ? ` matching ${typeLabel.slice(3)}` : ""}.` : typeFilter ? `No tokens matching ${typeLabel.slice(3)} in the active theme.` : "No tokens in the active theme." })
4632
+ children: /* @__PURE__ */ jsx("div", {
4633
+ className: "sb-block__empty",
4634
+ children: root ? `No tokens under "${root}"${typeFilter ? ` matching ${typeLabel.slice(3)}` : ""}.` : typeFilter ? `No tokens matching ${typeLabel.slice(3)} in the active theme.` : "No tokens in the active theme."
4635
+ })
4318
4636
  });
4319
4637
  return /* @__PURE__ */ jsxs("div", {
4320
4638
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -4384,6 +4702,32 @@ function TokenNavigator({ root, type, initiallyExpanded = 1, searchable = true,
4384
4702
  ]
4385
4703
  });
4386
4704
  }
4705
+ /**
4706
+ * A collapsible, searchable tree of tokens with roving-tabindex keyboard
4707
+ * navigation. Click a leaf to inspect it in a slide-over (unless `onSelect`
4708
+ * is provided, which hands the follow-up to the consumer).
4709
+ */
4710
+ function TokenNavigator({ root, type, initiallyExpanded = 1, searchable = true, onSelect, id, indicators }) {
4711
+ const { resolved, activeTheme, activeAxes, cssVarPrefix, indicators: indicatorBaseline } = useProject();
4712
+ const blockKey = useBlockKey("TokenNavigator", [
4713
+ root,
4714
+ type === void 0 ? "" : typeof type === "string" ? type : type.join(","),
4715
+ id
4716
+ ]);
4717
+ return /* @__PURE__ */ jsx(TokenNavigatorView, {
4718
+ resolved,
4719
+ root,
4720
+ enabledIndicators: useMemo(() => resolveIndicators(indicators, indicatorBaseline), [indicators, indicatorBaseline]),
4721
+ cssVarPrefix,
4722
+ activeAxes,
4723
+ activeTheme,
4724
+ blockKey,
4725
+ type,
4726
+ initiallyExpanded,
4727
+ searchable,
4728
+ onSelect
4729
+ });
4730
+ }
4387
4731
  function TreeNodeRow({ node, expanded, focusedPath, registerTreeItem, onToggle, onFocusPath, onLeafClick, root, resolveInView, onNavigate, enabled, level, setsize, posinset }) {
4388
4732
  if (node.kind === "leaf") return /* @__PURE__ */ jsx(LeafRow, {
4389
4733
  node,
@@ -4572,56 +4916,51 @@ const LeafPreview = memo(function LeafPreview({ path, token }) {
4572
4916
  });
4573
4917
  //#endregion
4574
4918
  //#region src/TokenTable.tsx
4575
- function TokenTable({ filter, type, caption, sortBy = "path", sortDir = "asc", searchable = true, onSelect, id, indicators }) {
4576
- const { resolved, activeTheme, activeAxes, cssVarPrefix, listing, varianceByPath, indicators: indicatorBaseline } = useProject();
4577
- const colorFormat = useColorFormat();
4578
- const rootFontSize = useRootFontSize();
4579
- const blockKey = useBlockKey("TokenTable", [
4580
- filter,
4581
- type,
4582
- caption,
4583
- id
4584
- ]);
4585
- const enabledIndicators = useMemo(() => resolveIndicators(indicators, indicatorBaseline), [indicators, indicatorBaseline]);
4919
+ /**
4920
+ * Pure derivation of the table's display rows from resolved project data.
4921
+ * Carries per-row `isDeprecated` / `token` / `variance` so the View renders
4922
+ * the indicator strip and deprecation state without reaching back into the
4923
+ * project. Extracted so it is unit-testable without React or a store.
4924
+ */
4925
+ function deriveTokenRows(resolved, listing, cssVarPrefix, varianceByPath, { filter, type, sortBy, sortDir, colorFormat, rootFontSizePx }) {
4926
+ const projectFields = {
4927
+ listing,
4928
+ cssVarPrefix
4929
+ };
4930
+ return sortTokens(Object.entries(resolved).filter(([path, token]) => {
4931
+ if (!matchPath(path, filter)) return false;
4932
+ if (type && token.$type !== type) return false;
4933
+ return true;
4934
+ }), {
4935
+ by: sortBy,
4936
+ dir: sortDir,
4937
+ rootFontSizePx
4938
+ }).map(([path, token]) => {
4939
+ const isColor = token.$type === "color";
4940
+ const color = isColor ? resolveColorValue(path, token.$value, colorFormat, projectFields) : null;
4941
+ const dep = token.$deprecated;
4942
+ return {
4943
+ path,
4944
+ type: token.$type ?? "",
4945
+ value: formatTokenValue(token.$value, token.$type, colorFormat, listing[path]),
4946
+ outOfGamut: color?.outOfGamut ?? false,
4947
+ cssVar: resolveCssVar(path, projectFields),
4948
+ isColor,
4949
+ isDeprecated: dep === true || typeof dep === "string" && dep.length > 0,
4950
+ token,
4951
+ variance: varianceByPath[path]
4952
+ };
4953
+ });
4954
+ }
4955
+ /**
4956
+ * Pure presentation for the token table. Owns its own search + selection UI
4957
+ * state; renders from the derived `rows` view-model. Composes the connected
4958
+ * DetailOverlay as a child (that child reads the project itself).
4959
+ */
4960
+ function TokenTableView({ rows, activeTheme, cssVarPrefix, activeAxes, colorFormat, enabledIndicators, validPaths, blockKey, filter, type, caption, searchable = true, onSelect }) {
4586
4961
  const [selectedPath, setSelectedPath] = usePersistedState(`${blockKey}::selected`, null);
4587
4962
  const [query, setQuery] = usePersistedState(`${blockKey}::query`, "");
4588
4963
  const deferredQuery = useDeferredValue(query);
4589
- const rows = useMemo(() => {
4590
- const projectFields = {
4591
- listing,
4592
- cssVarPrefix
4593
- };
4594
- return sortTokens(Object.entries(resolved).filter(([path, token]) => {
4595
- if (!matchPath(path, filter)) return false;
4596
- if (type && token.$type !== type) return false;
4597
- return true;
4598
- }), {
4599
- by: sortBy,
4600
- dir: sortDir,
4601
- rootFontSizePx: rootFontSize
4602
- }).map(([path, token]) => {
4603
- const isColor = token.$type === "color";
4604
- const color = isColor ? resolveColorValue(path, token.$value, colorFormat, projectFields) : null;
4605
- return {
4606
- path,
4607
- type: token.$type ?? "",
4608
- value: formatTokenValue(token.$value, token.$type, colorFormat, listing[path]),
4609
- outOfGamut: color?.outOfGamut ?? false,
4610
- cssVar: resolveCssVar(path, projectFields),
4611
- isColor
4612
- };
4613
- });
4614
- }, [
4615
- resolved,
4616
- listing,
4617
- cssVarPrefix,
4618
- filter,
4619
- type,
4620
- colorFormat,
4621
- sortBy,
4622
- sortDir,
4623
- rootFontSize
4624
- ]);
4625
4964
  const visibleRows = useMemo(() => {
4626
4965
  if (!searchable || deferredQuery.trim() === "") return rows;
4627
4966
  return fuzzyFilter(rows, deferredQuery, (row) => `${row.path} ${row.type} ${row.value}`);
@@ -4697,9 +5036,7 @@ function TokenTable({ filter, type, caption, sortBy = "path", sortDir = "asc", s
4697
5036
  "\"."
4698
5037
  ]
4699
5038
  }) }), visibleRows.map((row) => {
4700
- const token = resolved[row.path];
4701
- const dep = token?.$deprecated;
4702
- const isDeprecated = dep === true || typeof dep === "string" && dep.length > 0;
5039
+ const token = row.token;
4703
5040
  return /* @__PURE__ */ jsxs("tr", {
4704
5041
  className: "sb-token-table__row",
4705
5042
  onClick: () => handleRowClick(row.path),
@@ -4717,7 +5054,7 @@ function TokenTable({ filter, type, caption, sortBy = "path", sortDir = "asc", s
4717
5054
  children: [
4718
5055
  /* @__PURE__ */ jsx("td", {
4719
5056
  className: cx("sb-token-table__td", "sb-token-table__path"),
4720
- "data-deprecated": enabledIndicators.deprecation && isDeprecated ? "true" : void 0,
5057
+ "data-deprecated": enabledIndicators.deprecation && row.isDeprecated ? "true" : void 0,
4721
5058
  children: row.path
4722
5059
  }),
4723
5060
  /* @__PURE__ */ jsx("td", {
@@ -4766,9 +5103,9 @@ function TokenTable({ filter, type, caption, sortBy = "path", sortDir = "asc", s
4766
5103
  path: row.path,
4767
5104
  token,
4768
5105
  root: void 0,
4769
- variance: varianceByPath[row.path],
5106
+ variance: row.variance,
4770
5107
  colorFormat,
4771
- canReference: (p) => p in resolved,
5108
+ canReference: (p) => validPaths.has(p),
4772
5109
  onReferenceClick: (p) => setSelectedPath(p),
4773
5110
  enabled: enabledIndicators
4774
5111
  })
@@ -4786,6 +5123,56 @@ function TokenTable({ filter, type, caption, sortBy = "path", sortDir = "asc", s
4786
5123
  ]
4787
5124
  });
4788
5125
  }
5126
+ /**
5127
+ * A sortable, searchable table of tokens. Click a row to inspect it in a
5128
+ * slide-over (unless `onSelect` is provided, which hands the follow-up to the
5129
+ * consumer).
5130
+ */
5131
+ function TokenTable({ filter, type, caption, sortBy = "path", sortDir = "asc", searchable = true, onSelect, id, indicators }) {
5132
+ const { resolved, activeTheme, activeAxes, cssVarPrefix, listing, varianceByPath, indicators: indicatorBaseline } = useProject();
5133
+ const colorFormat = useColorFormat();
5134
+ const rootFontSize = useRootFontSize();
5135
+ const blockKey = useBlockKey("TokenTable", [
5136
+ filter,
5137
+ type,
5138
+ caption,
5139
+ id
5140
+ ]);
5141
+ const enabledIndicators = useMemo(() => resolveIndicators(indicators, indicatorBaseline), [indicators, indicatorBaseline]);
5142
+ return /* @__PURE__ */ jsx(TokenTableView, {
5143
+ rows: useMemo(() => deriveTokenRows(resolved, listing, cssVarPrefix, varianceByPath, {
5144
+ filter,
5145
+ type,
5146
+ sortBy,
5147
+ sortDir,
5148
+ colorFormat,
5149
+ rootFontSizePx: rootFontSize
5150
+ }), [
5151
+ resolved,
5152
+ listing,
5153
+ cssVarPrefix,
5154
+ varianceByPath,
5155
+ filter,
5156
+ type,
5157
+ sortBy,
5158
+ sortDir,
5159
+ colorFormat,
5160
+ rootFontSize
5161
+ ]),
5162
+ activeTheme,
5163
+ cssVarPrefix,
5164
+ activeAxes,
5165
+ colorFormat,
5166
+ enabledIndicators,
5167
+ validPaths: useMemo(() => new Set(Object.keys(resolved)), [resolved]),
5168
+ blockKey,
5169
+ filter,
5170
+ type,
5171
+ caption,
5172
+ searchable,
5173
+ onSelect
5174
+ });
5175
+ }
4789
5176
  //#endregion
4790
5177
  //#region src/TypographyScale.tsx
4791
5178
  function asDimension(raw) {
@@ -4822,30 +5209,29 @@ function buildRow(path, composite) {
4822
5209
  ].filter(Boolean).join(" · ")
4823
5210
  };
4824
5211
  }
4825
- function TypographyScale({ filter, sample = "The quick brown fox jumps over the lazy dog.", caption, sortBy = "path", sortDir = "asc" }) {
4826
- const { resolved, activeTheme, activeAxes, cssVarPrefix } = useProject();
4827
- const rows = useMemo(() => {
4828
- return sortTokens(Object.entries(resolved).filter(([path, token]) => {
4829
- if (token.$type !== "typography") return false;
4830
- return matchPath(path, filter);
4831
- }), {
4832
- by: sortBy,
4833
- dir: sortDir
4834
- }).map(([path, token]) => {
4835
- const value = token.$value;
4836
- if (!value || typeof value !== "object") return {
4837
- path,
4838
- sampleStyle: {},
4839
- specs: ""
4840
- };
4841
- return buildRow(path, value);
4842
- });
4843
- }, [
4844
- resolved,
4845
- filter,
4846
- sortBy,
4847
- sortDir
4848
- ]);
5212
+ /**
5213
+ * Pure derivation of the scale's display rows from resolved project data.
5214
+ * Extracted so it is unit-testable without React or a store.
5215
+ */
5216
+ function deriveTypographyRows(resolved, { filter, sortBy, sortDir }) {
5217
+ return sortTokens(Object.entries(resolved).filter(([path, token]) => {
5218
+ if (token.$type !== "typography") return false;
5219
+ return matchPath(path, filter);
5220
+ }), {
5221
+ by: sortBy,
5222
+ dir: sortDir
5223
+ }).map(([path, token]) => {
5224
+ const value = token.$value;
5225
+ if (!value || typeof value !== "object") return {
5226
+ path,
5227
+ sampleStyle: {},
5228
+ specs: ""
5229
+ };
5230
+ return buildRow(path, value);
5231
+ });
5232
+ }
5233
+ /** Pure presentation for the typography scale. Renders from plain props. */
5234
+ function TypographyScaleView({ rows, activeTheme, cssVarPrefix, activeAxes, sample, filter, caption }) {
4849
5235
  const captionText = caption ?? `${rows.length} typography token${rows.length === 1 ? "" : "s"}${filter && filter !== "typography" ? ` matching \`${filter}\`` : ""} · ${activeTheme}`;
4850
5236
  if (rows.length === 0) return /* @__PURE__ */ jsx("div", {
4851
5237
  ...blockWrapperAttrs(cssVarPrefix, activeAxes),
@@ -4877,7 +5263,28 @@ function TypographyScale({ filter, sample = "The quick brown fox jumps over the
4877
5263
  }, row.path))]
4878
5264
  });
4879
5265
  }
5266
+ function TypographyScale({ filter, sample = "The quick brown fox jumps over the lazy dog.", caption, sortBy = "path", sortDir = "asc" }) {
5267
+ const { resolved, activeTheme, activeAxes, cssVarPrefix } = useProject();
5268
+ return /* @__PURE__ */ jsx(TypographyScaleView, {
5269
+ rows: useMemo(() => deriveTypographyRows(resolved, {
5270
+ filter,
5271
+ sortBy,
5272
+ sortDir
5273
+ }), [
5274
+ resolved,
5275
+ filter,
5276
+ sortBy,
5277
+ sortDir
5278
+ ]),
5279
+ activeTheme,
5280
+ cssVarPrefix,
5281
+ activeAxes,
5282
+ sample,
5283
+ filter,
5284
+ caption
5285
+ });
5286
+ }
4880
5287
  //#endregion
4881
- 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 };
5288
+ 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, onChannel, registerChannel, registerTokenSource, useActiveAxes, useActiveTheme, useChannelGlobals, useColorFormat, useOptionalSwatchbookData, useSwatchbookData, useTokenSnapshot };
4882
5289
 
4883
5290
  //# sourceMappingURL=index.mjs.map