@motion-proto/live-tokens 0.73.0 → 0.75.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 (196) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +33 -38
  2. package/.claude/skills/live-tokens-create-component/SKILL copy.md +196 -0
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +198 -146
  4. package/.claude/skills/live-tokens-create-component/references/contract-tests.md +95 -52
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +2 -2
  6. package/.claude/skills/live-tokens-create-component/references/linked-siblings.md +2 -2
  7. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +12 -12
  8. package/.claude/skills/live-tokens-create-component/references/token-naming.md +2 -1
  9. package/.claude/skills/live-tokens-create-page/SKILL.md +187 -0
  10. package/.claude/skills/live-tokens-create-page/references/interaction-sources.md +66 -0
  11. package/.claude/skills/live-tokens-create-page/references/layout-sources.md +87 -0
  12. package/.claude/skills/live-tokens-create-theme/SKILL.md +84 -0
  13. package/.claude/skills/live-tokens-create-theme/references/design-directions.md +94 -0
  14. package/.claude/skills/live-tokens-fix-findings/SKILL.md +69 -60
  15. package/.claude/skills/live-tokens-pick-component/SKILL.md +64 -79
  16. package/.claude/skills/live-tokens-set-colors/SKILL.md +149 -0
  17. package/.claude/skills/live-tokens-set-colors/references/color-anchors.md +80 -0
  18. package/.claude/skills/live-tokens-set-geometry/SKILL.md +121 -0
  19. package/.claude/skills/live-tokens-set-geometry/references/geometry-anchors.md +63 -0
  20. package/.claude/skills/live-tokens-set-type/SKILL.md +93 -0
  21. package/.claude/skills/live-tokens-set-type/references/type-anchors.md +60 -0
  22. package/CHANGELOG.md +194 -0
  23. package/README.md +27 -18
  24. package/bin/check-component.mjs +91 -19
  25. package/bin/check-page.mjs +105 -24
  26. package/bin/cli.mjs +154 -97
  27. package/bin/contractRunner.mjs +945 -0
  28. package/bin/create.mjs +1 -1
  29. package/bin/lib/catalogue.mjs +37 -30
  30. package/bin/lib/findings.mjs +46 -15
  31. package/bin/lib/liveState.mjs +110 -0
  32. package/bin/lib/report.mjs +3 -3
  33. package/bin/lib/tokenVocabulary.mjs +4 -4
  34. package/bin/migrate-routes.mjs +5 -5
  35. package/bin/migrate.mjs +4 -4
  36. package/bin/save-theme.mjs +176 -0
  37. package/bin/set-colors.mjs +189 -0
  38. package/bin/{adjust.mjs → set-geometry.mjs} +25 -57
  39. package/bin/{set-fonts.mjs → set-type.mjs} +26 -62
  40. package/dist-plugin/{chunk-W6Y4BWFB.js → chunk-6WGFOJXO.js} +22 -0
  41. package/dist-plugin/{chunk-RIXO2E55.js → chunk-PDNL4NC5.js} +9 -2
  42. package/dist-plugin/{chunk-YLCOIGQC.js → chunk-SWXRVZKT.js} +98 -2
  43. package/dist-plugin/{dataPaths-BhWzd5cL.d.ts → dataPaths-BpIK_Obx.d.cts} +1 -0
  44. package/dist-plugin/{dataPaths-BhWzd5cL.d.cts → dataPaths-BpIK_Obx.d.ts} +1 -0
  45. package/dist-plugin/index.cjs +366 -148
  46. package/dist-plugin/index.d.cts +1 -1
  47. package/dist-plugin/index.d.ts +1 -1
  48. package/dist-plugin/index.js +226 -130
  49. package/dist-plugin/migrateData/index.cjs +121 -2
  50. package/dist-plugin/migrateData/index.d.cts +1 -1
  51. package/dist-plugin/migrateData/index.d.ts +1 -1
  52. package/dist-plugin/migrateData/index.js +3 -3
  53. package/dist-plugin/{generateColorsAndType → setColors}/index.cjs +594 -499
  54. package/dist-plugin/{generateColorsAndType → setColors}/index.d.cts +34 -25
  55. package/dist-plugin/{generateColorsAndType → setColors}/index.d.ts +34 -25
  56. package/dist-plugin/{generateColorsAndType → setColors}/index.js +48 -66
  57. package/dist-plugin/{adjust → setGeometry}/index.cjs +166 -48
  58. package/dist-plugin/{adjust → setGeometry}/index.d.cts +6 -6
  59. package/dist-plugin/{adjust → setGeometry}/index.d.ts +6 -6
  60. package/dist-plugin/{adjust → setGeometry}/index.js +51 -45
  61. package/dist-plugin/{fontPairing → setType}/index.cjs +19 -4
  62. package/dist-plugin/{fontPairing → setType}/index.d.cts +2 -2
  63. package/dist-plugin/{fontPairing → setType}/index.d.ts +2 -2
  64. package/dist-plugin/{fontPairing → setType}/index.js +1 -1
  65. package/dist-plugin/{themeTypes-DSV3Zisf.d.cts → themeTypes-BxRtuN5V.d.cts} +1 -1
  66. package/dist-plugin/{themeTypes-DSV3Zisf.d.ts → themeTypes-BxRtuN5V.d.ts} +1 -1
  67. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  68. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  69. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  70. package/package.json +36 -8
  71. package/src/app/site.css +19 -9
  72. package/src/editor/bootstrap.ts +2 -0
  73. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +4 -4
  74. package/src/editor/component-editor/DialogEditor.svelte +4 -4
  75. package/src/editor/component-editor/MenuSelectEditor.svelte +4 -1
  76. package/src/editor/component-editor/SegmentedControlEditor.svelte +6 -1
  77. package/src/editor/component-editor/TabBarEditor.svelte +1 -1
  78. package/src/editor/component-editor/TableEditor.svelte +2 -2
  79. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +6 -3
  80. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +41 -20
  81. package/src/editor/core/components/adjustAliases.ts +59 -45
  82. package/src/editor/core/components/aliasKinds.ts +9 -5
  83. package/src/editor/core/preview/themePreview.ts +9 -2
  84. package/src/editor/core/sketch/sketchLayer.ts +22 -0
  85. package/src/editor/core/store/editorStore.ts +10 -1
  86. package/src/editor/core/themes/{generateColorsAndType.ts → buildColors.ts} +84 -98
  87. package/src/editor/core/themes/liveStateStream.ts +26 -0
  88. package/src/editor/core/themes/migrations/2026-09-03-drop-legacy-component-keys.ts +62 -0
  89. package/src/editor/core/themes/migrations/2026-09-07-stroke-role-renames.ts +52 -0
  90. package/src/editor/core/themes/migrations/index.ts +4 -0
  91. package/src/editor/core/themes/themeDocumentSync.ts +22 -11
  92. package/src/editor/core/themes/themeService.ts +9 -2
  93. package/src/editor/docs/content/themes-workflow.md +2 -2
  94. package/src/editor/docs/content.generated.ts +1 -1
  95. package/src/editor/overlay/LiveEditorOverlay.svelte +519 -28
  96. package/src/editor/pages/ComponentEditorPage.svelte +17 -1
  97. package/src/editor/pages/liveTokensEditorHandle.ts +23 -0
  98. package/src/editor/skill-atlas/SkillAtlas.svelte +76 -574
  99. package/src/editor/skill-atlas/TreeCanvas.svelte +263 -0
  100. package/src/editor/skill-atlas/TreeNodeCard.svelte +136 -53
  101. package/src/editor/skill-atlas/edges.ts +31 -0
  102. package/src/editor/skill-atlas/skillSources.generated.ts +29 -24
  103. package/src/editor/skill-atlas/skillTrees.ts +19 -3687
  104. package/src/editor/skill-atlas/trees/check-compliance.ts +183 -0
  105. package/src/editor/skill-atlas/trees/create-component.ts +275 -0
  106. package/src/editor/skill-atlas/trees/create-page.ts +320 -0
  107. package/src/editor/skill-atlas/trees/create-theme.ts +255 -0
  108. package/src/editor/skill-atlas/trees/fix-findings.ts +469 -0
  109. package/src/editor/skill-atlas/trees/pick-component.ts +300 -0
  110. package/src/editor/skill-atlas/trees/set-colors.ts +148 -0
  111. package/src/editor/skill-atlas/trees/set-geometry.ts +136 -0
  112. package/src/editor/skill-atlas/trees/set-type.ts +142 -0
  113. package/src/editor/skill-atlas/types.ts +3 -4
  114. package/src/editor/skill-atlas/wireLayout.ts +287 -0
  115. package/src/live-tokens/data/colors-and-type/autumn.json +1 -37
  116. package/src/live-tokens/data/colors-and-type/default.json +1 -37
  117. package/src/live-tokens/data/colors-and-type/halloween.json +1 -37
  118. package/src/live-tokens/data/colors-and-type/midnight-study.json +1 -37
  119. package/src/live-tokens/data/colors-and-type/ocean.json +1 -37
  120. package/src/live-tokens/data/colors-and-type/royal-velvet.json +1 -37
  121. package/src/live-tokens/data/colors-and-type/sketchy.json +1 -37
  122. package/src/live-tokens/data/colors-and-type/spring-meadow.json +1 -37
  123. package/src/live-tokens/data/colors-and-type/sunset.json +1 -37
  124. package/src/live-tokens/data/themes/autumn.json +16 -52
  125. package/src/live-tokens/data/themes/halloween.json +16 -52
  126. package/src/live-tokens/data/themes/midnight-study.json +16 -52
  127. package/src/live-tokens/data/themes/ocean.json +16 -52
  128. package/src/live-tokens/data/themes/royal-velvet.json +16 -52
  129. package/src/live-tokens/data/themes/sketchy.json +16 -52
  130. package/src/live-tokens/data/themes/spring-meadow.json +16 -52
  131. package/src/live-tokens/data/themes/sunset.json +16 -52
  132. package/src/live-tokens/data/tokens.generated.css +0 -36
  133. package/src/system/components/Badge.svelte +7 -0
  134. package/src/system/components/Button.svelte +7 -0
  135. package/src/system/components/Callout.svelte +10 -6
  136. package/src/system/components/Card.svelte +23 -4
  137. package/src/system/components/CodeSnippet.svelte +4 -3
  138. package/src/system/components/CollapsibleSection.svelte +23 -8
  139. package/src/system/components/CornerBadge.svelte +6 -0
  140. package/src/system/components/Dialog.svelte +13 -6
  141. package/src/system/components/IconButton.svelte +9 -0
  142. package/src/system/components/Image.svelte +8 -0
  143. package/src/system/components/ImageLightbox.svelte +6 -0
  144. package/src/system/components/InlineEditActions.svelte +7 -0
  145. package/src/system/components/Input.svelte +7 -0
  146. package/src/system/components/MenuSelect.svelte +7 -0
  147. package/src/system/components/Notification.svelte +7 -0
  148. package/src/system/components/Panel.svelte +6 -0
  149. package/src/system/components/ProgressBar.svelte +5 -0
  150. package/src/system/components/RadioButton.svelte +11 -5
  151. package/src/system/components/SectionDivider.svelte +8 -0
  152. package/src/system/components/SegmentedControl.svelte +6 -0
  153. package/src/system/components/SideNavigation.svelte +6 -0
  154. package/src/system/components/Slider.svelte +7 -4
  155. package/src/system/components/TabBar.svelte +15 -9
  156. package/src/system/components/Table.svelte +8 -3
  157. package/src/system/components/Toggle.svelte +4 -4
  158. package/src/system/components/Tooltip.svelte +6 -0
  159. package/src/testing-js/chunk-AO7EZHYV.js +776 -0
  160. package/src/testing-js/chunk-AO7EZHYV.js.map +1 -0
  161. package/src/testing-js/chunk-FAFOAWYL.js +39 -0
  162. package/src/testing-js/chunk-FAFOAWYL.js.map +1 -0
  163. package/src/testing-js/chunk-L73N4NSO.js +23 -0
  164. package/src/testing-js/chunk-L73N4NSO.js.map +1 -0
  165. package/src/testing-js/chunk-LXR3MN6N.js +3063 -0
  166. package/src/testing-js/chunk-LXR3MN6N.js.map +1 -0
  167. package/src/testing-js/chunk-ZMSX6CXR.js +53 -0
  168. package/src/testing-js/chunk-ZMSX6CXR.js.map +1 -0
  169. package/src/testing-js/component-alias.contract.js +81 -0
  170. package/src/testing-js/component-alias.contract.js.map +1 -0
  171. package/src/testing-js/component-editor.contract.js +62 -0
  172. package/src/testing-js/component-editor.contract.js.map +1 -0
  173. package/src/testing-js/component-render.contract.js +568 -0
  174. package/src/testing-js/component-render.contract.js.map +1 -0
  175. package/src/testing-js/index.d.ts +293 -0
  176. package/src/testing-js/index.js +222 -0
  177. package/src/testing-js/index.js.map +1 -0
  178. package/src/testing-js/registry.contract.js +39 -0
  179. package/src/testing-js/registry.contract.js.map +1 -0
  180. package/src/testing-js/vitest-BE6uGF31.d.ts +73 -0
  181. package/src/testing-js/vitest.d.ts +3 -0
  182. package/src/testing-js/vitest.js +13 -0
  183. package/src/testing-js/vitest.js.map +1 -0
  184. package/template/README.md +13 -0
  185. package/template/_gitignore +2 -0
  186. package/template/package.json +2 -1
  187. package/template/src/pages/Home.svelte +4 -18
  188. package/.claude/skills/live-tokens-adjust-geometry/SKILL.md +0 -93
  189. package/.claude/skills/live-tokens-build-page/SKILL.md +0 -103
  190. package/.claude/skills/live-tokens-build-page/references/layout-sources.md +0 -48
  191. package/.claude/skills/live-tokens-generate-theme/SKILL.md +0 -156
  192. package/.claude/skills/live-tokens-generate-theme/references/mood-vocabulary.md +0 -43
  193. package/.claude/skills/live-tokens-generate-theme/references/named-themes.md +0 -18
  194. package/.claude/skills/live-tokens-generate-theme/references/style-vocabulary.md +0 -35
  195. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +0 -90
  196. package/bin/generate-theme.mjs +0 -260
