@sproutsocial/seeds-theme 0.1.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.
Files changed (42) hide show
  1. package/README.md +259 -0
  2. package/dist/css-tokens.json +134 -0
  3. package/dist/legacy-dark.css +437 -0
  4. package/dist/legacy-light.css +437 -0
  5. package/dist/legacy.css +659 -0
  6. package/dist/schema.json +11798 -0
  7. package/dist/shadcn-dark.css +2 -0
  8. package/dist/shadcn-light.css +2 -0
  9. package/dist/shadcn.css +2 -0
  10. package/dist/shadcn.registry.json +77 -0
  11. package/dist/source/shadcn-dark.tokens.json +169 -0
  12. package/dist/source/shadcn-light.tokens.json +169 -0
  13. package/dist/source/theme-dark.tokens.json +1945 -0
  14. package/dist/source/theme-light.tokens.json +1945 -0
  15. package/dist/styled-components/index.cjs +1648 -0
  16. package/dist/styled-components/index.d.ts +3654 -0
  17. package/dist/styled-components/index.js +1641 -0
  18. package/dist/styled-components/index.ts +5285 -0
  19. package/dist/tailwind.css +40 -0
  20. package/dist/theme-dark.css +388 -0
  21. package/dist/theme-dark.d.ts +420 -0
  22. package/dist/theme-dark.js +401 -0
  23. package/dist/theme-dark.json +712 -0
  24. package/dist/theme-light.css +388 -0
  25. package/dist/theme-light.d.ts +420 -0
  26. package/dist/theme-light.js +400 -0
  27. package/dist/theme-light.json +712 -0
  28. package/dist/theme.css +611 -0
  29. package/mode-authoring.md +54 -0
  30. package/package.json +96 -0
  31. package/src/cli.js +42 -0
  32. package/src/compiler.d.ts +9 -0
  33. package/src/compiler.js +533 -0
  34. package/src/config.d.ts +39 -0
  35. package/src/config.js +280 -0
  36. package/src/contract.js +351 -0
  37. package/src/extension-schema.js +88 -0
  38. package/src/extension.d.ts +18 -0
  39. package/src/extension.js +81 -0
  40. package/src/index.d.ts +90 -0
  41. package/src/index.js +9 -0
  42. package/src/primitives.js +98 -0
