@motion-proto/live-tokens 0.72.1 → 0.74.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 (81) hide show
  1. package/.claude/skills/live-tokens-build-page/SKILL.md +37 -2
  2. package/.claude/skills/live-tokens-build-page/references/layout-sources.md +48 -0
  3. package/.claude/skills/live-tokens-check-compliance/SKILL.md +2 -2
  4. package/.claude/skills/live-tokens-create-component/SKILL.md +4 -4
  5. package/.claude/skills/live-tokens-create-theme/SKILL.md +80 -0
  6. package/.claude/skills/live-tokens-create-theme/references/design-directions.md +94 -0
  7. package/.claude/skills/live-tokens-pick-component/SKILL.md +1 -1
  8. package/.claude/skills/live-tokens-set-colors/SKILL.md +140 -0
  9. package/.claude/skills/live-tokens-set-colors/references/color-anchors.md +80 -0
  10. package/.claude/skills/{live-tokens-adjust-geometry → live-tokens-set-geometry}/SKILL.md +12 -7
  11. package/.claude/skills/live-tokens-set-geometry/references/geometry-anchors.md +61 -0
  12. package/.claude/skills/{live-tokens-pair-fonts → live-tokens-set-type}/SKILL.md +18 -15
  13. package/.claude/skills/live-tokens-set-type/references/type-anchors.md +60 -0
  14. package/CHANGELOG.md +141 -0
  15. package/README.md +24 -15
  16. package/bin/check-page.mjs +3 -3
  17. package/bin/cli.mjs +92 -55
  18. package/bin/lib/liveState.mjs +110 -0
  19. package/bin/save-theme.mjs +177 -0
  20. package/bin/set-colors.mjs +191 -0
  21. package/bin/{adjust.mjs → set-geometry.mjs} +18 -50
  22. package/bin/{set-fonts.mjs → set-type.mjs} +21 -55
  23. package/dist-plugin/{chunk-RIXO2E55.js → chunk-7VRTBGJT.js} +1 -1
  24. package/dist-plugin/{chunk-YLCOIGQC.js → chunk-V3YF6CGT.js} +56 -2
  25. package/dist-plugin/index.cjs +58 -3
  26. package/dist-plugin/index.js +11 -11
  27. package/dist-plugin/migrateData/index.cjs +56 -1
  28. package/dist-plugin/migrateData/index.js +2 -2
  29. package/dist-plugin/{generateColorsAndType → setColors}/index.cjs +1109 -1071
  30. package/dist-plugin/{generateColorsAndType → setColors}/index.d.cts +33 -24
  31. package/dist-plugin/{generateColorsAndType → setColors}/index.d.ts +33 -24
  32. package/dist-plugin/{generateColorsAndType → setColors}/index.js +45 -63
  33. package/dist-plugin/{adjust → setGeometry}/index.cjs +60 -5
  34. package/dist-plugin/{adjust → setGeometry}/index.d.cts +1 -1
  35. package/dist-plugin/{adjust → setGeometry}/index.d.ts +1 -1
  36. package/dist-plugin/{adjust → setGeometry}/index.js +1 -1
  37. package/dist-plugin/{fontPairing → setType}/index.cjs +4 -4
  38. package/dist-plugin/{fontPairing → setType}/index.d.cts +1 -1
  39. package/dist-plugin/{fontPairing → setType}/index.d.ts +1 -1
  40. package/dist-plugin/{themeTypes-DSV3Zisf.d.cts → themeTypes-BxRtuN5V.d.cts} +1 -1
  41. package/dist-plugin/{themeTypes-DSV3Zisf.d.ts → themeTypes-BxRtuN5V.d.ts} +1 -1
  42. package/package.json +10 -2
  43. package/src/editor/core/themes/{generateColorsAndType.ts → buildColors.ts} +82 -96
  44. package/src/editor/core/themes/migrations/2026-09-03-drop-legacy-component-keys.ts +62 -0
  45. package/src/editor/core/themes/migrations/index.ts +2 -0
  46. package/src/editor/docs/content/creating-components.md +13 -0
  47. package/src/editor/docs/content/themes-workflow.md +2 -2
  48. package/src/editor/docs/content.generated.ts +2 -2
  49. package/src/editor/overlay/LiveEditorOverlay.svelte +519 -28
  50. package/src/editor/skill-atlas/SkillAtlas.svelte +836 -0
  51. package/src/editor/skill-atlas/SkillAtlas.svelte.d.ts +4 -0
  52. package/src/editor/skill-atlas/SourcePane.svelte +364 -0
  53. package/src/editor/skill-atlas/TreeNodeCard.svelte +206 -0
  54. package/src/editor/skill-atlas/skillSources.generated.ts +45 -0
  55. package/src/editor/skill-atlas/skillSources.ts +1 -0
  56. package/src/editor/skill-atlas/skillTrees.ts +3844 -0
  57. package/src/editor/skill-atlas/types.ts +65 -0
  58. package/src/live-tokens/data/colors-and-type/autumn.json +1 -37
  59. package/src/live-tokens/data/colors-and-type/default.json +1 -37
  60. package/src/live-tokens/data/colors-and-type/halloween.json +1 -37
  61. package/src/live-tokens/data/colors-and-type/midnight-study.json +1 -37
  62. package/src/live-tokens/data/colors-and-type/ocean.json +1 -37
  63. package/src/live-tokens/data/colors-and-type/royal-velvet.json +1 -37
  64. package/src/live-tokens/data/colors-and-type/sketchy.json +1 -37
  65. package/src/live-tokens/data/colors-and-type/spring-meadow.json +1 -37
  66. package/src/live-tokens/data/colors-and-type/sunset.json +1 -37
  67. package/src/live-tokens/data/themes/autumn.json +1 -37
  68. package/src/live-tokens/data/themes/halloween.json +1 -37
  69. package/src/live-tokens/data/themes/midnight-study.json +1 -37
  70. package/src/live-tokens/data/themes/ocean.json +1 -37
  71. package/src/live-tokens/data/themes/royal-velvet.json +1 -37
  72. package/src/live-tokens/data/themes/sketchy.json +1 -37
  73. package/src/live-tokens/data/themes/spring-meadow.json +1 -37
  74. package/src/live-tokens/data/themes/sunset.json +1 -37
  75. package/src/live-tokens/data/tokens.generated.css +0 -36
  76. package/.claude/skills/live-tokens-generate-theme/SKILL.md +0 -156
  77. package/.claude/skills/live-tokens-generate-theme/references/mood-vocabulary.md +0 -43
  78. package/.claude/skills/live-tokens-generate-theme/references/named-themes.md +0 -18
  79. package/.claude/skills/live-tokens-generate-theme/references/style-vocabulary.md +0 -35
  80. package/bin/generate-theme.mjs +0 -260
  81. /package/dist-plugin/{fontPairing → setType}/index.js +0 -0