package/bin/create.mjs CHANGED
@@ -78,7 +78,7 @@ export function runCreate({ targetDir, pkgRoot, force = false }) {
78
78
  export function formatCreateResult({ appName, targetDir }, targetArg) {
79
79
  return [
80
80
  ``,
81
- `Scaffolded ${appName} → ${targetDir}`,
81
+ `Scaffolded ${appName} in ${targetDir}`,
82
82
  ``,
83
83
  `Next steps:`,
84
84
  ` cd ${targetArg}`,
@@ -1,28 +1,33 @@
1
1
  // The registry as a query. Every component a project has, shipped or its own,
2
2
  // with the props each takes and the tokens each declares, and every theme token
3
- // grouped by family. Read from files through the same vocabulary the checkers
3
+ // grouped by scale. Read from files through the same vocabulary the checkers
4
4
  // use, so a skill or a script sees exactly what the checkers will hold it to.
5
5
 
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { relative } from 'node:path';
8
- import { CONTRACT_FAMILIES } from './tokenVocabulary.mjs';
8
+ import { CONTRACT_SCALES } from './tokenVocabulary.mjs';
9
9
 
10
- /** The runtime file's leading HTML comment, which is where a component says what it is for. */
10
+ /** The runtime file's leading HTML comment, which is where a component says what
11
+ it is for. A labelled line (`Use for:`, `Not for:`, `Emphasis:`) opens a line
12
+ of the description and every other line continues the one above it, so the
13
+ comment wraps in the source and still reads as its four lines here. */
11
14
  function descriptionOf(source) {
12
15
  const m = source.match(/^\s*<!--([\s\S]*?)-->/);
13
16
  if (!m) return '';
14
- return m[1]
15
- .split('\n')
16
- .map((line) => line.trim())
17
- .join(' ')
18
- .replace(/\s+/g, ' ')
19
- .trim()
20
- .replace(/^\S+\.svelte\s*[—–-]+\s*/, '');
17
+ const lines = [];
18
+ for (const raw of m[1].split('\n')) {
19
+ const line = raw.trim().replace(/\s+/g, ' ');
20
+ if (!line) continue;
21
+ if (lines.length && !/^[A-Z][A-Za-z ]{0,20}:/.test(line)) lines[lines.length - 1] += ` ${line}`;
22
+ else lines.push(line);
23
+ }
24
+ if (lines.length) lines[0] = lines[0].replace(/^\S+\.svelte\s*(?:[—–-]+|\.)\s*/, '');
25
+ return lines.join('\n');
21
26
  }
22
27
 
23
- function familyOf(name) {
28
+ function scaleOf(name) {
24
29
  const stem = name.replace(/^--/, '');
25
- const hit = CONTRACT_FAMILIES
30
+ const hit = CONTRACT_SCALES
26
31
  .filter((f) => stem === f || stem.startsWith(`${f}-`))
27
32
  .sort((a, b) => b.length - a.length)[0];
28
33
  return hit ?? stem.split('-')[0];
@@ -62,15 +67,15 @@ export function describeTokens(vocab, { root = process.cwd() } = {}) {
62
67
  if (!values.has(m[1])) values.set(m[1], m[2].trim());
63
68
  }
64
69
  }
65
- const byFamily = new Map();
70
+ const byScale = new Map();
66
71
  for (const name of vocab.themeTokens) {
67
- const family = familyOf(name);
68
- if (!byFamily.has(family)) byFamily.set(family, []);
69
- byFamily.get(family).push({ name, value: values.get(name) ?? '' });
72
+ const scale = scaleOf(name);
73
+ if (!byScale.has(scale)) byScale.set(scale, []);
74
+ byScale.get(scale).push({ name, value: values.get(name) ?? '' });
70
75
  }
71
76
  return {
72
77
  tokensCss: vocab.tokensCssPath ? relative(root, vocab.tokensCssPath) : null,
73
- families: [...byFamily].map(([family, tokens]) => ({ family, tokens })),
78
+ scales: [...byScale].map(([scale, tokens]) => ({ scale, tokens })),
74
79
  components: [...vocab.components.values()].map((c) => ({
75
80
  id: c.id,
76
81
  tokens: [...c.tokens].map(([name, value]) => ({ name, default: value })),
@@ -78,13 +83,15 @@ export function describeTokens(vocab, { root = process.cwd() } = {}) {
78
83
  };
79
84
  }
80
85
 
86
+ const describeLines = (c) => (c.description ? c.description.split('\n') : []);
87
+
81
88
  export function formatComponents(list, { id } = {}) {
82
89
  const lines = [];
83
90
  if (id) {
84
91
  const c = list.find((x) => x.id === id);
85
92
  if (!c) return `No component "${id}". Run \`live-tokens components\` for the list.`;
86
- lines.push(`${c.name} (${c.id}, ${c.origin}${c.registered ? '' : ', NOT registered'}) ${c.file}`);
87
- if (c.description) lines.push(` ${c.description}`);
93
+ lines.push(`${c.name} (${c.id}, ${c.origin}${c.registered ? '' : ', unregistered'}) ${c.file}`);
94
+ for (const line of describeLines(c)) lines.push(` ${line}`);
88
95
  if (c.props.length) {
89
96
  lines.push(' props:');
90
97
  for (const p of c.props) lines.push(` ${p.name}${p.values ? `: ${p.values.join(' | ')}` : p.type ? `: ${p.type}` : ''}`);
@@ -96,7 +103,7 @@ export function formatComponents(list, { id } = {}) {
96
103
  for (const c of list) {
97
104
  const variants = c.variants.length ? ` variants: ${c.variants.join(', ')}` : '';
98
105
  lines.push(`${c.id.padEnd(20)} ${c.origin.padEnd(8)} ${c.name}${c.registered ? '' : ' (NOT registered)'}${variants}`);
99
- if (c.description) lines.push(`${''.padEnd(29)} ${c.description}`);
106
+ for (const line of describeLines(c)) lines.push(` ${line}`);
100
107
  }
101
108
  lines.push('');
102
109
  const unregistered = list.filter((c) => !c.registered).length;
@@ -107,21 +114,21 @@ export function formatComponents(list, { id } = {}) {
107
114
  return lines.join('\n');
108
115
  }
109
116
 
110
- export function formatTokens(desc, { family } = {}) {
117
+ export function formatTokens(desc, { scale } = {}) {
111
118
  const lines = [];
112
- const families = family ? desc.families.filter((f) => f.family === family) : desc.families;
113
- if (family && families.length === 0) {
114
- return `No family "${family}". Families: ${desc.families.map((f) => f.family).join(', ')}.`;
119
+ const scales = scale ? desc.scales.filter((s) => s.scale === scale) : desc.scales;
120
+ if (scale && scales.length === 0) {
121
+ return `No token scale "${scale}". Scales: ${desc.scales.map((s) => s.scale).join(', ')}.`;
115
122
  }
116
- lines.push(`Theme tokens from ${desc.tokensCss ?? '(no tokens.css found)'}`);
117
- for (const f of families) {
123
+ lines.push(`Design tokens from ${desc.tokensCss ?? '(no tokens.css found)'}`);
124
+ for (const s of scales) {
118
125
  lines.push('');
119
- lines.push(`${f.family} (${f.tokens.length})`);
120
- for (const t of f.tokens) lines.push(` ${t.name}: ${t.value}`);
126
+ lines.push(`${s.scale} (${s.tokens.length})`);
127
+ for (const t of s.tokens) lines.push(` ${t.name}: ${t.value}`);
121
128
  }
122
- if (!family) {
129
+ if (!scale) {
123
130
  lines.push('');
124
- lines.push(`Component tokens: ${desc.components.reduce((n, c) => n + c.tokens.length, 0)} across ${desc.components.length} component(s). \`live-tokens components <id>\` lists one component's.`);
131
+ lines.push(`Semantic properties: ${desc.components.reduce((n, c) => n + c.tokens.length, 0)} across ${desc.components.length} component(s). \`live-tokens components <id>\` lists one component's.`);
125
132
  }
126
133
  return lines.join('\n');
127
134
  }
@@ -52,38 +52,65 @@ export function isExcluded(relPath, root) {
52
52
  * Unrecognised flags are returned in `rest` for the caller to handle.
53
53
  */
54
54
  export function parseCheckFlags(argv) {
55
- const opts = { off: [], warn: [], error: [], strict: false, json: false, rest: [] };
55
+ const opts = { off: [], warn: [], error: [], strict: false, json: false, tests: false, rest: [] };
56
56
  for (const arg of argv) {
57
57
  const m = arg.match(/^--(off|warn|error)=(.+)$/);
58
58
  if (m) {
59
59
  opts[m[1]].push(...m[2].split(',').map((s) => s.trim()).filter(Boolean));
60
60
  } else if (arg === '--strict') opts.strict = true;
61
61
  else if (arg === '--json') opts.json = true;
62
+ else if (arg === '--tests') opts.tests = true;
62
63
  else opts.rest.push(arg);
63
64
  }
64
65
  return opts;
65
66
  }
66
67
 
68
+ /** The same last-wins resolution `applySeverity` applies per finding, exposed
69
+ * standalone so coverage (which has no findings to attach a severity to, but
70
+ * still has to honor `--off`) can ask the same question. */
71
+ export function resolveRuleSeverity(ruleId, rules, opts = {}, config = {}) {
72
+ const configured = config.rules ?? {};
73
+ let severity = rules[ruleId] ?? 'error';
74
+ if (SEVERITIES.includes(configured[ruleId])) severity = configured[ruleId];
75
+ if (opts.off?.includes(ruleId)) severity = 'off';
76
+ if (opts.warn?.includes(ruleId)) severity = 'warn';
77
+ if (opts.error?.includes(ruleId)) severity = 'error';
78
+ if (opts.strict && severity === 'warn') severity = 'error';
79
+ return severity;
80
+ }
81
+
67
82
  /**
68
83
  * Resolve each finding's severity and drop the ones turned off.
69
84
  * `rules` maps rule id to its default severity.
70
85
  */
71
- export function applySeverity(findings, rules, opts = {}, config = {}) {
72
- const configured = config.rules ?? {};
73
- const resolve = (id) => {
74
- let severity = rules[id] ?? 'error';
75
- if (SEVERITIES.includes(configured[id])) severity = configured[id];
76
- if (opts.off?.includes(id)) severity = 'off';
77
- if (opts.warn?.includes(id)) severity = 'warn';
78
- if (opts.error?.includes(id)) severity = 'error';
79
- if (opts.strict && severity === 'warn') severity = 'error';
80
- return severity;
81
- };
86
+ export function applySeverity(findings, rules, opts = {}, config = {}, fixes = {}) {
82
87
  return findings
83
- .map((f) => ({ ...f, severity: resolve(f.rule) }))
88
+ .map((f) => ({
89
+ ...f,
90
+ severity: resolveRuleSeverity(f.rule, rules, opts, config),
91
+ ...(fixes[f.rule] ? { fix: fixes[f.rule] } : {}),
92
+ }))
84
93
  .filter((f) => f.severity !== 'off');
85
94
  }
86
95
 
96
+ /**
97
+ * A coverage entry for a rule resolved to severity 'off' becomes 'disabled',
98
+ * regardless of what actually happened: the plan requires `--off` to stay
99
+ * visible as disabled coverage rather than silently reading as a pass (an
100
+ * obligation that failed but was silenced is not the same as one that ran
101
+ * clean), and disabled coverage can never establish a complete pass.
102
+ */
103
+ export function applyCoverageSeverity(coverage, rules, opts = {}, config = {}) {
104
+ const out = {};
105
+ for (const [id, ruleMap] of Object.entries(coverage)) {
106
+ out[id] = {};
107
+ for (const [rule, entry] of Object.entries(ruleMap)) {
108
+ out[id][rule] = resolveRuleSeverity(rule, rules, opts, config) === 'off' ? { status: 'disabled' } : entry;
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
87
114
  export function countBySeverity(findings) {
88
115
  return {
89
116
  errors: findings.filter((f) => f.severity === 'error').length,
@@ -117,7 +144,11 @@ export function formatFindings(findings, { label, checked = 0 } = {}) {
117
144
  return lines.join('\n');
118
145
  }
119
146
 
120
- export function toJson(findings, { label, checked = 0 } = {}) {
147
+ export function toJson(findings, { label, checked = 0, coverage } = {}) {
121
148
  const { errors, warnings } = countBySeverity(findings);
122
- return JSON.stringify({ check: label, checked, errors, warnings, findings }, null, 2);
149
+ return JSON.stringify(
150
+ { check: label, checked, errors, warnings, findings, ...(coverage ? { coverage } : {}) },
151
+ null,
152
+ 2,
153
+ );
123
154
  }
@@ -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
+ }
@@ -120,7 +120,7 @@ const list = (items, max = 20) =>
120
120
 
121
121
  export function formatReport(r) {
122
122
  const out = [];
123
- out.push(`Project: ${r.project.pages} page file(s), ${r.project.components} component(s), ${r.project.themeTokens} theme tokens from ${r.project.tokensCss ?? '(no tokens.css)'}`);
123
+ out.push(`Project: ${r.project.pages} page file(s), ${r.project.components} component(s), ${r.project.themeTokens} design tokens from ${r.project.tokensCss ?? '(no tokens.css)'}`);
124
124
  if (r.migrations) {
125
125
  out.push('');
126
126
  out.push(`Migrations: ${r.migrations.status}${r.migrations.pending?.length ? ` (${list(r.migrations.pending)})` : ''}`);
@@ -131,11 +131,11 @@ export function formatReport(r) {
131
131
  const unread = r.components.filter((c) => c.unread.length);
132
132
  out.push(` tokens declared and read by their own CSS: ${r.components.reduce((n, c) => n + c.tokens - c.unread.length, 0)} of ${r.components.reduce((n, c) => n + c.tokens, 0)}`);
133
133
  for (const c of unread) out.push(` ${c.id}: ${c.unread.length} unread (${list(c.unread, 6)})`);
134
+ const undescribed = r.components.filter((c) => !c.described).map((c) => c.id);
135
+ if (undescribed.length) out.push(` no description comment: ${list(undescribed)}`);
134
136
  const custom = r.components.filter((c) => c.origin === 'custom');
135
137
  out.push(` custom: ${custom.length}${custom.length ? ` (${list(custom.map((c) => c.id))})` : ''}`);
136
138
  if (r.usage.customUnregistered.length) out.push(` not registered: ${list(r.usage.customUnregistered)}`);
137
- const undescribed = custom.filter((c) => !c.described).map((c) => c.id);
138
- if (undescribed.length) out.push(` no description comment: ${list(undescribed)}`);
139
139
 
140
140
  out.push('');
141
141
  out.push('Usage');
@@ -25,18 +25,18 @@ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
25
25
 
26
26
  const SHIPPED_COMPONENTS_DIR = 'src/system/components';
27
27
 
28
- /** Families whose names are governed by the token contract (see TOKENS.md). */
29
- export const CONTRACT_FAMILIES = [
28
+ /** Token scales whose names are governed by the token contract (see TOKENS.md). */
29
+ export const CONTRACT_SCALES = [
30
30
  'surface', 'text', 'border', 'color', 'space', 'radius', 'font', 'line-height',
31
31
  'letter-spacing', 'shadow', 'blur', 'icon-size', 'scrim', 'tint', 'columns',
32
32
  'heading', 'body', 'editorial', 'eyebrow', 'code', 'easing', 'duration', 'zoom',
33
33
  'gradient', 'stroke',
34
34
  ];
35
35
 
36
- /** True when `name` belongs to a contract-governed family, so a miss is a typo. */
36
+ /** True when `name` belongs to a contract-governed scale, so a miss is a typo. */
37
37
  export function isContractToken(name) {
38
38
  const stem = name.replace(/^--/, '');
39
- return CONTRACT_FAMILIES.some((f) => stem === f || stem.startsWith(`${f}-`));
39
+ return CONTRACT_SCALES.some((f) => stem === f || stem.startsWith(`${f}-`));
40
40
  }
41
41
 
42
42
  /** Every `--name:` declared anywhere in a stylesheet or style block. */
@@ -149,8 +149,8 @@ export function formatRouteResult(result, { check = false } = {}) {
149
149
  const { rewritten, pendingWrite, advisory } = result;
150
150
  if (!rewritten.length && !pendingWrite.length && !advisory.length) return '';
151
151
 
152
- const ref = (h) => ` ${h.file}:${h.line} ${h.kind} '${h.old}' → '${h.next}'`;
153
- const lines = ['Route references — /editor, /components, /docs moved to /live-tokens/* in 0.35.0:'];
152
+ const ref = (h) => ` ${h.file}:${h.line} ${h.kind} '${h.old}' to '${h.next}'`;
153
+ const lines = ['Route references: /editor, /components, /docs moved to /live-tokens/* in 0.35.0:'];
154
154
 
155
155
  if (rewritten.length) {
156
156
  lines.push(` ✓ Rewrote ${rewritten.length} reference(s):`);
@@ -160,7 +160,7 @@ export function formatRouteResult(result, { check = false } = {}) {
160
160
  lines.push(
161
161
  check
162
162
  ? ` Would rewrite ${pendingWrite.length} reference(s) with --write:`
163
- : ` ${pendingWrite.length} reference(s) can be rewritten — re-run with --write to apply:`,
163
+ : ` ${pendingWrite.length} reference(s) can be rewritten. Re-run with --write to apply:`,
164
164
  );
165
165
  pendingWrite.forEach((h) => lines.push(ref(h)));
166
166
  }
@@ -169,8 +169,8 @@ export function formatRouteResult(result, { check = false } = {}) {
169
169
  advisory.forEach((h) => {
170
170
  const why =
171
171
  h.reason === 'docs-never'
172
- ? `/docs is never auto-rewritten — update to '${h.next}' only if it points at the package guide`
173
- : `you declare or relocate '${h.old}' yourself — leave it if it's your route`;
172
+ ? `/docs is never auto-rewritten. Update to '${h.next}' only when it points at the package guide`
173
+ : `the project declares or relocates '${h.old}'. Leave it when it is the project's own route`;
174
174
  lines.push(` ${h.file}:${h.line} ${h.kind} '${h.old}' (${why})`);
175
175
  });
176
176
  }
package/bin/migrate.mjs CHANGED
@@ -127,13 +127,13 @@ export function formatMigrateDataResult(result) {
127
127
  // `sketch-presets/` → `sketch-styles/`. Told apart by the destination
128
128
  // name, since `LegacyRename` carries no reason of its own.
129
129
  const reason = basename(to) === 'sketch-styles' ? 'sketchstyle rename' : '0.48 layout';
130
- lines.push(` ${planned ? 'would move' : 'moved'} ${rel(from)} → ${rel(to)} (${reason})`);
130
+ lines.push(` ${planned ? 'would move' : 'moved'} ${rel(from)} to ${rel(to)} (${reason})`);
131
131
  }
132
132
  for (const p of result.upgradedThemes) {
133
133
  lines.push(` ${planned ? 'would carry' : 'carried'} ${rel(p)} by value (was a pre-v3 theme naming files)`);
134
134
  }
135
135
  if (result.production) {
136
- lines.push(` production theme → "${result.production.slug}" (${HOW_SAID[result.production.how]})`);
136
+ lines.push(` production theme: "${result.production.slug}" (${HOW_SAID[result.production.how]})`);
137
137
  }
138
138
  if (result.recoveredThemePath) {
139
139
  lines.push(` ${planned ? 'would write' : 'wrote'} ${rel(result.recoveredThemePath)}`);
@@ -148,13 +148,13 @@ export function formatMigrateDataResult(result) {
148
148
  lines.push(` ${planned ? 'would delete' : 'deleted'} ${rel(p)}: a copy of a saved theme`);
149
149
  }
150
150
  for (const p of result.keptUserFiles) {
151
- lines.push(` kept ${rel(p)}: it matches no theme, so it is yours`);
151
+ lines.push(` kept ${rel(p)}: it matches no theme, so it stays`);
152
152
  }
153
153
  for (const note of result.droppedRefs) {
154
154
  lines.push(` ${note}`);
155
155
  }
156
156
  for (const p of result.notThemes) {
157
- lines.push(` left ${rel(p)} alone: it is a colors-and-type file, not a theme`);
157
+ lines.push(` left ${rel(p)} alone: it is a colors-and-type file`);
158
158
  }
159
159
  for (const p of result.shadowedDefaults) {
160
160
  lines.push(` kept ${rel(p)}: it shadows the default the package ships, which has moved on`);
@@ -0,0 +1,176 @@
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}" at ${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 shipped defaults';
153
+ const kept = result.dryRun ? 'Would save' : 'Saved';
154
+ if (edited.length > 0) {
155
+ lines.push(`${kept} the buffer: ${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 buffer. ${kept} 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
+ `\nLoaded "${result.slug}" (previously open: "${result.previousActive}"). ` +
165
+ `Adopt in the editor ships it to tokens.generated.css.`,
166
+ );
167
+ } else if (result.dryRun) {
168
+ lines.push(`\nDry run: nothing written under ${relative(root, dirname(result.themePath))}.`);
169
+ } else {
170
+ lines.push(
171
+ `\nNot loaded (--no-activate). The buffer is still open, so the next ` +
172
+ `save-theme starts from the same theme.`,
173
+ );
174
+ }
175
+ return lines.join('\n');
176
+ }