@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
package/src/config.js ADDED
@@ -0,0 +1,280 @@
1
+ import { loadSeedsPrimitives } from "./primitives.js";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { buildThemeArtifacts } from "./compiler.js";
6
+ import {
7
+ assertThemeSelectors,
8
+ composeTheme,
9
+ defineTheme,
10
+ defineThemeExtension,
11
+ } from "./contract.js";
12
+
13
+ const packageRoot = path.resolve(
14
+ path.dirname(fileURLToPath(import.meta.url)),
15
+ ".."
16
+ );
17
+ const manifestFilename = ".seeds-theme-manifest.json";
18
+
19
+ const isObject = (value) =>
20
+ value !== null && typeof value === "object" && !Array.isArray(value);
21
+
22
+ function assertTokenSource(source, label) {
23
+ if (typeof source !== "string" && !isObject(source)) {
24
+ throw new TypeError(`${label} must be a DTCG object or a JSON file path.`);
25
+ }
26
+ }
27
+
28
+ export function defineProductTheme({
29
+ name,
30
+ extensionRoots = [],
31
+ common = {},
32
+ light,
33
+ dark,
34
+ outputDirectory = "src/theme/generated",
35
+ selectors,
36
+ }) {
37
+ if (typeof name !== "string" || !name) {
38
+ throw new TypeError("Product theme name is required.");
39
+ }
40
+ if (!Array.isArray(extensionRoots)) {
41
+ throw new TypeError("extensionRoots must be an array of token paths.");
42
+ }
43
+ assertTokenSource(common, "common");
44
+ assertTokenSource(light, "light");
45
+ assertTokenSource(dark, "dark");
46
+ if (typeof outputDirectory !== "string" || !outputDirectory) {
47
+ throw new TypeError("outputDirectory must be a non-empty path.");
48
+ }
49
+ // A host application owns its own theme and color-mode mechanism, so it may
50
+ // override the emitted selectors. Validated here so a bad config fails at
51
+ // definition rather than part-way through a build.
52
+ if (selectors !== undefined) assertThemeSelectors(selectors);
53
+
54
+ return {
55
+ kind: "product-theme-config",
56
+ name,
57
+ extensionRoots: [...extensionRoots],
58
+ sources: { common, light, dark },
59
+ outputDirectory,
60
+ ...(selectors === undefined ? {} : { selectors: { ...selectors } }),
61
+ };
62
+ }
63
+
64
+ async function readTokenSource(source, configDirectory) {
65
+ if (isObject(source)) return structuredClone(source);
66
+ const filename = path.resolve(configDirectory, source);
67
+ return JSON.parse(await fs.readFile(filename, "utf8"));
68
+ }
69
+
70
+ function resolveGeneratedArtifact(outputDirectory, filename) {
71
+ if (typeof filename !== "string" || !filename) {
72
+ throw new TypeError("Generated theme manifests must contain file paths.");
73
+ }
74
+
75
+ const normalized = path.normalize(filename);
76
+ const portableNormalized = normalized.split(path.sep).join("/");
77
+ const target = path.resolve(outputDirectory, normalized);
78
+ const relative = path.relative(outputDirectory, target);
79
+ if (
80
+ path.isAbsolute(filename) ||
81
+ portableNormalized !== filename ||
82
+ relative === "" ||
83
+ relative === ".." ||
84
+ relative.startsWith(`..${path.sep}`) ||
85
+ path.isAbsolute(relative)
86
+ ) {
87
+ throw new Error(
88
+ `Generated theme manifest path must stay inside the output directory: ${filename}`
89
+ );
90
+ }
91
+
92
+ return target;
93
+ }
94
+
95
+ async function readGeneratedManifest(outputDirectory) {
96
+ const manifestPath = path.join(outputDirectory, manifestFilename);
97
+
98
+ try {
99
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
100
+ if (manifest.version !== 1 || !Array.isArray(manifest.files)) {
101
+ throw new Error("Unsupported generated theme manifest format.");
102
+ }
103
+ return manifest.files.map((filename) => ({
104
+ filename,
105
+ path: resolveGeneratedArtifact(outputDirectory, filename),
106
+ }));
107
+ } catch (error) {
108
+ if (error?.code === "ENOENT") return [];
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ async function removeEmptyGeneratedDirectories(outputDirectory, artifacts) {
114
+ const directories = new Set();
115
+ for (const { path: artifactPath } of artifacts) {
116
+ let directory = path.dirname(artifactPath);
117
+ while (directory !== outputDirectory) {
118
+ directories.add(directory);
119
+ directory = path.dirname(directory);
120
+ }
121
+ }
122
+
123
+ for (const directory of [...directories].sort(
124
+ (left, right) => right.length - left.length
125
+ )) {
126
+ try {
127
+ await fs.rmdir(directory);
128
+ } catch (error) {
129
+ if (error?.code !== "ENOENT" && error?.code !== "ENOTEMPTY") throw error;
130
+ }
131
+ }
132
+ }
133
+
134
+ async function removeGeneratedArtifact(outputDirectory, artifactPath) {
135
+ let artifact;
136
+ try {
137
+ artifact = await fs.lstat(artifactPath);
138
+ } catch (error) {
139
+ if (error?.code === "ENOENT") return;
140
+ throw error;
141
+ }
142
+
143
+ if (artifact.isDirectory()) {
144
+ throw new Error(
145
+ `Refusing to recursively remove generated artifact directory: ${artifactPath}`
146
+ );
147
+ }
148
+
149
+ const [realOutputDirectory, realParentDirectory] = await Promise.all([
150
+ fs.realpath(outputDirectory),
151
+ fs.realpath(path.dirname(artifactPath)),
152
+ ]);
153
+ const relativeParent = path.relative(
154
+ realOutputDirectory,
155
+ realParentDirectory
156
+ );
157
+ if (
158
+ relativeParent === ".." ||
159
+ relativeParent.startsWith(`..${path.sep}`) ||
160
+ path.isAbsolute(relativeParent)
161
+ ) {
162
+ throw new Error(
163
+ `Refusing to remove generated artifact through a directory outside the output directory: ${artifactPath}`
164
+ );
165
+ }
166
+
167
+ await fs.rm(artifactPath);
168
+ }
169
+
170
+ async function updateGeneratedManifest(
171
+ outputDirectory,
172
+ previousArtifacts,
173
+ generatedFiles
174
+ ) {
175
+ const currentFiles = new Set(generatedFiles);
176
+ const staleArtifacts = previousArtifacts.filter(
177
+ ({ filename }) => !currentFiles.has(filename)
178
+ );
179
+
180
+ await Promise.all(
181
+ staleArtifacts.map(({ path: artifactPath }) =>
182
+ removeGeneratedArtifact(outputDirectory, artifactPath)
183
+ )
184
+ );
185
+ await removeEmptyGeneratedDirectories(outputDirectory, staleArtifacts);
186
+ await fs.writeFile(
187
+ path.join(outputDirectory, manifestFilename),
188
+ `${JSON.stringify(
189
+ { version: 1, files: [...currentFiles].sort() },
190
+ null,
191
+ 2
192
+ )}\n`
193
+ );
194
+ }
195
+
196
+ export async function buildProductTheme(
197
+ input,
198
+ {
199
+ configDirectory = process.cwd(),
200
+ baseTokenDirectory = path.join(packageRoot, "dist", "source"),
201
+ primitiveTokens,
202
+ } = {}
203
+ ) {
204
+ const config =
205
+ input?.kind === "product-theme-config"
206
+ ? input
207
+ : defineProductTheme(input ?? {});
208
+ const [
209
+ primitives,
210
+ baseLight,
211
+ baseDark,
212
+ shadcnLight,
213
+ shadcnDark,
214
+ common,
215
+ light,
216
+ dark,
217
+ ] = await Promise.all([
218
+ primitiveTokens
219
+ ? Promise.resolve(structuredClone(primitiveTokens))
220
+ : loadSeedsPrimitives(),
221
+ fs
222
+ .readFile(
223
+ path.join(baseTokenDirectory, "theme-light.tokens.json"),
224
+ "utf8"
225
+ )
226
+ .then(JSON.parse),
227
+ fs
228
+ .readFile(path.join(baseTokenDirectory, "theme-dark.tokens.json"), "utf8")
229
+ .then(JSON.parse),
230
+ fs
231
+ .readFile(
232
+ path.join(baseTokenDirectory, "shadcn-light.tokens.json"),
233
+ "utf8"
234
+ )
235
+ .then(JSON.parse),
236
+ fs
237
+ .readFile(
238
+ path.join(baseTokenDirectory, "shadcn-dark.tokens.json"),
239
+ "utf8"
240
+ )
241
+ .then(JSON.parse),
242
+ readTokenSource(config.sources.common, configDirectory),
243
+ readTokenSource(config.sources.light, configDirectory),
244
+ readTokenSource(config.sources.dark, configDirectory),
245
+ ]);
246
+
247
+ const seeds = defineTheme({
248
+ name: "seeds",
249
+ common: primitives,
250
+ modes: {
251
+ light: { ...baseLight, ...shadcnLight },
252
+ dark: { ...baseDark, ...shadcnDark },
253
+ },
254
+ });
255
+ const product = defineThemeExtension({
256
+ name: config.name,
257
+ extensionRoots: config.extensionRoots,
258
+ common,
259
+ modes: { light, dark },
260
+ });
261
+ const outputDirectory = path.resolve(configDirectory, config.outputDirectory);
262
+ const previousArtifacts = await readGeneratedManifest(outputDirectory);
263
+
264
+ const generatedFiles = await buildThemeArtifacts(
265
+ composeTheme(seeds, product),
266
+ {
267
+ outputDirectory,
268
+ ...(config.selectors === undefined
269
+ ? {}
270
+ : { selectors: config.selectors }),
271
+ }
272
+ );
273
+ await updateGeneratedManifest(
274
+ outputDirectory,
275
+ previousArtifacts,
276
+ generatedFiles
277
+ );
278
+
279
+ return outputDirectory;
280
+ }
@@ -0,0 +1,351 @@
1
+ import { primitiveRoots } from "./primitives.js";
2
+ const MODES = ["light", "dark"];
3
+ const REFERENCE_PATTERN = /\{([^}]+)\}/g;
4
+
5
+ const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
6
+
7
+ const isObject = (value) =>
8
+ value !== null && typeof value === "object" && !Array.isArray(value);
9
+
10
+ const isToken = (value) => isObject(value) && hasOwn(value, "$value");
11
+
12
+ const clone = (value) => structuredClone(value);
13
+
14
+ function assertTokenTree(value, label) {
15
+ if (!isObject(value)) {
16
+ throw new TypeError(`${label} must be a DTCG token object.`);
17
+ }
18
+ }
19
+
20
+ function mergeTokenTrees(base, overlay) {
21
+ if (overlay === undefined) return clone(base);
22
+ if (base === undefined) return clone(overlay);
23
+
24
+ if (isToken(base) || isToken(overlay)) {
25
+ if (!isToken(overlay)) return clone(overlay);
26
+ return isToken(base)
27
+ ? { ...clone(base), ...clone(overlay) }
28
+ : clone(overlay);
29
+ }
30
+
31
+ if (!isObject(base) || !isObject(overlay)) return clone(overlay);
32
+
33
+ const result = clone(base);
34
+ for (const [key, value] of Object.entries(overlay)) {
35
+ result[key] = mergeTokenTrees(result[key], value);
36
+ }
37
+ return result;
38
+ }
39
+
40
+ function collectTokens(tree, { allowUntyped = false } = {}) {
41
+ const tokens = new Map();
42
+
43
+ function visit(node, path, inheritedType) {
44
+ if (!isObject(node)) {
45
+ throw new TypeError(
46
+ `Token group ${path.join(".") || "<root>"} must be an object.`
47
+ );
48
+ }
49
+
50
+ const type = node.$type ?? inheritedType;
51
+ if (isToken(node)) {
52
+ const tokenPath = path.join(".");
53
+ if (!tokenPath) throw new Error("A token cannot exist at the root.");
54
+ if (!type && !allowUntyped)
55
+ throw new Error(`Token ${tokenPath} has no $type.`);
56
+ tokens.set(tokenPath, { node, path, type });
57
+ return;
58
+ }
59
+
60
+ for (const [key, value] of Object.entries(node)) {
61
+ if (!key.startsWith("$")) visit(value, [...path, key], type);
62
+ }
63
+ }
64
+
65
+ visit(tree, [], undefined);
66
+ return tokens;
67
+ }
68
+
69
+ function assertSameTokenContract(leftTree, rightTree, labels) {
70
+ const left = collectTokens(leftTree);
71
+ const right = collectTokens(rightTree);
72
+ const paths = new Set([...left.keys(), ...right.keys()]);
73
+ const errors = [];
74
+
75
+ for (const path of [...paths].sort()) {
76
+ if (!left.has(path)) errors.push(`${labels[0]} is missing ${path}`);
77
+ else if (!right.has(path)) errors.push(`${labels[1]} is missing ${path}`);
78
+ else if (left.get(path).type !== right.get(path).type) {
79
+ errors.push(
80
+ `${path} changes type from ${left.get(path).type} to ${
81
+ right.get(path).type
82
+ }`
83
+ );
84
+ }
85
+ }
86
+
87
+ if (errors.length) {
88
+ throw new Error(`Theme mode contract mismatch:\n- ${errors.join("\n- ")}`);
89
+ }
90
+ }
91
+
92
+ function referencesIn(value) {
93
+ if (typeof value === "string") {
94
+ return [...value.matchAll(REFERENCE_PATTERN)].map((match) => match[1]);
95
+ }
96
+ if (Array.isArray(value)) return value.flatMap(referencesIn);
97
+ if (isObject(value)) return Object.values(value).flatMap(referencesIn);
98
+ return [];
99
+ }
100
+
101
+ function assertReferencesResolve(tree, label) {
102
+ const tokens = collectTokens(tree);
103
+ const graph = new Map();
104
+
105
+ for (const [path, token] of tokens) {
106
+ const references = referencesIn(token.node.$value);
107
+ for (const reference of references) {
108
+ if (!tokens.has(reference)) {
109
+ throw new Error(
110
+ `${label}: ${path} references missing token ${reference}.`
111
+ );
112
+ }
113
+ }
114
+ graph.set(path, references);
115
+ }
116
+
117
+ const visiting = new Set();
118
+ const visited = new Set();
119
+
120
+ function visit(path, stack) {
121
+ if (visiting.has(path)) {
122
+ const cycleStart = stack.indexOf(path);
123
+ const cycle = [...stack.slice(cycleStart), path];
124
+ throw new Error(
125
+ `${label}: token reference cycle: ${cycle.join(" -> ")}.`
126
+ );
127
+ }
128
+ if (visited.has(path)) return;
129
+
130
+ visiting.add(path);
131
+ for (const reference of graph.get(path) ?? []) {
132
+ visit(reference, [...stack, path]);
133
+ }
134
+ visiting.delete(path);
135
+ visited.add(path);
136
+ }
137
+
138
+ for (const path of graph.keys()) visit(path, []);
139
+ }
140
+
141
+ function normalizedModes(modes, label) {
142
+ if (!isObject(modes)) {
143
+ throw new TypeError(
144
+ `${label}.modes must contain light and dark token sets.`
145
+ );
146
+ }
147
+
148
+ for (const mode of MODES) {
149
+ assertTokenTree(modes[mode], `${label}.modes.${mode}`);
150
+ }
151
+
152
+ return Object.fromEntries(MODES.map((mode) => [mode, clone(modes[mode])]));
153
+ }
154
+
155
+ function assertName(name, label) {
156
+ if (typeof name !== "string" || !/^[a-z][a-z0-9-]*$/.test(name)) {
157
+ throw new TypeError(
158
+ `${label}.name must be a lowercase kebab-case theme identifier.`
159
+ );
160
+ }
161
+ }
162
+
163
+ export function defineTheme({ name, common = {}, modes }) {
164
+ assertName(name, "theme");
165
+ assertTokenTree(common, "theme.common");
166
+ const theme = {
167
+ kind: "theme",
168
+ name,
169
+ common: clone(common),
170
+ modes: normalizedModes(modes, "theme"),
171
+ };
172
+ const composedModes = Object.fromEntries(
173
+ MODES.map((mode) => [
174
+ mode,
175
+ mergeTokenTrees(theme.common, theme.modes[mode]),
176
+ ])
177
+ );
178
+
179
+ assertSameTokenContract(composedModes.light, composedModes.dark, MODES);
180
+ for (const mode of MODES) {
181
+ assertReferencesResolve(composedModes[mode], `${name}/${mode}`);
182
+ }
183
+
184
+ return theme;
185
+ }
186
+
187
+ export function defineThemeExtension({
188
+ name,
189
+ common = {},
190
+ modes,
191
+ extensionRoots = [],
192
+ }) {
193
+ assertName(name, "extension");
194
+ assertTokenTree(common, "extension.common");
195
+ if (
196
+ !Array.isArray(extensionRoots) ||
197
+ extensionRoots.some((root) => typeof root !== "string" || !root)
198
+ ) {
199
+ throw new TypeError("extension.extensionRoots must be token path strings.");
200
+ }
201
+
202
+ return {
203
+ kind: "extension",
204
+ name,
205
+ common: clone(common),
206
+ modes: normalizedModes(modes, "extension"),
207
+ extensionRoots: [...new Set(extensionRoots)],
208
+ };
209
+ }
210
+
211
+ function belongsToExtension(path, extensionRoots) {
212
+ return extensionRoots.some(
213
+ (root) => path === root || path.startsWith(`${root}.`)
214
+ );
215
+ }
216
+
217
+ function validateOverlay(baseTree, overlayTree, extension) {
218
+ const baseTokens = collectTokens(baseTree);
219
+ const overlayTokens = collectTokens(overlayTree, { allowUntyped: true });
220
+
221
+ for (const [path, token] of overlayTokens) {
222
+ if (primitiveRoots.has(token.path[0])) {
223
+ throw new Error(
224
+ `${extension.name}: ${path} is an external design token; reference it instead of overriding it.`
225
+ );
226
+ }
227
+ const baseToken = baseTokens.get(path);
228
+ if (!baseToken) {
229
+ if (!belongsToExtension(path, extension.extensionRoots)) {
230
+ throw new Error(
231
+ `${extension.name}: ${path} is not a Seeds token or within a declared extension root.`
232
+ );
233
+ }
234
+ if (!token.type) throw new Error(`Token ${path} has no $type.`);
235
+ continue;
236
+ }
237
+
238
+ if (token.type && token.type !== baseToken.type) {
239
+ throw new Error(
240
+ `${extension.name}: ${path} must remain ${baseToken.type}, received ${token.type}.`
241
+ );
242
+ }
243
+ }
244
+ }
245
+
246
+ export function composeTheme(baseTheme, ...extensions) {
247
+ if (baseTheme?.kind !== "theme") {
248
+ throw new TypeError(
249
+ "composeTheme requires a theme created by defineTheme."
250
+ );
251
+ }
252
+
253
+ let name = baseTheme.name;
254
+ let modes = Object.fromEntries(
255
+ MODES.map((mode) => [
256
+ mode,
257
+ mergeTokenTrees(baseTheme.common, baseTheme.modes[mode]),
258
+ ])
259
+ );
260
+
261
+ for (const extension of extensions) {
262
+ if (extension?.kind !== "extension") {
263
+ throw new TypeError(
264
+ "composeTheme extensions must be created by defineThemeExtension."
265
+ );
266
+ }
267
+
268
+ for (const mode of MODES) {
269
+ validateOverlay(modes[mode], extension.common, extension);
270
+ const withCommon = mergeTokenTrees(modes[mode], extension.common);
271
+ validateOverlay(withCommon, extension.modes[mode], extension);
272
+ modes[mode] = mergeTokenTrees(withCommon, extension.modes[mode]);
273
+ }
274
+ name = extension.name;
275
+ }
276
+
277
+ assertSameTokenContract(modes.light, modes.dark, MODES);
278
+ for (const mode of MODES) {
279
+ assertReferencesResolve(modes[mode], `${name}/${mode}`);
280
+ }
281
+
282
+ return { name, modes };
283
+ }
284
+
285
+ const DEFAULT_THEME_NAME = "seeds";
286
+ const DEFAULT_DARK_VARIANT = "&:where(.dark, .dark *)";
287
+ const SELECTOR_KEYS = Object.freeze(["base", "dark", "darkVariant"]);
288
+
289
+ export function assertThemeSelectors(selectors) {
290
+ for (const key of SELECTOR_KEYS) {
291
+ const value = selectors?.[key];
292
+ if (typeof value !== "string" || value.trim() === "") {
293
+ throw new TypeError(
294
+ `Theme selectors must provide a non-empty ${key} string.`
295
+ );
296
+ }
297
+ }
298
+ if (
299
+ selectors.darkMedia !== undefined &&
300
+ (typeof selectors.darkMedia !== "string" || !selectors.darkMedia.trim())
301
+ ) {
302
+ throw new TypeError(
303
+ "Theme selectors darkMedia must be a non-empty media query."
304
+ );
305
+ }
306
+ }
307
+
308
+ export function selectorsForTheme(
309
+ name,
310
+ { defaultTheme = DEFAULT_THEME_NAME, mode = "class", darkVariant } = {}
311
+ ) {
312
+ assertName(name, "theme");
313
+ if (!["class", "attribute", "media"].includes(mode)) {
314
+ throw new TypeError(
315
+ 'Theme selector mode must be "class", "attribute" or "media".'
316
+ );
317
+ }
318
+
319
+ // The default theme is the inherited baseline, including when a product is
320
+ // selected. Its base must keep matching in dark mode because dark is a delta.
321
+ // Product selectors outrank both baseline blocks regardless of import order.
322
+ const isDefault = name === defaultTheme;
323
+ const root = isDefault ? ":root" : `:root[data-theme="${name}"]`;
324
+
325
+ if (mode === "media") {
326
+ return {
327
+ base: root,
328
+ dark: root,
329
+ darkMedia: "(prefers-color-scheme: dark)",
330
+ darkVariant: darkVariant ?? "@media (prefers-color-scheme: dark)",
331
+ };
332
+ }
333
+
334
+ return {
335
+ base: root,
336
+ // Keep the baseline mode condition at zero specificity so a product's
337
+ // mode-invariant override also wins over Seeds dark defaults.
338
+ dark: `${root}${isDefault ? ":where(" : ""}${
339
+ mode === "class" ? ".dark" : '[data-color-mode="dark"]'
340
+ }${isDefault ? ")" : ""}`,
341
+ // Theme-agnostic on purpose: a `dark:` utility should fire for any theme
342
+ // in dark mode, so this must not be scoped to one `data-theme` value.
343
+ darkVariant:
344
+ darkVariant ??
345
+ (mode === "class"
346
+ ? DEFAULT_DARK_VARIANT
347
+ : '&:where([data-color-mode="dark"], [data-color-mode="dark"] *)'),
348
+ };
349
+ }
350
+
351
+ export const themeModes = Object.freeze([...MODES]);
@@ -0,0 +1,88 @@
1
+ /** Sparse overrides inherit the Seeds contract; groups can declare types once. */
2
+ export function extensionSchema(graph, primitives = {}) {
3
+ const references = new Map();
4
+ function collect(node, path = [], inheritedType) {
5
+ const type = node.$type ?? inheritedType;
6
+ if ("$value" in node) {
7
+ const values = references.get(type) ?? [];
8
+ values.push(`{${path.join(".")}}`);
9
+ references.set(type, values);
10
+ return;
11
+ }
12
+ for (const [key, child] of Object.entries(node))
13
+ if (!key.startsWith("$")) collect(child, [...path, key], type);
14
+ }
15
+ collect(primitives);
16
+ collect(graph);
17
+ function token(type, description, valueType = "string") {
18
+ return {
19
+ type: "object",
20
+ description,
21
+ required: ["$value"],
22
+ additionalProperties: false,
23
+ properties: {
24
+ $type: typeof type === "string" ? { const: type } : { enum: type },
25
+ $value: {
26
+ description:
27
+ "Reference existing design tokens or Seeds theme roles. The build validates references.",
28
+ anyOf: [
29
+ ...(references.has(type)
30
+ ? [{ $ref: `#/definitions/${type}References` }]
31
+ : []),
32
+ { type: valueType === "number" ? ["number", "string"] : "string" },
33
+ ],
34
+ },
35
+ $description: { type: "string" },
36
+ },
37
+ };
38
+ }
39
+ function tree(node, inheritedType) {
40
+ const type = node.$type ?? inheritedType;
41
+ if ("$value" in node)
42
+ return token(type, node.$description, typeof node.$value);
43
+ return {
44
+ type: "object",
45
+ additionalProperties: false,
46
+ properties: {
47
+ ...(type ? { $type: { const: type } } : {}),
48
+ $description: { type: "string" },
49
+ ...Object.fromEntries(
50
+ Object.entries(node)
51
+ .filter(([name]) => !name.startsWith("$"))
52
+ .map(([name, child]) => [name, tree(child, type)])
53
+ ),
54
+ },
55
+ };
56
+ }
57
+ const schema = tree(graph);
58
+ return {
59
+ $schema: "http://json-schema.org/draft-07/schema#",
60
+ definitions: Object.fromEntries(
61
+ [...references].map(([type, values]) => [
62
+ `${type}References`,
63
+ { enum: values },
64
+ ])
65
+ ),
66
+ title: "Seeds theme extension tokens",
67
+ description:
68
+ "Override only the theme roles that differ from Seeds in this mode. Primitive packages are reference inputs, not override targets.",
69
+ ...schema,
70
+ properties: {
71
+ $schema: { type: "string" },
72
+ ...schema.properties,
73
+ product: {
74
+ type: "object",
75
+ description:
76
+ "Genuinely new product-owned tokens; reuse existing design tokens first.",
77
+ properties: { $type: { enum: ["color", "dimension", "string"] } },
78
+ patternProperties: {
79
+ "^[a-z][a-z0-9-]*$": token(
80
+ ["color", "dimension", "string"],
81
+ "Product-owned token"
82
+ ),
83
+ },
84
+ additionalProperties: false,
85
+ },
86
+ },
87
+ };
88
+ }