package/bin/cli.mjs CHANGED
@@ -8,9 +8,10 @@
8
8
  // report The project as facts: tokens read, components used, findings by rule. Always exits 0.
9
9
  // check-component [id] Validate a component (or every authored one) against the create-component skill contract.
10
10
  // check-page [paths...] Validate pages against the build-page skill contract.
11
- // generate-theme <brief> Build a theme from a 10-seed OKLCH brief and open it.
12
- // adjust <ops.json> Apply radius/padding/gap/border-width ops to the open buffer.
13
- // set-fonts <brief.json> Bind Google Fonts families to the theme's font stacks.
11
+ // set-colors <colors> Build the color identity from 10 OKLCH base colors, into the open buffer.
12
+ // set-geometry <ops> Apply radius/padding/gap/border-width ops to the open buffer.
13
+ // set-type <pairing> Bind Google Fonts families to the theme's font stacks.
14
+ // save-theme <name> Compose the live state into themes/<slug>.json and open it.
14
15
  // migrate [...] Reconcile tokens.css, the data tree, and route references.
15
16
 
16
17
  import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeSync } from 'node:fs';
@@ -38,9 +39,10 @@ import {
38
39
  } from './migrate.mjs';
39
40
  import { runMigrateRoutes, formatRouteResult } from './migrate-routes.mjs';
40
41
  import { runCreate, formatCreateResult } from './create.mjs';
41
- import { runGenerateTheme, formatGenerateThemeResult } from './generate-theme.mjs';
42
- import { runAdjust, formatAdjustResult } from './adjust.mjs';
43
- import { runSetFonts, formatSetFontsResult } from './set-fonts.mjs';
42
+ import { runSetColors, formatSetColorsResult } from './set-colors.mjs';
43
+ import { runSetGeometry, formatSetGeometryResult } from './set-geometry.mjs';
44
+ import { runSetType, formatSetTypeResult } from './set-type.mjs';
45
+ import { runSaveTheme, formatSaveThemeResult } from './save-theme.mjs';
44
46
 