@@ -0,0 +1,533 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import StyleDictionary from "style-dictionary";
4
+ import { primitiveRoots } from "./primitives.js";
5
+ import { formattedVariables } from "style-dictionary/utils";
6
+ import {
7
+ assertThemeSelectors,
8
+ selectorsForTheme,
9
+ themeModes,
10
+ } from "./contract.js";
11
+
12
+ const LEGACY_ABBREVIATIONS = {
13
+ background: "bg",
14
+ foreground: "fg",
15
+ };
16
+
17
+ const SHADCN_COLOR_SLOTS = [
18
+ "background",
19
+ "foreground",
20
+ "card",
21
+ "card-foreground",
22
+ "popover",
23
+ "popover-foreground",
24
+ "primary",
25
+ "primary-foreground",
26
+ "secondary",
27
+ "secondary-foreground",
28
+ "muted",
29
+ "muted-foreground",
30
+ "accent",
31
+ "accent-foreground",
32
+ "destructive",
33
+ "destructive-foreground",
34
+ "border",
35
+ "input",
36
+ "ring",
37
+ "chart-1",
38
+ "chart-2",
39
+ "chart-3",
40
+ "chart-4",
41
+ "chart-5",
42
+ "sidebar",
43
+ "sidebar-foreground",
44
+ "sidebar-primary",
45
+ "sidebar-primary-foreground",
46
+ "sidebar-accent",
47
+ "sidebar-accent-foreground",
48
+ "sidebar-border",
49
+ "sidebar-ring",
50
+ ];
51
+
52
+ const isObject = (value) =>
53
+ value !== null && typeof value === "object" && !Array.isArray(value);
54
+
55
+ const isToken = (value) => isObject(value) && "$value" in value;
56
+ const isPrimitiveToken = (token) => primitiveRoots.has(token.path[0]);
57
+ const isShadcnToken = (token) => token.path[0] === "shadcn";
58
+ const isLegacyToken = (token) => token.path[0] === "legacy";
59
+ const isThemeToken = (token) =>
60
+ !isPrimitiveToken(token) && !isShadcnToken(token) && !isLegacyToken(token);
61
+
62
+ function kebabCase(value) {
63
+ return value
64
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
65
+ .replace(/[_\s]+/g, "-")
66
+ .toLowerCase();
67
+ }
68
+
69
+ function cssVariableName(tokenPath, node) {
70
+ const publicName = node?.$extensions?.["com.sproutsocial.theme"]?.cssName;
71
+ if (publicName !== undefined) {
72
+ if (!/^--[a-z][a-z0-9-]*$/.test(publicName))
73
+ throw new Error(`Invalid public CSS name: ${publicName}`);
74
+ return publicName;
75
+ }
76
+ if (tokenPath[0] === "legacy") {
77
+ return `--${tokenPath
78
+ .slice(1)
79
+ .map((segment) => LEGACY_ABBREVIATIONS[segment] ?? segment)
80
+ .join("-")}`;
81
+ }
82
+
83
+ if (tokenPath[0] === "css") return `--${tokenPath.slice(1).join("-")}`;
84
+
85
+ if (tokenPath[0] === "shadcn") {
86
+ const pathWithoutNamespace =
87
+ tokenPath[1] === "color" ? tokenPath.slice(2) : tokenPath.slice(1);
88
+ return `--${pathWithoutNamespace.join("-")}`;
89
+ }
90
+
91
+ return `--${tokenPath.map(kebabCase).join("-")}`;
92
+ }
93
+
94
+ function assertNoCssVariableCollisions(tokens, label) {
95
+ const variables = new Map();
96
+
97
+ function visit(node, tokenPath = []) {
98
+ if (isToken(node)) {
99
+ const variable = cssVariableName(tokenPath, node);
100
+ const previous = variables.get(variable);
101
+ if (
102
+ previous &&
103
+ JSON.stringify(previous.value) !== JSON.stringify(node.$value)
104
+ ) {
105
+ throw new Error(
106
+ `${label}: CSS variable ${variable} is generated with conflicting values by both ${
107
+ previous.path
108
+ } and ${tokenPath.join(".")}.`
109
+ );
110
+ }
111
+ variables.set(variable, {
112
+ path: tokenPath.join("."),
113
+ value: node.$value,
114
+ });
115
+ return;
116
+ }
117
+
118
+ if (!isObject(node)) return;
119
+ for (const [key, value] of Object.entries(node)) {
120
+ if (!key.startsWith("$")) visit(value, [...tokenPath, key]);
121
+ }
122
+ }
123
+
124
+ visit(tokens);
125
+ }
126
+
127
+ // The legacy layer reproduces the selectors `seeds-react-theme` shipped, because
128
+ // keeping existing consumers working is its entire purpose. These are historical
129
+ // facts rather than configuration, so a host does not get to choose them.
130
+ //
131
+ // `data-theme` meant color mode in that contract and identifies a brand in the
132
+ // current one, so the two vocabularies cannot share a selector. An app still on
133
+ // `data-theme="dark"` matches no current-contract block at all, which is why the
134
+ // legacy blocks keep a bare `:root` base. `[data-color-mode="dark"]` is included
135
+ // so an app can adopt the new mode mechanism before it migrates off these names.
136
+ const LEGACY_SELECTORS = Object.freeze({
137
+ base: ":root",
138
+ dark: '[data-theme="dark"], .dark, .lights-out, [data-color-mode="dark"]',
139
+ });
140
+
141
+ function tailwindTheme(shadcn, darkVariant) {
142
+ const colorMappings = SHADCN_COLOR_SLOTS.filter((slot) => slot in shadcn).map(
143
+ (slot) => ` --color-${slot}: var(--${slot});`
144
+ );
145
+ const radiusMappings =
146
+ "radius" in shadcn
147
+ ? [
148
+ " --radius-sm: calc(var(--radius) - 4px);",
149
+ " --radius-md: calc(var(--radius) - 2px);",
150
+ " --radius-lg: var(--radius);",
151
+ " --radius-xl: calc(var(--radius) + 4px);",
152
+ ]
153
+ : [];
154
+
155
+ return `@custom-variant dark (${darkVariant});
156
+
157
+ @theme inline {
158
+ ${[...colorMappings, ...radiusMappings].join("\n")}
159
+ }
160
+ `;
161
+ }
162
+
163
+ function selectRoots(tokens, predicate) {
164
+ return Object.fromEntries(
165
+ Object.entries(tokens)
166
+ .filter(([key]) => !key.startsWith("$"))
167
+ .filter(([key]) => predicate({ path: [key] }))
168
+ .map(([key, value]) => [key, structuredClone(value)])
169
+ );
170
+ }
171
+
172
+ function linesFor(dictionary, predicate, excludedNames = new Set()) {
173
+ const allTokens = dictionary.allTokens.filter(
174
+ (token) => predicate(token) && !excludedNames.has(`--${token.name}`)
175
+ );
176
+ if (allTokens.length === 0) return [];
177
+
178
+ return formattedVariables({
179
+ format: "css",
180
+ dictionary: { ...dictionary, allTokens },
181
+ outputReferences: true,
182
+ usesDtcg: true,
183
+ formatting: { indentation: " " },
184
+ }).split("\n");
185
+ }
186
+
187
+ function variableName(line) {
188
+ return line.match(/^\s*(--[^:]+):/)?.[1];
189
+ }
190
+
191
+ function changedLines(lightLines, darkLines) {
192
+ const lightByName = new Map(
193
+ lightLines.map((line) => [variableName(line), line.trim()])
194
+ );
195
+ return darkLines.filter(
196
+ (line) => lightByName.get(variableName(line)) !== line.trim()
197
+ );
198
+ }
199
+
200
+ function cssBlock(selector, lines) {
201
+ if (lines.length === 0) return "";
202
+ return `${selector} {\n${lines.join("\n")}\n}\n`;
203
+ }
204
+
205
+ function darkCssBlock(selectors, lines) {
206
+ const block = cssBlock(selectors.dark, lines);
207
+ if (!block) return block;
208
+ return selectors.darkMedia
209
+ ? `@media ${selectors.darkMedia} {\n${block}}\n`
210
+ : block;
211
+ }
212
+
213
+ /** Compare declarations with references intact so inherited aliases remain live. */
214
+ export async function compileExtensionCss(product, baseline, selectors) {
215
+ const layers = {};
216
+ for (const [name, theme] of Object.entries({ product, baseline })) {
217
+ layers[name] = {};
218
+ for (const mode of themeModes) {
219
+ assertNoCssVariableCollisions(theme.modes[mode], `${theme.name}/${mode}`);
220
+ const dictionary = await styleDictionary(
221
+ theme,
222
+ mode,
223
+ "."
224
+ ).getPlatformTokens("css");
225
+ layers[name][mode] = linesFor(dictionary, isThemeToken);
226
+ }
227
+ }
228
+ const light = changedLines(layers.baseline.light, layers.product.light);
229
+ // Product base outranks Seeds dark. Reset any light override that differs from
230
+ // the desired dark value, including when that value matches Seeds again.
231
+ const dark = changedLines(
232
+ [...layers.baseline.dark, ...light],
233
+ layers.product.dark
234
+ );
235
+ return `/* Generated extension of Seeds. Do not edit. */\n${cssBlock(
236
+ selectors.base,
237
+ light
238
+ )}${darkCssBlock(selectors, dark)}`;
239
+ }
240
+
241
+ function withImports(imports, blocks) {
242
+ const importsCss = imports
243
+ .map((filename) => `@import "${filename}";`)
244
+ .join("\n");
245
+ const blocksCss = blocks.filter(Boolean).join("\n");
246
+ return `${importsCss}${importsCss && blocksCss ? "\n\n" : ""}${blocksCss}`;
247
+ }
248
+
249
+ function shadcnValues(dictionary) {
250
+ const result = {};
251
+ for (const token of dictionary.allTokens.filter(
252
+ (token) => isShadcnToken(token) || token.path[0] === "css"
253
+ )) {
254
+ const pathWithoutNamespace =
255
+ token.path[1] === "color" ? token.path.slice(2) : token.path.slice(1);
256
+ const slot = pathWithoutNamespace.join("-");
257
+ if (SHADCN_COLOR_SLOTS.includes(slot) || slot === "radius") {
258
+ result[slot] = token.$value;
259
+ }
260
+ }
261
+ return result;
262
+ }
263
+
264
+ function styleDictionary(theme, mode, outputDirectory) {
265
+ const jsonFiles = [
266
+ {
267
+ destination: `theme-${mode}.json`,
268
+ format: "json/nested",
269
+ filter: isThemeToken,
270
+ options: { showFileHeader: false },
271
+ },
272
+ ];
273
+ const javascriptFiles = [
274
+ {
275
+ destination: `theme-${mode}.js`,
276
+ format: "javascript/es6",
277
+ filter: isThemeToken,
278
+ options: { showFileHeader: false },
279
+ },
280
+ {
281
+ destination: `theme-${mode}.d.ts`,
282
+ format: "typescript/es6-declarations",
283
+ filter: isThemeToken,
284
+ options: { showFileHeader: false },
285
+ },
286
+ ];
287
+
288
+ return new StyleDictionary({
289
+ tokens: theme.modes[mode],
290
+ log: { verbosity: "silent" },
291
+ platforms: {
292
+ css: {
293
+ transforms: [
294
+ // Avoid color/css first converting wide-gamut inputs to sRGB.
295
+ ...StyleDictionary.hooks.transformGroups.css.filter(
296
+ (name) => name !== "color/css"
297
+ ),
298
+ "seeds/theme/css-name",
299
+ "seeds/theme/css-value",
300
+ "seeds/theme/css-color",
301
+ "seeds/theme/legacy-value",
302
+ "seeds/theme/primitive-value",
303
+ ],
304
+ },
305
+ json: {
306
+ transformGroup: "js",
307
+ buildPath: `${outputDirectory}${path.sep}`,
308
+ files: jsonFiles,
309
+ },
310
+ javascript: {
311
+ transformGroup: "js",
312
+ buildPath: `${outputDirectory}${path.sep}`,
313
+ files: javascriptFiles,
314
+ },
315
+ },
316
+ });
317
+ }
318
+
319
+ StyleDictionary.registerTransform({
320
+ name: "seeds/theme/css-name",
321
+ type: "name",
322
+ transform: (token) => cssVariableName(token.path, token.original).slice(2),
323
+ });
324
+
325
+ StyleDictionary.registerTransform({
326
+ name: "seeds/theme/css-value",
327
+ type: "value",
328
+ filter: (token) =>
329
+ Boolean(token.original.$extensions?.["com.sproutsocial.theme"]?.cssName),
330
+ transform: (token) => token.original.$value,
331
+ });
332
+
333
+ // Style Dictionary owns color conversion. Keep runtime CSS expressions and
334
+ // references intact; those are evaluated by CSS after theme selection.
335
+ StyleDictionary.registerTransform({
336
+ name: "seeds/theme/css-color",
337
+ type: "value",
338
+ filter: (token) =>
339
+ token.$type === "color" &&
340
+ !isLegacyToken(token) &&
341
+ !isPrimitiveToken(token),
342
+ transform: (token, platform, options) => {
343
+ const value = token.original.$value;
344
+ // Already-OKLCH source values retain their authored precision.
345
+ if (typeof value === "string" && /^oklch\(/i.test(value)) return value;
346
+ if (
347
+ typeof value === "string" &&
348
+ /\{|\b(?:var|color-mix|light-dark|(?:repeating-)?(?:linear|radial|conic)-gradient)\s*\(|\(\s*from\b|^currentcolor$/i.test(
349
+ value
350
+ )
351
+ )
352
+ return value;
353
+ return StyleDictionary.hooks.transforms["color/oklch"].transform(
354
+ { ...token, $value: value },
355
+ platform,
356
+ options
357
+ );
358
+ },
359
+ });
360
+
361
+ // Migration data preserves the exact strings emitted by seeds-react-theme,
362
+ // including gradients, var() references, casing, and transparent.
363
+ StyleDictionary.registerTransform({
364
+ name: "seeds/theme/legacy-value",
365
+ type: "value",
366
+ filter: isLegacyToken,
367
+ transform: (token) => token.original.$value,
368
+ });
369
+
370
+ // Primitive values remain an input so semantic references resolve and the
371
+ // migration layer can avoid redeclaring names owned by seeds-color.
372
+ StyleDictionary.registerTransform({
373
+ name: "seeds/theme/primitive-value",
374
+ type: "value",
375
+ filter: isPrimitiveToken,
376
+ transform: (token) => token.original.$value,
377
+ });
378
+
379
+ export async function buildThemeArtifacts(
380
+ theme,
381
+ { outputDirectory, selectors = selectorsForTheme(theme.name) }
382
+ ) {
383
+ if (!outputDirectory) {
384
+ throw new TypeError("buildThemeArtifacts requires an outputDirectory.");
385
+ }
386
+ assertThemeSelectors(selectors);
387
+
388
+ await fs.mkdir(path.join(outputDirectory, "source"), { recursive: true });
389
+ const dictionaries = {};
390
+
391
+ for (const mode of themeModes) {
392
+ assertNoCssVariableCollisions(theme.modes[mode], `${theme.name}/${mode}`);
393
+ const dictionary = styleDictionary(theme, mode, outputDirectory);
394
+ await dictionary.buildPlatform("json");
395
+ await dictionary.buildPlatform("javascript");
396
+ dictionaries[mode] = await dictionary.getPlatformTokens("css");
397
+ }
398
+
399
+ const primitiveLight = linesFor(dictionaries.light, isPrimitiveToken);
400
+ const primitiveDark = linesFor(dictionaries.dark, isPrimitiveToken);
401
+ if (changedLines(primitiveLight, primitiveDark).length > 0) {
402
+ throw new Error("Primitive tokens must not change between color modes.");
403
+ }
404
+
405
+ // Legacy CSS has always supplied its border/spacing aliases itself. Only the
406
+ // historical color palette is external to that compatibility entry point.
407
+ const primitiveNames = new Set(
408
+ linesFor(dictionaries.light, (token) => token.path[0] === "color").map(
409
+ variableName
410
+ )
411
+ );
412
+ const layers = Object.fromEntries(
413
+ themeModes.map((mode) => {
414
+ const dictionary = dictionaries[mode];
415
+ return [
416
+ mode,
417
+ {
418
+ theme: linesFor(dictionary, isThemeToken),
419
+ shadcn: linesFor(dictionary, isShadcnToken),
420
+ legacy: linesFor(dictionary, isLegacyToken, primitiveNames),
421
+ },
422
+ ];
423
+ })
424
+ );
425
+
426
+ const files = {
427
+ "theme-light.css": cssBlock(selectors.base, layers.light.theme),
428
+ "theme-dark.css": darkCssBlock(selectors, layers.dark.theme),
429
+ "theme.css": withImports(
430
+ [],
431
+ [
432
+ cssBlock(selectors.base, layers.light.theme),
433
+ darkCssBlock(
434
+ selectors,
435
+ changedLines(layers.light.theme, layers.dark.theme)
436
+ ),
437
+ ]
438
+ ),
439
+ "shadcn-light.css": withImports(
440
+ ["./theme-light.css", "./tailwind.css"],
441
+ [cssBlock(selectors.base, layers.light.shadcn)]
442
+ ),
443
+ "shadcn-dark.css": withImports(
444
+ ["./theme-dark.css", "./tailwind.css"],
445
+ [darkCssBlock(selectors, layers.dark.shadcn)]
446
+ ),
447
+ "shadcn.css": withImports(
448
+ ["./theme.css", "./tailwind.css"],
449
+ [
450
+ cssBlock(selectors.base, layers.light.shadcn),
451
+ darkCssBlock(
452
+ selectors,
453
+ changedLines(layers.light.shadcn, layers.dark.shadcn)
454
+ ),
455
+ ]
456
+ ),
457
+ "legacy-light.css": withImports(
458
+ ["./theme-light.css"],
459
+ [cssBlock(LEGACY_SELECTORS.base, layers.light.legacy)]
460
+ ),
461
+ "legacy-dark.css": withImports(
462
+ ["./theme-dark.css"],
463
+ [cssBlock(LEGACY_SELECTORS.dark, layers.dark.legacy)]
464
+ ),
465
+ "legacy.css": withImports(
466
+ ["./theme.css"],
467
+ [
468
+ cssBlock(LEGACY_SELECTORS.base, layers.light.legacy),
469
+ cssBlock(
470
+ LEGACY_SELECTORS.dark,
471
+ changedLines(layers.light.legacy, layers.dark.legacy)
472
+ ),
473
+ ]
474
+ ),
475
+ };
476
+
477
+ const rawLayers = {
478
+ "theme-light": selectRoots(theme.modes.light, isThemeToken),
479
+ "theme-dark": selectRoots(theme.modes.dark, isThemeToken),
480
+ "shadcn-light": selectRoots(
481
+ theme.modes.light,
482
+ (token) => isShadcnToken(token) || token.path[0] === "css"
483
+ ),
484
+ "shadcn-dark": selectRoots(
485
+ theme.modes.dark,
486
+ (token) => isShadcnToken(token) || token.path[0] === "css"
487
+ ),
488
+ };
489
+ const shadcn = Object.fromEntries(
490
+ themeModes.map((mode) => [mode, shadcnValues(dictionaries[mode])])
491
+ );
492
+
493
+ await Promise.all([
494
+ ...Object.entries(files).map(([filename, content]) =>
495
+ fs.writeFile(path.join(outputDirectory, filename), content)
496
+ ),
497
+ ...Object.entries(rawLayers).map(([name, tokens]) =>
498
+ fs.writeFile(
499
+ path.join(outputDirectory, "source", `${name}.tokens.json`),
500
+ `${JSON.stringify(tokens, null, 2)}\n`
501
+ )
502
+ ),
503
+ fs.writeFile(
504
+ path.join(outputDirectory, "tailwind.css"),
505
+ tailwindTheme(shadcn.light, selectors.darkVariant)
506
+ ),
507
+ fs.writeFile(
508
+ path.join(outputDirectory, "shadcn.registry.json"),
509
+ `${JSON.stringify(
510
+ {
511
+ $schema: "https://ui.shadcn.com/schema/registry-item.json",
512
+ name: theme.name,
513
+ type: "registry:theme",
514
+ cssVars: shadcn,
515
+ },
516
+ null,
517
+ 2
518
+ )}\n`
519
+ ),
520
+ ]);
521
+
522
+ return [
523
+ ...Object.keys(files),
524
+ ...Object.keys(rawLayers).map((name) => `source/${name}.tokens.json`),
525
+ ...themeModes.flatMap((mode) => [
526
+ `theme-${mode}.json`,
527
+ `theme-${mode}.js`,
528
+ `theme-${mode}.d.ts`,
529
+ ]),
530
+ "tailwind.css",
531
+ "shadcn.registry.json",
532
+ ].sort();
533
+ }
@@ -0,0 +1,39 @@
1
+ import type { DtcgTokenTree, ThemeSelectors } from "./index.js";
2
+
3
+ export type ProductThemeTokenSource = DtcgTokenTree | string;
4
+
5
+ export type ProductThemeInput = {
6
+ name: string;
7
+ extensionRoots?: string[];
8
+ common?: ProductThemeTokenSource;
9
+ light: ProductThemeTokenSource;
10
+ dark: ProductThemeTokenSource;
11
+ outputDirectory?: string;
12
+ selectors?: ThemeSelectors;
13
+ };
14
+
15
+ export type ProductThemeConfig = {
16
+ kind: "product-theme-config";
17
+ name: string;
18
+ extensionRoots: string[];
19
+ sources: {
20
+ common: ProductThemeTokenSource;
21
+ light: ProductThemeTokenSource;
22
+ dark: ProductThemeTokenSource;
23
+ };
24
+ outputDirectory: string;
25
+ selectors?: ThemeSelectors;
26
+ };
27
+
28
+ export function defineProductTheme(
29
+ input: ProductThemeInput
30
+ ): ProductThemeConfig;
31
+
32
+ export function buildProductTheme(
33
+ config: ProductThemeConfig | ProductThemeInput,
34
+ options?: {
35
+ configDirectory?: string;
36
+ baseTokenDirectory?: string;
37
+ primitiveTokens?: DtcgTokenTree;
38
+ }
39
+ ): Promise<string>;