@unpunnyfuns/swatchbook-integrations 0.50.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/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # swatchbook-integrations
2
+
3
+ Preview-side adapters that let your Storybook stories use [Tailwind v4](./src/tailwind.ts) or a [CSS-in-JS library](./src/css-in-js.ts) against [swatchbook](https://github.com/unpunnyfuns/swatchbook)'s tokens.
4
+
5
+ Not a replacement for your production build. Integrations are preview-only — they let `bg-sb-surface-default` or `theme.color.accent.bg` resolve correctly inside Storybook; for production artifacts, run [Terrazzo](https://terrazzo.app/)'s CLI against the same DTCG sources.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install -D @unpunnyfuns/swatchbook-integrations
11
+ ```
12
+
13
+ The `/tailwind` subpath wants Tailwind's Vite plugin too:
14
+
15
+ ```sh
16
+ npm install -D tailwindcss @tailwindcss/vite
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ts title=".storybook/main.ts"
22
+ import tailwindIntegration from '@unpunnyfuns/swatchbook-integrations/tailwind';
23
+ import cssInJsIntegration from '@unpunnyfuns/swatchbook-integrations/css-in-js';
24
+
25
+ export default defineMain({
26
+ addons: [
27
+ {
28
+ name: '@unpunnyfuns/swatchbook-addon',
29
+ options: {
30
+ configPath: '../swatchbook.config.ts',
31
+ integrations: [tailwindIntegration(), cssInJsIntegration()],
32
+ },
33
+ },
34
+ ],
35
+ });
36
+ ```
37
+
38
+ Per-integration wiring details live in the [Integrations guide](https://unpunnyfuns.github.io/swatchbook/guides/integrations/) — Tailwind's Vite plugin setup, the CSS-in-JS `virtual:swatchbook/theme` import, role-map overrides, and how to write your own integration against the `SwatchbookIntegration` contract.
39
+
40
+ ## Boundaries
41
+
42
+ - ✅ Preview-side use inside Storybook. Stories that style with utility classes or theme accessors pick up the swatchbook toolbar's axis flips via CSS cascade.
43
+ - ❌ No MUI / Vuetify / Bootstrap SCSS factories. Those need resolved values per named theme — run Terrazzo's CLI with `@terrazzo/plugin-js` for that case.
44
+
45
+ ## Documentation
46
+
47
+ [unpunnyfuns.github.io/swatchbook](https://unpunnyfuns.github.io/swatchbook/) — concepts, guides, and full API reference.
@@ -0,0 +1,62 @@
1
+ import { SwatchbookIntegration } from "@unpunnyfuns/swatchbook-core";
2
+
3
+ //#region src/css-in-js.d.ts
4
+ interface CssInJsIntegrationOptions {
5
+ /**
6
+ * Virtual module ID the addon serves. Default:
7
+ * `'virtual:swatchbook/theme'`. Consumers import this string from
8
+ * their preview (or stories) to receive a typed accessor object whose
9
+ * leaves are `var(--<cssVarPrefix>-*)` references.
10
+ */
11
+ virtualId?: string;
12
+ }
13
+ /**
14
+ * Preview-only CSS-in-JS integration for swatchbook's Storybook addon.
15
+ * Contributes a virtual JS module that exports a nested accessor
16
+ * mirroring the project's token tree — every leaf is a `var(...)`
17
+ * reference carrying the project's `cssVarPrefix`. Lets stories import
18
+ * `{ theme }` the same way their production components do, but backed
19
+ * by swatchbook's runtime-switchable cascade rather than a concrete
20
+ * theme object. Not a replacement for the consumer's production
21
+ * CSS-in-JS emit step.
22
+ *
23
+ * Swatchbook's toolbar still does the flipping via compound `data-*`
24
+ * attributes; the accessor's values don't change across tuples because
25
+ * the cascade resolves the var.
26
+ *
27
+ * ```ts
28
+ * // .storybook/main.ts
29
+ * import cssInJsIntegration from '@unpunnyfuns/swatchbook-integrations/css-in-js';
30
+ *
31
+ * export default defineMain({
32
+ * addons: [
33
+ * {
34
+ * name: '@unpunnyfuns/swatchbook-addon',
35
+ * options: {
36
+ * configPath: '../swatchbook.config.ts',
37
+ * integrations: [cssInJsIntegration()],
38
+ * },
39
+ * },
40
+ * ],
41
+ * });
42
+ *
43
+ * // Any story / component
44
+ * import { theme, color, space } from 'virtual:swatchbook/theme';
45
+ *
46
+ * // styled-components / emotion
47
+ * <ThemeProvider theme={theme}>...</ThemeProvider>
48
+ *
49
+ * // direct ref
50
+ * const bg = color.surface.default; // -> "var(--sb-color-surface-default)"
51
+ * ```
52
+ *
53
+ * The theme object is stable across tuples — consumers wire it into a
54
+ * provider *once*; runtime switching happens entirely through CSS cascade
55
+ * when swatchbook's toolbar toggles `data-<prefix>-<axis>` on `<html>`.
56
+ * Consumers who need resolved-value permutations (MUI `createTheme`, Vuetify
57
+ * factories) are not covered — that's a different emission story.
58
+ */
59
+ declare function cssInJsIntegration(options?: CssInJsIntegrationOptions): SwatchbookIntegration;
60
+ //#endregion
61
+ export { CssInJsIntegrationOptions, cssInJsIntegration as default };
62
+ //# sourceMappingURL=css-in-js.d.mts.map
@@ -0,0 +1,132 @@
1
+ //#region src/css-in-js.ts
2
+ /**
3
+ * Preview-only CSS-in-JS integration for swatchbook's Storybook addon.
4
+ * Contributes a virtual JS module that exports a nested accessor
5
+ * mirroring the project's token tree — every leaf is a `var(...)`
6
+ * reference carrying the project's `cssVarPrefix`. Lets stories import
7
+ * `{ theme }` the same way their production components do, but backed
8
+ * by swatchbook's runtime-switchable cascade rather than a concrete
9
+ * theme object. Not a replacement for the consumer's production
10
+ * CSS-in-JS emit step.
11
+ *
12
+ * Swatchbook's toolbar still does the flipping via compound `data-*`
13
+ * attributes; the accessor's values don't change across tuples because
14
+ * the cascade resolves the var.
15
+ *
16
+ * ```ts
17
+ * // .storybook/main.ts
18
+ * import cssInJsIntegration from '@unpunnyfuns/swatchbook-integrations/css-in-js';
19
+ *
20
+ * export default defineMain({
21
+ * addons: [
22
+ * {
23
+ * name: '@unpunnyfuns/swatchbook-addon',
24
+ * options: {
25
+ * configPath: '../swatchbook.config.ts',
26
+ * integrations: [cssInJsIntegration()],
27
+ * },
28
+ * },
29
+ * ],
30
+ * });
31
+ *
32
+ * // Any story / component
33
+ * import { theme, color, space } from 'virtual:swatchbook/theme';
34
+ *
35
+ * // styled-components / emotion
36
+ * <ThemeProvider theme={theme}>...</ThemeProvider>
37
+ *
38
+ * // direct ref
39
+ * const bg = color.surface.default; // -> "var(--sb-color-surface-default)"
40
+ * ```
41
+ *
42
+ * The theme object is stable across tuples — consumers wire it into a
43
+ * provider *once*; runtime switching happens entirely through CSS cascade
44
+ * when swatchbook's toolbar toggles `data-<prefix>-<axis>` on `<html>`.
45
+ * Consumers who need resolved-value permutations (MUI `createTheme`, Vuetify
46
+ * factories) are not covered — that's a different emission story.
47
+ */
48
+ function cssInJsIntegration(options = {}) {
49
+ return {
50
+ name: "css-in-js",
51
+ virtualModule: {
52
+ virtualId: options.virtualId ?? "virtual:swatchbook/theme",
53
+ render: renderTheme
54
+ }
55
+ };
56
+ }
57
+ function renderTheme(project) {
58
+ const prefix = project.config.cssVarPrefix ?? "";
59
+ const varPrefix = prefix ? `${prefix}-` : "";
60
+ const tree = buildTree(collectPaths(project), (path) => `var(--${varPrefix}${path.replaceAll(".", "-")})`);
61
+ const groupNames = Object.keys(tree).toSorted();
62
+ const groupExports = groupNames.map((name) => `export const ${safeIdent(name)} = ${renderNode(tree[name], 1)};`);
63
+ const aggregate = `export const theme = { ${groupNames.map(safeIdent).join(", ")} };`;
64
+ return [
65
+ "/* Synthesized by @unpunnyfuns/swatchbook-integrations/css-in-js for preview.",
66
+ " * Served via `virtual:swatchbook/theme` — rebuilt on token changes. */",
67
+ "",
68
+ ...groupExports,
69
+ "",
70
+ aggregate,
71
+ ""
72
+ ].join("\n");
73
+ }
74
+ function collectPaths(project) {
75
+ const all = /* @__PURE__ */ new Set();
76
+ for (const theme of project.permutations) {
77
+ const tokens = project.permutationsResolved[theme.name] ?? {};
78
+ for (const path of Object.keys(tokens)) all.add(path);
79
+ }
80
+ return [...all].toSorted();
81
+ }
82
+ /**
83
+ * Build a nested object tree from a sorted path list. Leaves hold the
84
+ * emitted value from `leafFor(path)`. Leaf/branch collisions (a shorter
85
+ * path emits a leaf while a longer path wants to nest under the same
86
+ * key) are resolved by keeping the leaf — realistic DTCG trees don't
87
+ * hit this, but the explicit behaviour beats silent UB.
88
+ */
89
+ function buildTree(sortedPaths, leafFor) {
90
+ const root = {};
91
+ for (const path of sortedPaths) {
92
+ const segments = path.split(".");
93
+ let node = root;
94
+ for (let i = 0; i < segments.length - 1; i++) {
95
+ const seg = segments[i];
96
+ const existing = node[seg];
97
+ if (typeof existing === "string") break;
98
+ if (existing === void 0) {
99
+ const next = {};
100
+ node[seg] = next;
101
+ node = next;
102
+ } else node = existing;
103
+ }
104
+ const leafKey = segments.at(-1);
105
+ if (node[leafKey] === void 0) node[leafKey] = leafFor(path);
106
+ }
107
+ return root;
108
+ }
109
+ function renderNode(node, depth) {
110
+ if (typeof node === "string") return JSON.stringify(node);
111
+ const indent = " ".repeat(depth);
112
+ const closing = " ".repeat(depth - 1);
113
+ return `{\n${Object.keys(node).toSorted().map((key) => `${indent}${safeKey(key)}: ${renderNode(node[key], depth + 1)}`).join(",\n")},\n${closing}}`;
114
+ }
115
+ /**
116
+ * Bare identifier (or canonical non-leading-zero integer literal) if
117
+ * safe; quoted string otherwise. Leading-zero numerics like `"050"`
118
+ * stay quoted because bare `050` is an octal under strict mode.
119
+ */
120
+ function safeKey(key) {
121
+ if (/^[A-Za-z_$][\w$]*$/.test(key)) return key;
122
+ if (/^(0|[1-9]\d*)$/.test(key)) return key;
123
+ return JSON.stringify(key);
124
+ }
125
+ /** Top-level exports must be valid JS identifiers. */
126
+ function safeIdent(key) {
127
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? key : `_${key.replaceAll(/[^\w$]/g, "_")}`;
128
+ }
129
+ //#endregion
130
+ export { cssInJsIntegration as default };
131
+
132
+ //# sourceMappingURL=css-in-js.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"css-in-js.mjs","names":[],"sources":["../src/css-in-js.ts"],"sourcesContent":["import type { Project, SwatchbookIntegration } from '@unpunnyfuns/swatchbook-core';\n\nexport interface CssInJsIntegrationOptions {\n /**\n * Virtual module ID the addon serves. Default:\n * `'virtual:swatchbook/theme'`. Consumers import this string from\n * their preview (or stories) to receive a typed accessor object whose\n * leaves are `var(--<cssVarPrefix>-*)` references.\n */\n virtualId?: string;\n}\n\n/**\n * Preview-only CSS-in-JS integration for swatchbook's Storybook addon.\n * Contributes a virtual JS module that exports a nested accessor\n * mirroring the project's token tree — every leaf is a `var(...)`\n * reference carrying the project's `cssVarPrefix`. Lets stories import\n * `{ theme }` the same way their production components do, but backed\n * by swatchbook's runtime-switchable cascade rather than a concrete\n * theme object. Not a replacement for the consumer's production\n * CSS-in-JS emit step.\n *\n * Swatchbook's toolbar still does the flipping via compound `data-*`\n * attributes; the accessor's values don't change across tuples because\n * the cascade resolves the var.\n *\n * ```ts\n * // .storybook/main.ts\n * import cssInJsIntegration from '@unpunnyfuns/swatchbook-integrations/css-in-js';\n *\n * export default defineMain({\n * addons: [\n * {\n * name: '@unpunnyfuns/swatchbook-addon',\n * options: {\n * configPath: '../swatchbook.config.ts',\n * integrations: [cssInJsIntegration()],\n * },\n * },\n * ],\n * });\n *\n * // Any story / component\n * import { theme, color, space } from 'virtual:swatchbook/theme';\n *\n * // styled-components / emotion\n * <ThemeProvider theme={theme}>...</ThemeProvider>\n *\n * // direct ref\n * const bg = color.surface.default; // -> \"var(--sb-color-surface-default)\"\n * ```\n *\n * The theme object is stable across tuples — consumers wire it into a\n * provider *once*; runtime switching happens entirely through CSS cascade\n * when swatchbook's toolbar toggles `data-<prefix>-<axis>` on `<html>`.\n * Consumers who need resolved-value permutations (MUI `createTheme`, Vuetify\n * factories) are not covered — that's a different emission story.\n */\nexport default function cssInJsIntegration(\n options: CssInJsIntegrationOptions = {},\n): SwatchbookIntegration {\n const virtualId = options.virtualId ?? 'virtual:swatchbook/theme';\n return {\n name: 'css-in-js',\n virtualModule: {\n virtualId,\n render: renderTheme,\n },\n };\n}\n\nfunction renderTheme(project: Project): string {\n const prefix = project.config.cssVarPrefix ?? '';\n const varPrefix = prefix ? `${prefix}-` : '';\n const paths = collectPaths(project);\n const tree = buildTree(paths, (path) => `var(--${varPrefix}${path.replaceAll('.', '-')})`);\n\n const groupNames = Object.keys(tree).toSorted();\n const groupExports = groupNames.map(\n (name) => `export const ${safeIdent(name)} = ${renderNode(tree[name]!, 1)};`,\n );\n const aggregate = `export const theme = { ${groupNames.map(safeIdent).join(', ')} };`;\n\n return [\n '/* Synthesized by @unpunnyfuns/swatchbook-integrations/css-in-js for preview.',\n ' * Served via `virtual:swatchbook/theme` — rebuilt on token changes. */',\n '',\n ...groupExports,\n '',\n aggregate,\n '',\n ].join('\\n');\n}\n\nfunction collectPaths(project: Project): string[] {\n const all = new Set<string>();\n for (const theme of project.permutations) {\n const tokens = project.permutationsResolved[theme.name] ?? {};\n for (const path of Object.keys(tokens)) all.add(path);\n }\n return [...all].toSorted();\n}\n\ntype TreeNode = { [key: string]: TreeNode | string };\n\n/**\n * Build a nested object tree from a sorted path list. Leaves hold the\n * emitted value from `leafFor(path)`. Leaf/branch collisions (a shorter\n * path emits a leaf while a longer path wants to nest under the same\n * key) are resolved by keeping the leaf — realistic DTCG trees don't\n * hit this, but the explicit behaviour beats silent UB.\n */\nfunction buildTree(sortedPaths: readonly string[], leafFor: (path: string) => string): TreeNode {\n const root: TreeNode = {};\n for (const path of sortedPaths) {\n const segments = path.split('.');\n let node = root;\n for (let i = 0; i < segments.length - 1; i++) {\n const seg = segments[i]!;\n const existing = node[seg];\n if (typeof existing === 'string') break;\n if (existing === undefined) {\n const next: TreeNode = {};\n node[seg] = next;\n node = next;\n } else {\n node = existing;\n }\n }\n const leafKey = segments.at(-1)!;\n if (node[leafKey] === undefined) node[leafKey] = leafFor(path);\n }\n return root;\n}\n\nfunction renderNode(node: TreeNode | string, depth: number): string {\n if (typeof node === 'string') return JSON.stringify(node);\n const indent = ' '.repeat(depth);\n const closing = ' '.repeat(depth - 1);\n const entries = Object.keys(node)\n .toSorted()\n .map((key) => `${indent}${safeKey(key)}: ${renderNode(node[key]!, depth + 1)}`);\n return `{\\n${entries.join(',\\n')},\\n${closing}}`;\n}\n\n/**\n * Bare identifier (or canonical non-leading-zero integer literal) if\n * safe; quoted string otherwise. Leading-zero numerics like `\"050\"`\n * stay quoted because bare `050` is an octal under strict mode.\n */\nfunction safeKey(key: string): string {\n if (/^[A-Za-z_$][\\w$]*$/.test(key)) return key;\n if (/^(0|[1-9]\\d*)$/.test(key)) return key;\n return JSON.stringify(key);\n}\n\n/** Top-level exports must be valid JS identifiers. */\nfunction safeIdent(key: string): string {\n return /^[A-Za-z_$][\\w$]*$/.test(key) ? key : `_${key.replaceAll(/[^\\w$]/g, '_')}`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0DA,SAAwB,mBACtB,UAAqC,EAAE,EAChB;AAEvB,QAAO;EACL,MAAM;EACN,eAAe;GACb,WAJc,QAAQ,aAAa;GAKnC,QAAQ;GACT;EACF;;AAGH,SAAS,YAAY,SAA0B;CAC7C,MAAM,SAAS,QAAQ,OAAO,gBAAgB;CAC9C,MAAM,YAAY,SAAS,GAAG,OAAO,KAAK;CAE1C,MAAM,OAAO,UADC,aAAa,QAAQ,GACJ,SAAS,SAAS,YAAY,KAAK,WAAW,KAAK,IAAI,CAAC,GAAG;CAE1F,MAAM,aAAa,OAAO,KAAK,KAAK,CAAC,UAAU;CAC/C,MAAM,eAAe,WAAW,KAC7B,SAAS,gBAAgB,UAAU,KAAK,CAAC,KAAK,WAAW,KAAK,OAAQ,EAAE,CAAC,GAC3E;CACD,MAAM,YAAY,0BAA0B,WAAW,IAAI,UAAU,CAAC,KAAK,KAAK,CAAC;AAEjF,QAAO;EACL;EACA;EACA;EACA,GAAG;EACH;EACA;EACA;EACD,CAAC,KAAK,KAAK;;AAGd,SAAS,aAAa,SAA4B;CAChD,MAAM,sBAAM,IAAI,KAAa;AAC7B,MAAK,MAAM,SAAS,QAAQ,cAAc;EACxC,MAAM,SAAS,QAAQ,qBAAqB,MAAM,SAAS,EAAE;AAC7D,OAAK,MAAM,QAAQ,OAAO,KAAK,OAAO,CAAE,KAAI,IAAI,KAAK;;AAEvD,QAAO,CAAC,GAAG,IAAI,CAAC,UAAU;;;;;;;;;AAY5B,SAAS,UAAU,aAAgC,SAA6C;CAC9F,MAAM,OAAiB,EAAE;AACzB,MAAK,MAAM,QAAQ,aAAa;EAC9B,MAAM,WAAW,KAAK,MAAM,IAAI;EAChC,IAAI,OAAO;AACX,OAAK,IAAI,IAAI,GAAG,IAAI,SAAS,SAAS,GAAG,KAAK;GAC5C,MAAM,MAAM,SAAS;GACrB,MAAM,WAAW,KAAK;AACtB,OAAI,OAAO,aAAa,SAAU;AAClC,OAAI,aAAa,KAAA,GAAW;IAC1B,MAAM,OAAiB,EAAE;AACzB,SAAK,OAAO;AACZ,WAAO;SAEP,QAAO;;EAGX,MAAM,UAAU,SAAS,GAAG,GAAG;AAC/B,MAAI,KAAK,aAAa,KAAA,EAAW,MAAK,WAAW,QAAQ,KAAK;;AAEhE,QAAO;;AAGT,SAAS,WAAW,MAAyB,OAAuB;AAClE,KAAI,OAAO,SAAS,SAAU,QAAO,KAAK,UAAU,KAAK;CACzD,MAAM,SAAS,KAAK,OAAO,MAAM;CACjC,MAAM,UAAU,KAAK,OAAO,QAAQ,EAAE;AAItC,QAAO,MAHS,OAAO,KAAK,KAAK,CAC9B,UAAU,CACV,KAAK,QAAQ,GAAG,SAAS,QAAQ,IAAI,CAAC,IAAI,WAAW,KAAK,MAAO,QAAQ,EAAE,GAAG,CAC5D,KAAK,MAAM,CAAC,KAAK,QAAQ;;;;;;;AAQhD,SAAS,QAAQ,KAAqB;AACpC,KAAI,qBAAqB,KAAK,IAAI,CAAE,QAAO;AAC3C,KAAI,iBAAiB,KAAK,IAAI,CAAE,QAAO;AACvC,QAAO,KAAK,UAAU,IAAI;;;AAI5B,SAAS,UAAU,KAAqB;AACtC,QAAO,qBAAqB,KAAK,IAAI,GAAG,MAAM,IAAI,IAAI,WAAW,WAAW,IAAI"}
@@ -0,0 +1,65 @@
1
+ import { SwatchbookIntegration } from "@unpunnyfuns/swatchbook-core";
2
+
3
+ //#region src/tailwind.d.ts
4
+ interface TailwindIntegrationOptions {
5
+ /**
6
+ * Virtual module ID the addon serves. Storybook users import this
7
+ * string from their preview to receive the rendered `@theme` block.
8
+ * Default: `'virtual:swatchbook/tailwind.css'`.
9
+ */
10
+ virtualId?: string;
11
+ /**
12
+ * Override the auto-derived role map. Keys are Tailwind scale names
13
+ * (`color`, `spacing`, `radius`, `shadow`, `font`); values are ordered
14
+ * `[tailwindEntryName, dtcgPath]` pairs. Supplied map **replaces** the
15
+ * derived one — pass your own to pin exact entries, restrict the
16
+ * universe of emitted utilities, or name scales the derivation doesn't
17
+ * cover.
18
+ *
19
+ * Omit to let the integration derive a role map from the project at
20
+ * render time: every `color` token lands under the `color` scale,
21
+ * every `dimension` token under `spacing` / `radius` based on its
22
+ * path prefix, every `shadow` under `shadow`, every `fontFamily` under
23
+ * `font`. Works for any DTCG project without a configuration step.
24
+ */
25
+ roles?: Readonly<Record<string, readonly (readonly [string, string])[]>>;
26
+ }
27
+ /**
28
+ * Preview-only Tailwind v4 integration for the swatchbook Storybook
29
+ * addon. Contributes a virtual CSS module whose `@theme` block aliases
30
+ * Tailwind utility scales to the project's DTCG tokens via
31
+ * `var(--<cssVarPrefix>-*)` references, so stories authored with
32
+ * utility classes render correctly inside Storybook and react to the
33
+ * toolbar's axis flips. This is not a replacement for the consumer's
34
+ * production Tailwind build.
35
+ *
36
+ * Every entry is nested under the project's own `cssVarPrefix` so it
37
+ * never collides with Tailwind's shipped scales: with
38
+ * `cssVarPrefix: 'sb'` you get `--color-sb-surface-default`,
39
+ * `--spacing-sb-md`, etc., generating utilities `bg-sb-surface-default`
40
+ * and `p-sb-md` that coexist with `bg-red-500` / `p-4` / `max-w-md`.
41
+ *
42
+ * ```ts
43
+ * // .storybook/main.ts
44
+ * import tailwindIntegration from '@unpunnyfuns/swatchbook-integrations/tailwind';
45
+ *
46
+ * export default defineMain({
47
+ * addons: [
48
+ * {
49
+ * name: '@unpunnyfuns/swatchbook-addon',
50
+ * options: {
51
+ * configPath: '../swatchbook.config.ts',
52
+ * integrations: [tailwindIntegration()],
53
+ * },
54
+ * },
55
+ * ],
56
+ * });
57
+ *
58
+ * // .storybook/preview.tsx
59
+ * import 'virtual:swatchbook/tailwind.css';
60
+ * ```
61
+ */
62
+ declare function tailwindIntegration(options?: TailwindIntegrationOptions): SwatchbookIntegration;
63
+ //#endregion
64
+ export { TailwindIntegrationOptions, tailwindIntegration as default };
65
+ //# sourceMappingURL=tailwind.d.mts.map
@@ -0,0 +1,176 @@
1
+ //#region src/tailwind.ts
2
+ /**
3
+ * Preview-only Tailwind v4 integration for the swatchbook Storybook
4
+ * addon. Contributes a virtual CSS module whose `@theme` block aliases
5
+ * Tailwind utility scales to the project's DTCG tokens via
6
+ * `var(--<cssVarPrefix>-*)` references, so stories authored with
7
+ * utility classes render correctly inside Storybook and react to the
8
+ * toolbar's axis flips. This is not a replacement for the consumer's
9
+ * production Tailwind build.
10
+ *
11
+ * Every entry is nested under the project's own `cssVarPrefix` so it
12
+ * never collides with Tailwind's shipped scales: with
13
+ * `cssVarPrefix: 'sb'` you get `--color-sb-surface-default`,
14
+ * `--spacing-sb-md`, etc., generating utilities `bg-sb-surface-default`
15
+ * and `p-sb-md` that coexist with `bg-red-500` / `p-4` / `max-w-md`.
16
+ *
17
+ * ```ts
18
+ * // .storybook/main.ts
19
+ * import tailwindIntegration from '@unpunnyfuns/swatchbook-integrations/tailwind';
20
+ *
21
+ * export default defineMain({
22
+ * addons: [
23
+ * {
24
+ * name: '@unpunnyfuns/swatchbook-addon',
25
+ * options: {
26
+ * configPath: '../swatchbook.config.ts',
27
+ * integrations: [tailwindIntegration()],
28
+ * },
29
+ * },
30
+ * ],
31
+ * });
32
+ *
33
+ * // .storybook/preview.tsx
34
+ * import 'virtual:swatchbook/tailwind.css';
35
+ * ```
36
+ */
37
+ function tailwindIntegration(options = {}) {
38
+ const virtualId = options.virtualId ?? "virtual:swatchbook/tailwind.css";
39
+ const userRoles = options.roles;
40
+ return {
41
+ name: "tailwind",
42
+ virtualModule: {
43
+ virtualId,
44
+ render: (project) => renderTailwindTheme(project, userRoles ?? deriveRoles(project)),
45
+ autoInject: true
46
+ }
47
+ };
48
+ }
49
+ function renderTailwindTheme(project, roles) {
50
+ const prefix = project.config.cssVarPrefix ?? "";
51
+ const sourcePrefix = prefix ? `${prefix}-` : "";
52
+ const scopePrefix = prefix ? `${prefix}-` : "";
53
+ const entries = [];
54
+ for (const [scale, names] of Object.entries(roles)) {
55
+ if (names.length === 0) continue;
56
+ entries.push(` /* ${scale} */`);
57
+ for (const [themeKey, sourcePath] of names) {
58
+ const sourceVar = `--${sourcePrefix}${sourcePath.replaceAll(".", "-")}`;
59
+ const themeVar = `--${scale}-${scopePrefix}${themeKey}`;
60
+ entries.push(` ${themeVar}: var(${sourceVar});`);
61
+ }
62
+ entries.push("");
63
+ }
64
+ while (entries.length > 0 && entries.at(-1) === "") entries.pop();
65
+ return [
66
+ "/* Synthesized by @unpunnyfuns/swatchbook-integrations/tailwind for preview.",
67
+ " * Served via `virtual:swatchbook/tailwind.css` — rebuilt on token changes. */",
68
+ "@import 'tailwindcss';",
69
+ "",
70
+ "@theme {",
71
+ ...entries,
72
+ "}",
73
+ ""
74
+ ].join("\n");
75
+ }
76
+ /**
77
+ * Walk the project's default-theme token graph and classify each entry
78
+ * into a Tailwind scale based on its `$type` + path. Returns a role map
79
+ * shaped the same as a user-supplied `roles` option.
80
+ *
81
+ * Scale assignment:
82
+ * - `$type: 'color'` → `color`, role = path minus `color.` prefix
83
+ * - `$type: 'dimension'` → `spacing` (default), `radius` (if path starts with
84
+ * `radius.`, `borderRadius.`, or `border-radius.`), or nothing (skipped)
85
+ * for dimensions that look like font sizes (`font.size.*`, `text.*`), since
86
+ * Tailwind's `--text-*` scale needs size + line-height pairs this preview
87
+ * integration doesn't synthesize
88
+ * - `$type: 'shadow'` → `shadow`, role = path minus `shadow.` prefix
89
+ * - `$type: 'fontFamily'` → `font`, role = path minus `font.` / `font.family.` / `fontFamily.` prefix
90
+ *
91
+ * Other `$type`s are skipped — they don't have a natural single-value
92
+ * Tailwind utility and would produce broken output.
93
+ */
94
+ function deriveRoles(project) {
95
+ const scales = {
96
+ color: [],
97
+ spacing: [],
98
+ radius: [],
99
+ shadow: [],
100
+ font: []
101
+ };
102
+ for (const [path, token] of Object.entries(project.graph)) {
103
+ const classification = classify(path, token.$type);
104
+ if (!classification) continue;
105
+ const { scale, role } = classification;
106
+ if (!role) continue;
107
+ scales[scale]?.push([role, path]);
108
+ }
109
+ const out = {};
110
+ for (const [scale, entries] of Object.entries(scales)) {
111
+ if (entries.length === 0) continue;
112
+ entries.sort(([a], [b]) => a.localeCompare(b, "en"));
113
+ out[scale] = entries;
114
+ }
115
+ return out;
116
+ }
117
+ const SPACING_ROOTS = ["space", "spacing"];
118
+ const RADIUS_ROOTS = [
119
+ "radius",
120
+ "borderRadius",
121
+ "border-radius"
122
+ ];
123
+ const FONT_PREFIXES = [
124
+ "font.family.",
125
+ "fontFamily.",
126
+ "font."
127
+ ];
128
+ function classify(path, type) {
129
+ switch (type) {
130
+ case "color": return {
131
+ scale: "color",
132
+ role: stripPrefix(path, "color.")
133
+ };
134
+ case "shadow": return {
135
+ scale: "shadow",
136
+ role: stripPrefix(path, "shadow.")
137
+ };
138
+ case "fontFamily":
139
+ for (const prefix of FONT_PREFIXES) if (path.startsWith(prefix)) return {
140
+ scale: "font",
141
+ role: pathToRole(path.slice(prefix.length))
142
+ };
143
+ return {
144
+ scale: "font",
145
+ role: pathToRole(path)
146
+ };
147
+ case "dimension": {
148
+ const head = path.split(".", 1)[0] ?? "";
149
+ if (RADIUS_ROOTS.includes(head)) return {
150
+ scale: "radius",
151
+ role: pathToRole(path.slice(head.length + 1))
152
+ };
153
+ if (SPACING_ROOTS.includes(head)) return {
154
+ scale: "spacing",
155
+ role: pathToRole(path.slice(head.length + 1))
156
+ };
157
+ if (head === "font" && /size|text/i.test(path)) return null;
158
+ if (head === "text") return null;
159
+ return {
160
+ scale: "spacing",
161
+ role: pathToRole(path)
162
+ };
163
+ }
164
+ default: return null;
165
+ }
166
+ }
167
+ function stripPrefix(path, prefix) {
168
+ return pathToRole(path.startsWith(prefix) ? path.slice(prefix.length) : path);
169
+ }
170
+ function pathToRole(remainder) {
171
+ return remainder.replaceAll(".", "-");
172
+ }
173
+ //#endregion
174
+ export { tailwindIntegration as default };
175
+
176
+ //# sourceMappingURL=tailwind.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tailwind.mjs","names":[],"sources":["../src/tailwind.ts"],"sourcesContent":["import type { Project, SwatchbookIntegration } from '@unpunnyfuns/swatchbook-core';\n\nexport interface TailwindIntegrationOptions {\n /**\n * Virtual module ID the addon serves. Storybook users import this\n * string from their preview to receive the rendered `@theme` block.\n * Default: `'virtual:swatchbook/tailwind.css'`.\n */\n virtualId?: string;\n /**\n * Override the auto-derived role map. Keys are Tailwind scale names\n * (`color`, `spacing`, `radius`, `shadow`, `font`); values are ordered\n * `[tailwindEntryName, dtcgPath]` pairs. Supplied map **replaces** the\n * derived one — pass your own to pin exact entries, restrict the\n * universe of emitted utilities, or name scales the derivation doesn't\n * cover.\n *\n * Omit to let the integration derive a role map from the project at\n * render time: every `color` token lands under the `color` scale,\n * every `dimension` token under `spacing` / `radius` based on its\n * path prefix, every `shadow` under `shadow`, every `fontFamily` under\n * `font`. Works for any DTCG project without a configuration step.\n */\n roles?: Readonly<Record<string, readonly (readonly [string, string])[]>>;\n}\n\ntype RoleMap = Readonly<Record<string, readonly (readonly [string, string])[]>>;\n\n/**\n * Preview-only Tailwind v4 integration for the swatchbook Storybook\n * addon. Contributes a virtual CSS module whose `@theme` block aliases\n * Tailwind utility scales to the project's DTCG tokens via\n * `var(--<cssVarPrefix>-*)` references, so stories authored with\n * utility classes render correctly inside Storybook and react to the\n * toolbar's axis flips. This is not a replacement for the consumer's\n * production Tailwind build.\n *\n * Every entry is nested under the project's own `cssVarPrefix` so it\n * never collides with Tailwind's shipped scales: with\n * `cssVarPrefix: 'sb'` you get `--color-sb-surface-default`,\n * `--spacing-sb-md`, etc., generating utilities `bg-sb-surface-default`\n * and `p-sb-md` that coexist with `bg-red-500` / `p-4` / `max-w-md`.\n *\n * ```ts\n * // .storybook/main.ts\n * import tailwindIntegration from '@unpunnyfuns/swatchbook-integrations/tailwind';\n *\n * export default defineMain({\n * addons: [\n * {\n * name: '@unpunnyfuns/swatchbook-addon',\n * options: {\n * configPath: '../swatchbook.config.ts',\n * integrations: [tailwindIntegration()],\n * },\n * },\n * ],\n * });\n *\n * // .storybook/preview.tsx\n * import 'virtual:swatchbook/tailwind.css';\n * ```\n */\nexport default function tailwindIntegration(\n options: TailwindIntegrationOptions = {},\n): SwatchbookIntegration {\n const virtualId = options.virtualId ?? 'virtual:swatchbook/tailwind.css';\n const userRoles = options.roles;\n\n return {\n name: 'tailwind',\n virtualModule: {\n virtualId,\n render: (project) => renderTailwindTheme(project, userRoles ?? deriveRoles(project)),\n // Tailwind's `@theme` block is a global stylesheet — exactly the\n // kind of payload the addon should auto-inject into the preview,\n // so consumers don't hand-write a second `import` line after\n // plugging the integration in.\n autoInject: true,\n },\n };\n}\n\nfunction renderTailwindTheme(project: Project, roles: RoleMap): string {\n const prefix = project.config.cssVarPrefix ?? '';\n const sourcePrefix = prefix ? `${prefix}-` : '';\n const scopePrefix = prefix ? `${prefix}-` : '';\n\n const entries: string[] = [];\n for (const [scale, names] of Object.entries(roles)) {\n if (names.length === 0) continue;\n entries.push(` /* ${scale} */`);\n for (const [themeKey, sourcePath] of names) {\n const sourceVar = `--${sourcePrefix}${sourcePath.replaceAll('.', '-')}`;\n const themeVar = `--${scale}-${scopePrefix}${themeKey}`;\n entries.push(` ${themeVar}: var(${sourceVar});`);\n }\n entries.push('');\n }\n while (entries.length > 0 && entries.at(-1) === '') entries.pop();\n\n return [\n '/* Synthesized by @unpunnyfuns/swatchbook-integrations/tailwind for preview.',\n ' * Served via `virtual:swatchbook/tailwind.css` — rebuilt on token changes. */',\n \"@import 'tailwindcss';\",\n '',\n '@theme {',\n ...entries,\n '}',\n '',\n ].join('\\n');\n}\n\n/**\n * Walk the project's default-theme token graph and classify each entry\n * into a Tailwind scale based on its `$type` + path. Returns a role map\n * shaped the same as a user-supplied `roles` option.\n *\n * Scale assignment:\n * - `$type: 'color'` → `color`, role = path minus `color.` prefix\n * - `$type: 'dimension'` → `spacing` (default), `radius` (if path starts with\n * `radius.`, `borderRadius.`, or `border-radius.`), or nothing (skipped)\n * for dimensions that look like font sizes (`font.size.*`, `text.*`), since\n * Tailwind's `--text-*` scale needs size + line-height pairs this preview\n * integration doesn't synthesize\n * - `$type: 'shadow'` → `shadow`, role = path minus `shadow.` prefix\n * - `$type: 'fontFamily'` → `font`, role = path minus `font.` / `font.family.` / `fontFamily.` prefix\n *\n * Other `$type`s are skipped — they don't have a natural single-value\n * Tailwind utility and would produce broken output.\n */\nfunction deriveRoles(project: Project): RoleMap {\n const scales: Record<string, [string, string][]> = {\n color: [],\n spacing: [],\n radius: [],\n shadow: [],\n font: [],\n };\n\n for (const [path, token] of Object.entries(project.graph)) {\n const classification = classify(path, token.$type);\n if (!classification) continue;\n const { scale, role } = classification;\n if (!role) continue;\n scales[scale]?.push([role, path]);\n }\n\n const out: Record<string, readonly (readonly [string, string])[]> = {};\n for (const [scale, entries] of Object.entries(scales)) {\n if (entries.length === 0) continue;\n entries.sort(([a], [b]) => a.localeCompare(b, 'en'));\n out[scale] = entries;\n }\n return out;\n}\n\nconst SPACING_ROOTS = ['space', 'spacing'] as const;\nconst RADIUS_ROOTS = ['radius', 'borderRadius', 'border-radius'] as const;\nconst FONT_PREFIXES = ['font.family.', 'fontFamily.', 'font.'] as const;\n\nfunction classify(path: string, type: string | undefined): { scale: string; role: string } | null {\n switch (type) {\n case 'color':\n return { scale: 'color', role: stripPrefix(path, 'color.') };\n case 'shadow':\n return { scale: 'shadow', role: stripPrefix(path, 'shadow.') };\n case 'fontFamily': {\n for (const prefix of FONT_PREFIXES) {\n if (path.startsWith(prefix)) {\n return { scale: 'font', role: pathToRole(path.slice(prefix.length)) };\n }\n }\n return { scale: 'font', role: pathToRole(path) };\n }\n case 'dimension': {\n const head = path.split('.', 1)[0] ?? '';\n if ((RADIUS_ROOTS as readonly string[]).includes(head)) {\n return { scale: 'radius', role: pathToRole(path.slice(head.length + 1)) };\n }\n if ((SPACING_ROOTS as readonly string[]).includes(head)) {\n return { scale: 'spacing', role: pathToRole(path.slice(head.length + 1)) };\n }\n // Font-size-ish dimensions are skipped — Tailwind's `--text-*` entries\n // expect a size+line-height pair that this integration doesn't build.\n if (head === 'font' && /size|text/i.test(path)) return null;\n if (head === 'text') return null;\n // Default-bucket any other dimension under spacing — safer than guessing.\n return { scale: 'spacing', role: pathToRole(path) };\n }\n default:\n return null;\n }\n}\n\nfunction stripPrefix(path: string, prefix: string): string {\n return pathToRole(path.startsWith(prefix) ? path.slice(prefix.length) : path);\n}\n\nfunction pathToRole(remainder: string): string {\n return remainder.replaceAll('.', '-');\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@unpunnyfuns/swatchbook-integrations",
3
+ "version": "0.50.0",
4
+ "description": "Display-side integrations for the swatchbook Storybook addon. Each subpath (./tailwind, …) exports a factory that plugs into the addon's options as a SwatchbookIntegration.",
5
+ "license": "MIT",
6
+ "author": "unpunnyfuns <unpunnyfuns@gmail.com>",
7
+ "homepage": "https://unpunnyfuns.github.io/swatchbook/",
8
+ "bugs": "https://github.com/unpunnyfuns/swatchbook/issues",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/unpunnyfuns/swatchbook.git",
12
+ "directory": "packages/integrations"
13
+ },
14
+ "keywords": [
15
+ "swatchbook",
16
+ "design-tokens",
17
+ "dtcg",
18
+ "storybook",
19
+ "tailwindcss"
20
+ ],
21
+ "type": "module",
22
+ "engines": {
23
+ "node": ">=24.14.0"
24
+ },
25
+ "exports": {
26
+ "./tailwind": {
27
+ "types": "./dist/tailwind.d.mts",
28
+ "import": "./dist/tailwind.mjs"
29
+ },
30
+ "./css-in-js": {
31
+ "types": "./dist/css-in-js.d.mts",
32
+ "import": "./dist/css-in-js.mjs"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "imports": {
37
+ "#/*": "./src/*"
38
+ },
39
+ "files": [
40
+ "dist"
41
+ ],
42
+ "sideEffects": false,
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "tsdown src/tailwind.ts src/css-in-js.ts --format esm --dts --clean --sourcemap",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest",
51
+ "lint": "oxlint --deny-warnings -c ../../.oxlintrc.json src test",
52
+ "format": "oxfmt -c ../../.oxfmtrc.json src test",
53
+ "format:check": "oxfmt --check -c ../../.oxfmtrc.json src test"
54
+ },
55
+ "dependencies": {
56
+ "@unpunnyfuns/swatchbook-core": "workspace:*"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^25.6.0",
60
+ "@unpunnyfuns/swatchbook-tokens": "workspace:*",
61
+ "tsdown": "^0.21.9",
62
+ "typescript": "^6.0.0",
63
+ "vitest": "^4.1.4"
64
+ }
65
+ }