45
47
  const USAGE = `Usage: npx @motion-proto/live-tokens <command> [options]
46
48
 
@@ -69,7 +71,7 @@ Commands:
69
71
  value a theme token. Checks every page under src/
70
72
  when given no paths.
71
73
 
72
- Both check commands accept:
74
+ check-component and check-page also accept:
73
75
  --json Machine-readable findings, for a skill to iterate
74
76
  against until the exit code is 0
75
77
  --strict Treat warnings as errors
@@ -77,31 +79,29 @@ Both check commands accept:
77
79
  (or set "checks": { "rules": {...} } in
78
80
  live-tokens.config.json; "checks": { "exclude":
79
81
  [...] } drops paths from discovery entirely)
80
- generate-theme <brief.json> [--no-activate] [--dry-run] [--carry-from <name>]
81
- Build a full theme from a 10-seed OKLCH brief
82
- (see the live-tokens-generate-theme skill),
83
- enforce AA contrast on derived text tokens, write
84
- themes/<slug>.json, and open it in the editor.
85
- Opening never changes what your site ships; Adopt
86
- in the editor does that.
87
- --no-activate writes the theme without opening it;
88
- --dry-run prints the contrast report without
89
- writing. Non-color content (gradients, fonts,
90
- component aliases) carries forward from the live
91
- look, or from theme <name> with --carry-from.
92
- adjust <ops.json> [--dry-run]
82
+ set-colors <base-colors.json> [--dry-run]
83
+ Build the theme's whole color identity from 10
84
+ OKLCH base colors (see the live-tokens-set-colors
85
+ skill) and enforce AA contrast on the derived text
86
+ tokens. Reads the live colors and type and writes
87
+ the result to the unsaved colors-and-type buffer,
88
+ so save the open theme in the editor or run
89
+ save-theme to keep it. Fonts and every override no
90
+ palette owns carry forward. --dry-run prints the
91
+ contrast report without writing.
92
+ set-geometry <ops.json> [--dry-run]
93
93
  Move radius, padding, gap, and border-width
94
94
  aliases along their token scales (see the
95
- live-tokens-adjust-geometry skill). Reads each
95
+ live-tokens-set-geometry skill). Reads each
96
96
  component's live config and writes the result to
97
97
  that component's unsaved buffer, so save the open
98
98
  theme in the editor to keep it. --dry-run prints
99
99
  the report without writing.
100
- set-fonts <brief.json> [--dry-run] [--no-verify]
100
+ set-type <pairing.json> [--dry-run] [--no-verify]
101
101
  Bind Google Fonts families to --font-display,
102
102
  --font-sans, --font-serif, --font-mono and
103
103
  --font-editorial (see
104
- the live-tokens-pair-fonts skill). Each family is
104
+ the live-tokens-set-type skill). Each family is
105
105
  verified against the Google Fonts API and the URL
106
106
  is negotiated from the weights it actually has.
107
107
  Writes the result to the unsaved colors-and-type
@@ -109,6 +109,17 @@ Both check commands accept:
109
109
  keep it. --dry-run prints the report without
110
110
  writing; --no-verify skips the network and
111
111
  requires an explicit URL per family.
112
+ save-theme <name> [--no-activate] [--dry-run]
113
+ Compose the live state (the unsaved buffers, the
114
+ open theme under them, the shipped defaults under
115
+ that) into themes/<slug>.json and open it, which
116
+ clears the buffers. With no unsaved edits it saves
117
+ a copy of the open theme under the new name.
118
+ Opening never changes what your site ships; Adopt
119
+ in the editor does that. --no-activate writes the
120
+ theme and leaves the live state alone, so a set of
121
+ themes comes off one starting look; --dry-run
122
+ prints the report without writing.
112
123
  migrate [--check] [--write] [--tokens <path>]
113
124
  Reconcile your project with the installed package:
114
125
  applies additive tokens.css migrations, moves a
@@ -150,7 +161,7 @@ if (!command || command === '--help' || command === '-h') {
150
161
 
151
162
  const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
152
163
 
153
- if (command === 'create' || command === 'init') {
164
+ if (command === 'create') {
154
165
  const targetArg = rest.find((a) => !a.startsWith('-'));
155
166
  if (!targetArg) {
156
167
  fail(`Usage: npx @motion-proto/live-tokens create <project-directory>`);
@@ -241,72 +252,97 @@ if (command === 'check-page') {
241
252
  reportChecks('check-page', findings, checked, PAGE_RULES, opts);
242
253
  }
243
254
 
244
- if (command === 'generate-theme') {
245
- const briefPath = rest.find((a) => !a.startsWith('-'));
246
- if (!briefPath) {
247
- fail(`Usage: npx @motion-proto/live-tokens generate-theme <brief.json> [--no-activate] [--dry-run]`);
255
+ if (command === 'set-colors') {
256
+ const baseColorsPath = rest.find((a) => !a.startsWith('-'));
257
+ if (!baseColorsPath) {
258
+ fail(`Usage: npx @motion-proto/live-tokens set-colors <base-colors.json> [--dry-run]`);
259
+ }
260
+ if (rest.includes('--no-activate')) {
261
+ fail(
262
+ `set-colors has no --no-activate: it edits the open buffer, which is what the page already runs. ` +
263
+ `Nothing is activated until save-theme, which takes the flag.`,
264
+ );
265
+ }
266
+ if (rest.includes('--carry-from')) {
267
+ fail(
268
+ `set-colors has no --carry-from: it reads the live look and edits it in place, so a second theme ` +
269
+ `already starts from the first. Run save-theme --no-activate between themes.`,
270
+ );
248
271
  }
249
272
  try {
250
- const carryIdx = rest.indexOf('--carry-from');
251
- const carryFrom = carryIdx !== -1 ? rest[carryIdx + 1] : undefined;
252
- if (carryIdx !== -1 && !carryFrom) fail(`--carry-from requires a theme name`);
253
- const result = await runGenerateTheme({
254
- briefPath,
255
- activate: !rest.includes('--no-activate'),
273
+ const result = await runSetColors({
274
+ baseColorsPath,
256
275
  dryRun: rest.includes('--dry-run'),
257
- carryFrom,
258
276
  });
259
- console.log(formatGenerateThemeResult(result));
277
+ console.log(formatSetColorsResult(result));
260
278
  process.exit(result.report.failures.length === 0 ? 0 : 1);
261
279
  } catch (err) {
262
- fail(`generate-theme failed: ${err instanceof Error ? err.message : String(err)}`);
280
+ fail(`set-colors failed: ${err instanceof Error ? err.message : String(err)}`);
263
281
  }
264
282
  }
265
283
 
266
- if (command === 'adjust') {
284
+ if (command === 'set-geometry') {
267
285
  const opsPath = rest.find((a) => !a.startsWith('-'));
268
286
  if (!opsPath) {
269
- fail(`Usage: npx @motion-proto/live-tokens adjust <ops.json> [--dry-run]`);
287
+ fail(`Usage: npx @motion-proto/live-tokens set-geometry <ops.json> [--dry-run]`);
270
288
  }
271
289
  if (rest.includes('--no-activate')) {
272
290
  fail(
273
- `adjust has no --no-activate: it edits the open buffer, which is what the page already runs. ` +
291
+ `set-geometry has no --no-activate: it edits the open buffer, which is what the page already runs. ` +
274
292
  `Drop the flag and re-run.`,
275
293
  );
276
294
  }
277
295
  try {
278
- const result = await runAdjust({
296
+ const result = await runSetGeometry({
279
297
  opsPath,
280
298
  dryRun: rest.includes('--dry-run'),
281
299
  });
282
- console.log(formatAdjustResult(result));
300
+ console.log(formatSetGeometryResult(result));
283
301
  process.exit(0);
284
302
  } catch (err) {
285
- fail(`adjust failed: ${err instanceof Error ? err.message : String(err)}`);
303
+ fail(`set-geometry failed: ${err instanceof Error ? err.message : String(err)}`);
286
304
  }
287
305
  }
288
306
 
289
- if (command === 'set-fonts') {
290
- const briefPath = rest.find((a) => !a.startsWith('-'));
291
- if (!briefPath) {
292
- fail(`Usage: npx @motion-proto/live-tokens set-fonts <brief.json> [--dry-run] [--no-verify]`);
307
+ if (command === 'set-type') {
308
+ const pairingPath = rest.find((a) => !a.startsWith('-'));
309
+ if (!pairingPath) {
310
+ fail(`Usage: npx @motion-proto/live-tokens set-type <pairing.json> [--dry-run] [--no-verify]`);
293
311
  }
294
312
  if (rest.includes('--no-activate')) {
295
313
  fail(
296
- `set-fonts has no --no-activate: it edits the open buffer, which is what the page already runs. ` +
314
+ `set-type has no --no-activate: it edits the open buffer, which is what the page already runs. ` +
297
315
  `Drop the flag and re-run.`,
298
316
  );
299
317
  }
300
318
  try {
301
- const result = await runSetFonts({
302
- briefPath,
319
+ const result = await runSetType({
320
+ pairingPath,
303
321
  dryRun: rest.includes('--dry-run'),
304
322
  verify: !rest.includes('--no-verify'),
305
323
  });
306
- console.log(formatSetFontsResult(result));
324
+ console.log(formatSetTypeResult(result));
325
+ process.exit(0);
326
+ } catch (err) {
327
+ fail(`set-type failed: ${err instanceof Error ? err.message : String(err)}`);
328
+ }
329
+ }
330
+
331
+ if (command === 'save-theme') {
332
+ const name = rest.find((a) => !a.startsWith('-'));
333
+ if (!name) {
334
+ fail(`Usage: npx @motion-proto/live-tokens save-theme <name> [--no-activate] [--dry-run]`);
335
+ }
336
+ try {
337
+ const result = await runSaveTheme({
338
+ name,
339
+ activate: !rest.includes('--no-activate'),
340
+ dryRun: rest.includes('--dry-run'),
341
+ });
342
+ console.log(formatSaveThemeResult(result));
307
343
  process.exit(0);
308
344
  } catch (err) {
309
- fail(`set-fonts failed: ${err instanceof Error ? err.message : String(err)}`);
345
+ fail(`save-theme failed: ${err instanceof Error ? err.message : String(err)}`);
310
346
  }
311
347
  }
312
348
 
@@ -391,9 +427,10 @@ const SAMPLE_PROMPTS = {
391
427
  'live-tokens-build-page': 'build a pricing page using live-tokens components',
392
428
  'live-tokens-pick-component': "what's the difference between TabBar and SegmentedControl?",
393
429
  'live-tokens-create-component': 'author a new Toggle component for my live-tokens project',
394
- 'live-tokens-generate-theme': 'make me a bright and cheerful theme',
395
- 'live-tokens-adjust-geometry': 'make the buttons pill shaped',
396
- 'live-tokens-pair-fonts': 'pair some fonts for this theme',
430
+ 'live-tokens-create-theme': 'make me a bright and cheerful theme',
431
+ 'live-tokens-set-colors': 'give me a cooler palette, same fonts',
432
+ 'live-tokens-set-type': 'pair some fonts for this theme',
433
+ 'live-tokens-set-geometry': 'make the buttons pill shaped',
397
434
  'live-tokens-fix-findings': 'make check:design pass',
398
435
  'live-tokens-check-compliance': 'check this project against the design system',
399
436
  };
@@ -0,0 +1,110 @@
1
+ // The live state the three `set-*` workers read, in one place.
2
+ //
3
+ // Every resolver mirrors the dev server's own resolution order — the unsaved
4
+ // buffer, then the open theme's copy by value, then the shipped default — so a
5
+ // CLI edits what the page runs. Pure file reads: the caller passes the resolved
6
+ // dirs, so nothing here loads `dist-plugin` and `bin/engineLoadsLazily.test.ts`
7
+ // holds by construction.
8
+
9
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
10
+ import { dirname, join, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const packageDataDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..', 'src/live-tokens/data');
14
+
15
+ function readJsonIfExists(path) {
16
+ if (!existsSync(path)) return null;
17
+ try {
18
+ return JSON.parse(readFileSync(path, 'utf8'));
19
+ } catch (err) {
20
+ throw new Error(`${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
21
+ }
22
+ }
23
+
24
+ /** Local tree first, then the copy the installed package ships. */
25
+ function readData(localDir, packageSubdir, fileName) {
26
+ return (
27
+ readJsonIfExists(join(localDir, `${fileName}.json`)) ??
28
+ readJsonIfExists(join(packageDataDir, packageSubdir, `${fileName}.json`))
29
+ );
30
+ }
31
+
32
+ /** `_fileName` and `_source` are read-door markers, never part of a document. */
33
+ export function stripMarkers(value) {
34
+ if (!value || typeof value !== 'object') return value;
35
+ const { _fileName, _source, ...rest } = value;
36
+ return rest;
37
+ }
38
+
39
+ export function componentNames(componentConfigsDir) {
40
+ if (!existsSync(componentConfigsDir)) return [];
41
+ return readdirSync(componentConfigsDir, { withFileTypes: true })
42
+ .filter((e) => e.isDirectory())
43
+ .map((e) => e.name)
44
+ .sort();
45
+ }
46
+
47
+ export function readActiveTheme(themesDir) {
48
+ if (!themesDir) return null;
49
+ const slug = readJsonIfExists(join(themesDir, '_active.json'))?.activeFile ?? 'default';
50
+ const theme = readData(themesDir, 'themes', slug);
51
+ return theme ? { slug, theme } : null;
52
+ }
53
+
54
+ export function readLiveColorsAndType(colorsAndTypeDir, active) {
55
+ const working = readJsonIfExists(join(colorsAndTypeDir, '_working.json'));
56
+ if (working) return { colorsAndType: stripMarkers(working), source: 'working' };
57
+ if (active?.theme?.colorsAndType) {
58
+ return { colorsAndType: stripMarkers(active.theme.colorsAndType), source: 'theme' };
59
+ }
60
+ const shipped = readData(colorsAndTypeDir, 'colors-and-type', 'default');
61
+ if (!shipped) throw new Error(`no colors and type to read at ${colorsAndTypeDir}`);
62
+ return { colorsAndType: stripMarkers(shipped), source: 'default' };
63
+ }
64
+
65
+ /** `updatedAt` records when a buffer was written, not what it holds, so the
66
+ * comparison that tells a discard from an edit ignores it. Both writers of
67
+ * `colors-and-type/_working.json` ask this one question, and a rule that kept
68
+ * `updatedAt` missed every buffer the other verb had stamped. */
69
+ export function sameContent(a, b) {
70
+ const strip = ({ updatedAt: _updatedAt, ...rest }) => rest;
71
+ return JSON.stringify(strip(a)) === JSON.stringify(strip(b));
72
+ }
73
+
74
+ /** Which layer sits under the buffer: the open theme when it carries colors and
75
+ * type, else the shipped default. A report that names the layer reads it from
76
+ * here rather than re-deriving the fall-through, which is how the discard
77
+ * message came to name a theme that was not open. */
78
+ export const savedColorsAndTypeSource = (active) => (active?.theme?.colorsAndType ? 'theme' : 'default');
79
+
80
+ /** The layer under the buffer. Returning the buffer to exactly this is a
81
+ * discard, not an edit, so a caller compares against it before writing. */
82
+ export function readSavedColorsAndType(colorsAndTypeDir, active) {
83
+ const saved =
84
+ savedColorsAndTypeSource(active) === 'theme'
85
+ ? active.theme.colorsAndType
86
+ : readData(colorsAndTypeDir, 'colors-and-type', 'default');
87
+ return saved ? stripMarkers(saved) : null;
88
+ }
89
+
90
+ /** Each component's live config, and whether a buffer or the open document
91
+ * answered. Every theme carries every component by value, but this is a raw
92
+ * file read, ahead of any server-side fill — a theme written before a
93
+ * component existed can still omit it, so a missing entry falls back to the
94
+ * shipped default the same way the theme's own fill would, and still reports
95
+ * as the document. */
96
+ export function readLiveComponentConfigs(componentConfigsDir, active) {
97
+ const configs = {};
98
+ const sources = {};
99
+ for (const comp of componentNames(componentConfigsDir)) {
100
+ const working = readJsonIfExists(join(componentConfigsDir, comp, '_working.json'));
101
+ const config =
102
+ working ??
103
+ active?.theme?.componentConfigs?.[comp] ??
104
+ readData(join(componentConfigsDir, comp), `component-configs/${comp}`, 'default');
105
+ if (!config) continue;
106
+ configs[comp] = stripMarkers(config);
107
+ sources[comp] = working ? 'working' : 'theme';
108
+ }
109
+ return { configs, sources };
110
+ }
@@ -0,0 +1,177 @@
1
+ // `live-tokens save-theme` worker.
2
+ //
3
+ // Composes the live state into a theme document at `themes/<slug>.json` and
4
+ // opens it. Live state, and only live state: each layer resolves the way the
5
+ // dev server resolves it — the unsaved buffer, then the open theme's copy by
6
+ // value, then the shipped default — so the theme this writes is the look the
7
+ // page renders. Opening clears every `_working.json` and points
8
+ // `themes/_active.json` at the new document, exactly as the editor's apply door
9
+ // does; `--no-activate` writes the file and leaves the live state alone, which
10
+ // is how a set of themes comes off one starting look.
11
+ //
12
+ // This is the only verb that writes a theme. The three `set-*` verbs write
13
+ // buffers.
14
+
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { dirname, join, relative, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import {
20
+ componentNames,
21
+ readActiveTheme,
22
+ readLiveColorsAndType,
23
+ readLiveComponentConfigs,
24
+ } from './lib/liveState.mjs';
25
+
26
+ const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
27
+ // The setColors bundle carries the slug helper, the component schema stamp and
28
+ // the dir resolution; composing a theme needs no color pipeline of its own.
29
+ const ENGINE = resolve(pkgRoot, 'dist-plugin/setColors/index.js');
30
+ // Source of truth: src/editor/core/themes/themeTypes.ts, which
31
+ // normalizeTheme.ts re-exports. This copy cannot import TS, so
32
+ // `bin/schemaVersionCopies.test.ts` is what catches a drift.
33
+ const THEME_SCHEMA_VERSION = 5;
34
+
35
+ async function loadEngine() {
36
+ if (!existsSync(ENGINE)) {
37
+ throw new Error(
38
+ `setColors engine not found at ${relative(process.cwd(), ENGINE)}. ` +
39
+ `Build the plugin first (npm run build:plugin).`,
40
+ );
41
+ }
42
+ return import(ENGINE);
43
+ }
44
+
45
+ /** The destination file, read to keep its `createdAt`. Never the package copy:
46
+ * this is the file about to be overwritten, not a layer to fall through to. */
47
+ function readLocalJson(path) {
48
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
49
+ }
50
+
51
+ /** `engine` is a test seam; the CLI always runs the compiled bundle. */
52
+ export async function runSaveTheme({
53
+ name,
54
+ activate = true,
55
+ dryRun = false,
56
+ colorsAndTypeDir,
57
+ componentConfigsDir,
58
+ themesDir,
59
+ engine,
60
+ } = {}) {
61
+ const { sanitizeFileName, resolveDataDirs, CURRENT_COMPONENT_SCHEMA_VERSION } =
62
+ engine ?? (await loadEngine());
63
+
64
+ const themeName = typeof name === 'string' ? name.trim() : '';
65
+ if (!themeName) throw new Error('save-theme needs a theme name');
66
+ const slug = sanitizeFileName(themeName);
67
+ if (slug === 'default') {
68
+ throw new Error('"default" is the protected package theme; pick another name');
69
+ }
70
+
71
+ const resolved = colorsAndTypeDir && componentConfigsDir && themesDir ? null : resolveDataDirs();
72
+ const dirs = {
73
+ colorsAndTypeDir: colorsAndTypeDir ?? resolved.colorsAndTypeDir,
74
+ componentConfigsDir: componentConfigsDir ?? resolved.componentConfigsDir,
75
+ themesDir: themesDir ?? resolved.themesDir,
76
+ };
77
+
78
+ const active = readActiveTheme(dirs.themesDir);
79
+ const { colorsAndType, source } = readLiveColorsAndType(dirs.colorsAndTypeDir, active);
80
+ const { configs, sources } = readLiveComponentConfigs(dirs.componentConfigsDir, active);
81
+
82
+ const themePath = join(dirs.themesDir, `${slug}.json`);
83
+ const existing = readLocalJson(themePath);
84
+ const now = new Date().toISOString();
85
+ const sketchSettings = active?.theme?.sketchSettings;
86
+
87
+ const theme = {
88
+ name: themeName,
89
+ createdAt: existing?.createdAt ?? now,
90
+ updatedAt: now,
91
+ schemaVersion: THEME_SCHEMA_VERSION,
92
+ colorsAndType: { ...colorsAndType, name: themeName },
93
+ componentConfigs: configs,
94
+ componentSchemaVersion: CURRENT_COMPONENT_SCHEMA_VERSION,
95
+ ...(sketchSettings ? { sketchSettings } : {}),
96
+ };
97
+
98
+ if (!dryRun) {
99
+ mkdirSync(dirs.themesDir, { recursive: true });
100
+ writeFileSync(themePath, JSON.stringify(theme, null, 2) + '\n');
101
+ if (activate) applyTheme(slug, dirs);
102
+ }
103
+
104
+ // `working` is the one source label that proves a buffer answered: a
105
+ // component the open theme omits reports as `theme` after falling through to
106
+ // its shipped default, so the report says what came from a buffer and names
107
+ // the fall-through for the rest rather than claiming a layer per component.
108
+ const bufferedComponents = componentNames(dirs.componentConfigsDir).filter(
109
+ (comp) => sources[comp] === 'working',
110
+ );
111
+
112
+ return {
113
+ name: themeName,
114
+ slug,
115
+ themePath,
116
+ existed: existing !== null,
117
+ dryRun,
118
+ activated: activate && !dryRun,
119
+ previousActive: active?.slug ?? 'default',
120
+ openTheme: active?.slug ?? null,
121
+ buffered: { colorsAndType: source === 'working', components: bufferedComponents },
122
+ components: Object.keys(configs).length,
123
+ sketchSettings: Boolean(sketchSettings),
124
+ };
125
+ }
126
+
127
+ /** The apply door's write set, reproduced: clear every working delta and point
128
+ * `themes/_active.json` at the open document. Production is untouched. */
129
+ function applyTheme(slug, dirs) {
130
+ const colorsWorking = join(dirs.colorsAndTypeDir, '_working.json');
131
+ if (existsSync(colorsWorking)) rmSync(colorsWorking);
132
+
133
+ for (const comp of componentNames(dirs.componentConfigsDir)) {
134
+ const workingPath = join(dirs.componentConfigsDir, comp, '_working.json');
135
+ if (existsSync(workingPath)) rmSync(workingPath);
136
+ }
137
+
138
+ writeFileSync(join(dirs.themesDir, '_active.json'), JSON.stringify({ activeFile: slug }));
139
+ }
140
+
141
+ export function formatSaveThemeResult(result) {
142
+ const root = process.cwd();
143
+ const lines = [];
144
+ const wrote = result.dryRun ? 'Would write' : result.existed ? 'Updated' : 'Created';
145
+ lines.push(`${wrote} theme "${result.name}" → ${relative(root, result.themePath)}`);
146
+ lines.push(`It carries the colors and type and ${result.components} component config(s) by value.`);
147
+
148
+ const edited = [
149
+ ...(result.buffered.colorsAndType ? ['colors and type'] : []),
150
+ ...result.buffered.components,
151
+ ];
152
+ const open = result.openTheme ? `the open theme "${result.openTheme}"` : 'the package defaults';
153
+ const kept = result.dryRun ? 'Would save' : 'Saved';
154
+ if (edited.length > 0) {
155
+ lines.push(`${kept} your unsaved edits: ${edited.join(', ')}.`);
156
+ lines.push(`Everything else came from ${open}, or the shipped defaults where it carries no entry.`);
157
+ } else {
158
+ lines.push(`No unsaved edits; ${kept.toLowerCase()} a copy of ${open}.`);
159
+ }
160
+ if (result.sketchSettings) lines.push(`Sketch settings rode through from the open theme.`);
161
+
162
+ if (result.activated) {
163
+ lines.push(
164
+ `\nOpened "${result.slug}" (previously open: "${result.previousActive}"). ` +
165
+ `Reload the app to see it; switch back any time from Load in the editor's Theme panel. ` +
166
+ `Adopt it there to publish it to tokens.generated.css.`,
167
+ );
168
+ } else if (result.dryRun) {
169
+ lines.push(`\nDry run: nothing written under ${relative(root, dirname(result.themePath))}.`);
170
+ } else {
171
+ lines.push(
172
+ `\nNot opened (--no-activate). Your unsaved edits are still open, so the next ` +
173
+ `save-theme starts from the same look. Load "${result.slug}" from the editor's Theme panel to see it.`,
174
+ );
175
+ }
176
+ return lines.join('\n');
177
+ }