@motion-proto/live-tokens 0.78.0 → 0.80.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 (172) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +49 -38
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +23 -28
  3. package/.claude/skills/live-tokens-create-component/references/contract-tests.md +61 -0
  4. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +2 -2
  5. package/.claude/skills/live-tokens-create-component/references/token-naming.md +6 -15
  6. package/.claude/skills/live-tokens-create-page/SKILL.md +6 -4
  7. package/.claude/skills/live-tokens-create-theme/references/design-directions.md +1 -1
  8. package/.claude/skills/live-tokens-pick-component/SKILL.md +3 -3
  9. package/.claude/skills/live-tokens-set-colors/references/color-anchors.md +1 -1
  10. package/.claude/skills/live-tokens-set-geometry/SKILL.md +1 -1
  11. package/.claude/skills/live-tokens-set-geometry/references/geometry-anchors.md +1 -1
  12. package/CHANGELOG.md +271 -0
  13. package/README.md +10 -16
  14. package/bin/check-component.mjs +154 -551
  15. package/bin/check-page.mjs +57 -541
  16. package/bin/cli.mjs +93 -16
  17. package/bin/contractRunner.mjs +37 -91
  18. package/bin/lib/buildChecks.mjs +32 -0
  19. package/bin/lib/catalogue.mjs +161 -34
  20. package/bin/lib/componentSource.mjs +152 -0
  21. package/bin/lib/cssValues.mjs +9 -0
  22. package/bin/lib/dataDir.mjs +126 -0
  23. package/bin/lib/findings.mjs +103 -12
  24. package/bin/lib/fixers.mjs +64 -0
  25. package/bin/lib/geometry.mjs +92 -0
  26. package/bin/lib/pageSource.mjs +230 -0
  27. package/bin/lib/report.mjs +57 -59
  28. package/bin/lib/tokenVocabulary.mjs +104 -34
  29. package/bin/migrate-build-script.mjs +66 -0
  30. package/bin/migrate.mjs +5 -0
  31. package/bin/rules/componentStructure.mjs +344 -0
  32. package/bin/rules/componentUse.mjs +313 -0
  33. package/bin/rules/importsAndRoutes.mjs +136 -0
  34. package/bin/rules/testRuns.mjs +122 -0
  35. package/bin/rules/tokens.mjs +426 -0
  36. package/bin/setup-claude.mjs +1 -2
  37. package/dist-plugin/{chunk-PDNL4NC5.js → chunk-D4WRIKEZ.js} +7 -2
  38. package/dist-plugin/{chunk-SWXRVZKT.js → chunk-REBHE3ZM.js} +414 -1
  39. package/dist-plugin/index.cjs +470 -19
  40. package/dist-plugin/index.d.cts +1 -0
  41. package/dist-plugin/index.d.ts +1 -0
  42. package/dist-plugin/index.js +87 -55
  43. package/dist-plugin/migrateData/index.cjs +422 -4
  44. package/dist-plugin/migrateData/index.js +2 -2
  45. package/dist-plugin/setColors/index.cjs +414 -1
  46. package/dist-plugin/setColors/index.d.cts +1 -1
  47. package/dist-plugin/setColors/index.d.ts +1 -1
  48. package/dist-plugin/setColors/index.js +1 -1
  49. package/dist-plugin/setGeometry/index.cjs +423 -13
  50. package/dist-plugin/setGeometry/index.d.cts +3 -3
  51. package/dist-plugin/setGeometry/index.d.ts +3 -3
  52. package/dist-plugin/setGeometry/index.js +10 -13
  53. package/dist-plugin/setType/index.d.cts +1 -1
  54. package/dist-plugin/setType/index.d.ts +1 -1
  55. package/dist-plugin/{themeTypes-BxRtuN5V.d.cts → themeTypes-B8_Idrp4.d.cts} +2 -2
  56. package/dist-plugin/{themeTypes-BxRtuN5V.d.ts → themeTypes-B8_Idrp4.d.ts} +2 -2
  57. package/package.json +2 -2
  58. package/src/editor/component-editor/CalloutEditor.svelte +2 -2
  59. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +14 -15
  60. package/src/editor/component-editor/CornerBadgeEditor.svelte +14 -14
  61. package/src/editor/component-editor/DialogEditor.svelte +5 -5
  62. package/src/editor/component-editor/InlineEditActionsEditor.svelte +2 -2
  63. package/src/editor/component-editor/RadioButtonEditor.svelte +21 -21
  64. package/src/editor/component-editor/SectionDividerEditor.svelte +7 -7
  65. package/src/editor/component-editor/SegmentedControlEditor.svelte +5 -5
  66. package/src/editor/component-editor/SideNavigationEditor.svelte +25 -25
  67. package/src/editor/component-editor/TabBarEditor.svelte +6 -6
  68. package/src/editor/component-editor/TableEditor.svelte +6 -6
  69. package/src/editor/component-editor/ToggleEditor.svelte +2 -2
  70. package/src/editor/component-editor/index.ts +3 -0
  71. package/src/editor/component-editor/registry.ts +57 -1
  72. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +14 -14
  73. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +3 -0
  74. package/src/editor/component-editor/scaffolding/types.ts +15 -0
  75. package/src/editor/core/components/adjustAliases.ts +4 -4
  76. package/src/editor/core/components/aliasKinds.ts +20 -19
  77. package/src/editor/core/sketch/sketchLayer.ts +3 -3
  78. package/src/editor/core/themes/migrateComponentConfig.ts +14 -6
  79. package/src/editor/core/themes/migrations/2026-09-13-badge-brand.ts +42 -0
  80. package/src/editor/core/themes/migrations/2026-09-13-collapsiblesection-open.ts +33 -0
  81. package/src/editor/core/themes/migrations/2026-09-13-cornerbadge-prefix.ts +70 -0
  82. package/src/editor/core/themes/migrations/2026-09-13-hairline.ts +92 -0
  83. package/src/editor/core/themes/migrations/2026-09-13-indicator.ts +54 -0
  84. package/src/editor/core/themes/migrations/2026-09-13-sectiondivider-surface.ts +36 -0
  85. package/src/editor/core/themes/migrations/2026-09-13-selected-state.ts +124 -0
  86. package/src/editor/core/themes/migrations/2026-09-13-toggle-label.ts +31 -0
  87. package/src/editor/core/themes/migrations/index.ts +16 -0
  88. package/src/editor/core/themes/themeService.ts +3 -3
  89. package/src/editor/core/themes/themeTypes.ts +13 -15
  90. package/src/editor/docs/Docs.svelte +1 -1
  91. package/src/editor/docs/content/light-and-dark.md +3 -3
  92. package/src/editor/docs/content.generated.ts +1 -1
  93. package/src/editor/index.ts +1 -0
  94. package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
  95. package/src/editor/skill-atlas/SkillAtlas.svelte +4 -4
  96. package/src/editor/skill-atlas/TreeNodeCard.svelte +4 -4
  97. package/src/editor/skill-atlas/skillSources.generated.ts +11 -14
  98. package/src/editor/skill-atlas/skillTrees.ts +1 -3
  99. package/src/editor/skill-atlas/trees/check-compliance.ts +37 -149
  100. package/src/editor/skill-atlas/trees/create-component.ts +62 -64
  101. package/src/editor/skill-atlas/trees/create-page.ts +38 -17
  102. package/src/editor/skill-atlas/trees/pick-component.ts +3 -3
  103. package/src/editor/skill-atlas/trees/set-geometry.ts +1 -1
  104. package/src/editor/ui/UIPaletteSelector.svelte +11 -15
  105. package/src/editor/ui/variantScales.ts +8 -8
  106. package/src/live-tokens/data/themes/autumn.json +188 -188
  107. package/src/live-tokens/data/themes/halloween.json +188 -188
  108. package/src/live-tokens/data/themes/midnight-study.json +245 -245
  109. package/src/live-tokens/data/themes/ocean.json +188 -188
  110. package/src/live-tokens/data/themes/royal-velvet.json +188 -188
  111. package/src/live-tokens/data/themes/sketchy.json +188 -188
  112. package/src/live-tokens/data/themes/spring-meadow.json +188 -188
  113. package/src/live-tokens/data/themes/sunset.json +188 -188
  114. package/src/system/components/Badge.svelte +27 -23
  115. package/src/system/components/Button.svelte +13 -7
  116. package/src/system/components/Callout.svelte +16 -11
  117. package/src/system/components/Card.svelte +22 -15
  118. package/src/system/components/CodeSnippet.svelte +10 -6
  119. package/src/system/components/CollapsibleSection.svelte +81 -76
  120. package/src/system/components/CornerBadge.svelte +78 -72
  121. package/src/system/components/Dialog.svelte +21 -18
  122. package/src/system/components/IconButton.svelte +13 -9
  123. package/src/system/components/Image.svelte +14 -8
  124. package/src/system/components/ImageLightbox.svelte +10 -6
  125. package/src/system/components/InlineEditActions.svelte +17 -14
  126. package/src/system/components/Input.svelte +13 -7
  127. package/src/system/components/MenuSelect.svelte +13 -7
  128. package/src/system/components/Notification.svelte +17 -11
  129. package/src/system/components/Panel.svelte +13 -6
  130. package/src/system/components/ProgressBar.svelte +10 -5
  131. package/src/system/components/RadioButton.svelte +33 -30
  132. package/src/system/components/SectionDivider.svelte +28 -21
  133. package/src/system/components/SegmentedControl.svelte +27 -23
  134. package/src/system/components/SideNavigation.svelte +175 -171
  135. package/src/system/components/Slider.svelte +11 -7
  136. package/src/system/components/TabBar.svelte +53 -49
  137. package/src/system/components/Table.svelte +20 -15
  138. package/src/system/components/Toggle.svelte +14 -10
  139. package/src/system/components/Tooltip.svelte +10 -6
  140. package/src/system/styles/CONVENTIONS.md +3 -4
  141. package/src/testing-js/chunk-3UKGXCDL.js +48 -0
  142. package/src/testing-js/chunk-3UKGXCDL.js.map +1 -0
  143. package/src/testing-js/{chunk-FAFOAWYL.js → chunk-Q3YIAAG3.js} +17 -3
  144. package/src/testing-js/chunk-Q3YIAAG3.js.map +1 -0
  145. package/src/testing-js/{chunk-GNIUPIU2.js → chunk-U7OJE5DU.js} +18 -18
  146. package/src/testing-js/chunk-U7OJE5DU.js.map +1 -0
  147. package/src/testing-js/{chunk-4JQX6WWL.js → chunk-ZMZQZ33J.js} +474 -166
  148. package/src/testing-js/chunk-ZMZQZ33J.js.map +1 -0
  149. package/src/testing-js/component-behavior.contract.js +155 -0
  150. package/src/testing-js/component-behavior.contract.js.map +1 -0
  151. package/src/testing-js/component-editor.contract.js +6 -4
  152. package/src/testing-js/component-editor.contract.js.map +1 -1
  153. package/src/testing-js/component-render.contract.js +14 -10
  154. package/src/testing-js/component-render.contract.js.map +1 -1
  155. package/src/testing-js/index.d.ts +44 -4
  156. package/src/testing-js/index.js +13 -7
  157. package/src/testing-js/index.js.map +1 -1
  158. package/src/testing-js/page-compliance.contract.js +48 -10
  159. package/src/testing-js/page-compliance.contract.js.map +1 -1
  160. package/src/testing-js/registry.contract.js +5 -3
  161. package/src/testing-js/registry.contract.js.map +1 -1
  162. package/src/testing-js/{vitest-C-wNWcoA.d.ts → vitest-BMLIbDs2.d.ts} +8 -2
  163. package/src/testing-js/vitest.d.ts +1 -1
  164. package/src/testing-js/vitest.js +2 -1
  165. package/template/package.json +1 -2
  166. package/template/vite.config.ts +3 -2
  167. package/.claude/skills/live-tokens-create-component/SKILL copy.md +0 -196
  168. package/.claude/skills/live-tokens-fix-findings/SKILL.md +0 -105
  169. package/src/editor/skill-atlas/trees/fix-findings.ts +0 -504
  170. package/src/testing-js/chunk-4JQX6WWL.js.map +0 -1
  171. package/src/testing-js/chunk-FAFOAWYL.js.map +0 -1
  172. package/src/testing-js/chunk-GNIUPIU2.js.map +0 -1
package/bin/cli.mjs CHANGED
@@ -12,27 +12,31 @@
12
12
  // set-geometry <ops> Apply radius/padding/gap/border-width ops to the open buffer.
13
13
  // set-type <pairing> Bind Google Fonts families to the theme's font stacks.
14
14
  // save-theme <name> Compose the live state into themes/<slug>.json and open it.
15
- // migrate [...] Reconcile tokens.css, the data tree, and route references.
15
+ // migrate [...] Reconcile tokens.css, the data tree, route references, and the build script.
16
16
 
17
17
  import { writeSync } from 'node:fs';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import process from 'node:process';
21
- import { COMPONENT_RULES, COMPONENT_RULE_FIX, checkComponent, discoverComponents, formatReport } from './check-component.mjs';
21
+ import { COMPONENT_RULES, checkComponent, discoverComponents, formatReport } from './check-component.mjs';
22
22
  import { PAGE_RULES, checkPages, discoverPages } from './check-page.mjs';
23
23
  import { resolvePageTestTargets } from './lib/pageRoutes.mjs';
24
24
  import { describeComponents, describeTokens, formatComponents, formatTokens } from './lib/catalogue.mjs';
25
+ import { applyFixes } from './lib/fixers.mjs';
25
26
  import { buildReport, formatReport as formatProjectReport } from './lib/report.mjs';
26
27
  import { loadVocabulary } from './lib/tokenVocabulary.mjs';
27
28
  import {
28
29
  applyCoverageSeverity,
29
30
  applySeverity,
30
31
  countBySeverity,
32
+ dedupeAliasFindings,
31
33
  formatFindings,
32
34
  parseCheckFlags,
33
35
  readChecksConfig,
36
+ resolveRuleSeverity,
34
37
  toJson,
35
38
  } from './lib/findings.mjs';
39
+ import { checkTokensCssMigrations } from './rules/tokens.mjs';
36
40
  import {
37
41
  runMigrate,
38
42
  formatMigrateResult,
@@ -40,6 +44,7 @@ import {
40
44
  formatMigrateDataResult,
41
45
  } from './migrate.mjs';
42
46
  import { runMigrateRoutes, formatRouteResult } from './migrate-routes.mjs';
47
+ import { runMigrateBuildScript, formatBuildScriptResult } from './migrate-build-script.mjs';
43
48
  import { runCreate, formatCreateResult } from './create.mjs';
44
49
  import { runSetupClaude, formatSetupResult } from './setup-claude.mjs';
45
50
  import { runSetColors, formatSetColorsResult } from './set-colors.mjs';
@@ -98,6 +103,16 @@ check-component and check-page also accept:
98
103
  (or set "checks": { "rules": {...} } in
99
104
  live-tokens.config.json; "checks": { "exclude":
100
105
  [...] } drops paths from discovery entirely)
106
+ --no-fix Report every finding and edit nothing, for a
107
+ build or CI. Without it, both checkers first
108
+ apply every additive tokens.css migration and
109
+ every finding whose repair is auto (a
110
+ deep import into a component, a raw dimension
111
+ with one nearest design token, a shipped
112
+ component's size prop, a semantic property
113
+ overridden in place), re-check, and list each
114
+ fix beside what remains. --tests runs after
115
+ the fixes land.
101
116
  set-colors <base-colors.json> [--dry-run]
102
117
  Build the theme's whole color identity from 10
103
118
  OKLCH base colors (see the live-tokens-set-colors
@@ -139,7 +154,8 @@ check-component and check-page also accept:
139
154
  without writing.
140
155
  migrate [--check] [--write] [--tokens <path>]
141
156
  Reconcile the project with the installed package:
142
- applies additive tokens.css migrations, moves a
157
+ applies every tokens.css migration, breaking
158
+ ones included, moves a
143
159
  pre-0.48 data tree onto the current directory
144
160
  names, heals what the retired pointer files named,
145
161
  and reports source references to the
@@ -148,6 +164,9 @@ check-component and check-page also accept:
148
164
  unambiguous route references (never /docs). --check
149
165
  prints both plans without writing (exit 1 when
150
166
  either is pending; route findings are advisory).
167
+ Also removes the check:design script live-tokens
168
+ added to the build, since vite build now runs the
169
+ design checks through themeFileApi.
151
170
  `;
152
171
 
153
172
  // A large body written through console.log is cut at the pipe buffer when the
@@ -194,14 +213,14 @@ if (command === 'create') {
194
213
  }
195
214
  }
196
215
 
197
- function reportChecks(label, findings, checked, rules, opts, { coverage, hardFailure, fixes } = {}) {
216
+ function reportChecks(label, findings, checked, rules, opts, { coverage, hardFailure, exclude, fix } = {}) {
198
217
  const checksConfig = readChecksConfig(process.cwd());
199
- const resolved = applySeverity(findings, rules, opts, checksConfig, fixes ?? {});
218
+ const resolved = applySeverity(findings, rules, opts, checksConfig, { exclude });
200
219
  const resolvedCoverage = coverage ? applyCoverageSeverity(coverage, rules, opts, checksConfig) : coverage;
201
220
  console.log(
202
221
  opts.json
203
- ? toJson(resolved, { label, checked, coverage: resolvedCoverage })
204
- : formatFindings(resolved, { label, checked }),
222
+ ? toJson(resolved, { label, checked, coverage: resolvedCoverage, fix })
223
+ : [fix ? formatFixes(fix) : '', formatFindings(resolved, { label, checked })].filter(Boolean).join('\n'),
205
224
  );
206
225
  // Decision 2: a missing tool or a setup failure under --tests is an error
207
226
  // even if a project silenced its rule id, since silencing it would read as
@@ -209,6 +228,41 @@ function reportChecks(label, findings, checked, rules, opts, { coverage, hardFai
209
228
  process.exit(countBySeverity(resolved).errors === 0 && !hardFailure ? 0 : 1);
210
229
  }
211
230
 
231
+ function shiftNote(f) {
232
+ if (f.rule !== 'dimension-literal') return '';
233
+ const shifts = f.details.literals.map((l) => l.candidates[0].shift).map((n) => `${n > 0 ? '+' : ''}${n}px`);
234
+ return ` shift ${shifts.join(', ')}`;
235
+ }
236
+
237
+ async function migrateTokensCss(rules, opts) {
238
+ const root = process.cwd();
239
+ const config = readChecksConfig(root);
240
+ const apply = !opts.noFix && resolveRuleSeverity('tokens-migration', rules, opts, config) !== 'off';
241
+ const { applied, findings } = await checkTokensCssMigrations({ root, apply });
242
+ return { applied: applySeverity(applied, rules, opts, config), findings };
243
+ }
244
+
245
+ /** Resolves severities and exclusions first, so a silenced rule or an excluded
246
+ * file is never edited. */
247
+ function applyStaticFixes(findings, rules, opts, { exclude } = {}) {
248
+ const root = process.cwd();
249
+ return applyFixes(applySeverity(findings, rules, opts, readChecksConfig(root), { exclude }), root);
250
+ }
251
+
252
+ function formatFixes({ applied, skipped }) {
253
+ const lines = [];
254
+ for (const f of applied) {
255
+ lines.push(
256
+ f.details.patch
257
+ ? ` fixed ${f.file}:${f.line} ${f.details.patch.from} → ${f.details.patch.to || '(removed)'} [${f.rule}]${shiftNote(f)}`
258
+ : ` fixed ${f.file} ${f.details.migrations.map((m) => m.id).join(', ')} [${f.rule}]`,
259
+ );
260
+ }
261
+ for (const f of skipped) lines.push(` skipped ${f.file}:${f.line} ${f.details.patch.from} [${f.rule}]`);
262
+ if (lines.length === 0) return '';
263
+ return [`${applied.length} fix(es) applied, ${skipped.length} skipped:`, ...lines, ''].join('\n');
264
+ }
265
+
212
266
  if (command === 'components') {
213
267
  const opts = parseCheckFlags(rest);
214
268
  const list = describeComponents(loadVocabulary());
@@ -257,21 +311,28 @@ if (command === 'check-component') {
257
311
  console.log('✓ check-component: no component authored under src/system/components yet.');
258
312
  process.exit(0);
259
313
  }
260
- const results = ids.map((id) => [id, checkComponent(id)]);
261
- if (!opts.tests && ids.length === 1 && !opts.json && !opts.strict && opts.off.length + opts.warn.length + opts.error.length === 0) {
314
+ const migration = await migrateTokensCss(COMPONENT_RULES, opts);
315
+ let results = ids.map((id) => [id, checkComponent(id)]);
316
+ const label = ids.length === 1 ? `check-component ${ids[0]}${opts.tests ? ' --tests' : ''}` : `check-component${opts.tests ? ' --tests' : ''}`;
317
+ let fix;
318
+ if (!opts.noFix) {
319
+ fix = applyStaticFixes(results.flatMap(([, r]) => r.findings), COMPONENT_RULES, opts);
320
+ if (fix.applied.length > 0) results = ids.map((id) => [id, checkComponent(id)]);
321
+ fix.applied.unshift(...migration.applied);
322
+ }
323
+ if (!opts.tests && migration.findings.length === 0 && ids.length === 1 && !opts.json && !opts.strict && opts.off.length + opts.warn.length + opts.error.length === 0) {
262
324
  const [id, result] = results[0];
263
- console.log(formatReport(id, result));
325
+ console.log([fix ? formatFixes(fix) : '', formatReport(id, result)].filter(Boolean).join('\n'));
264
326
  process.exit(result.errors.length === 0 ? 0 : 1);
265
327
  }
266
- const label = ids.length === 1 ? `check-component ${ids[0]}${opts.tests ? ' --tests' : ''}` : `check-component${opts.tests ? ' --tests' : ''}`;
267
328
  if (!opts.tests) {
268
- reportChecks(label, results.flatMap(([, r]) => r.findings), ids.length, COMPONENT_RULES, opts, { fixes: COMPONENT_RULE_FIX });
329
+ reportChecks(label, [...migration.findings, ...results.flatMap(([, r]) => r.findings)], ids.length, COMPONENT_RULES, opts, { fix });
269
330
  }
270
331
  const { hasHardFailure, runContractTests } = await import('./contractRunner.mjs');
271
332
  const testOutcome = await runContractTests(opts.rest[0], { root: process.cwd() });
272
- const findings = [...results.flatMap(([, r]) => r.findings), ...testOutcome.findings];
333
+ const findings = dedupeAliasFindings([...migration.findings, ...results.flatMap(([, r]) => r.findings), ...testOutcome.findings]);
273
334
  reportChecks(label, findings, Math.max(ids.length, 1), COMPONENT_RULES, opts, {
274
- fixes: COMPONENT_RULE_FIX,
335
+ fix,
275
336
  coverage: testOutcome.coverage,
276
337
  hardFailure: hasHardFailure(testOutcome.findings),
277
338
  });
@@ -280,9 +341,17 @@ if (command === 'check-component') {
280
341
  if (command === 'check-page') {
281
342
  const opts = parseCheckFlags(rest);
282
343
  const targets = opts.rest.length > 0 ? opts.rest : discoverPages(process.cwd());
283
- const { findings, checked } = checkPages(targets, { root: process.cwd() });
344
+ const migration = await migrateTokensCss(PAGE_RULES, opts);
345
+ let { findings, checked } = checkPages(targets, { root: process.cwd() });
346
+ let fix;
347
+ if (!opts.noFix) {
348
+ fix = applyStaticFixes(findings, PAGE_RULES, opts, { exclude: true });
349
+ if (fix.applied.length > 0) ({ findings, checked } = checkPages(targets, { root: process.cwd() }));
350
+ fix.applied.unshift(...migration.applied);
351
+ }
352
+ findings = [...migration.findings, ...findings];
284
353
  if (!opts.tests) {
285
- reportChecks('check-page', findings, checked, PAGE_RULES, opts);
354
+ reportChecks('check-page', findings, checked, PAGE_RULES, opts, { exclude: true, fix });
286
355
  }
287
356
  const { hasHardFailure, runPageTests } = await import('./contractRunner.mjs');
288
357
  const pageTargets = resolvePageTestTargets(opts.rest, process.cwd());
@@ -290,6 +359,8 @@ if (command === 'check-page') {
290
359
  const label = 'check-page --tests';
291
360
  const allFindings = [...findings, ...testOutcome.findings];
292
361
  reportChecks(label, allFindings, Math.max(checked, pageTargets.length), PAGE_RULES, opts, {
362
+ exclude: true,
363
+ fix,
293
364
  coverage: testOutcome.coverage,
294
365
  hardFailure: hasHardFailure(testOutcome.findings),
295
366
  });
@@ -412,6 +483,12 @@ if (command === 'migrate') {
412
483
  const routeOut = formatRouteResult(routes, { check });
413
484
  if (routeOut) console.log('\n' + routeOut);
414
485
 
486
+ // Build-script pass: removes the check:design step that themeFileApi now
487
+ // runs during vite build. Advisory under --check, like the data heal.
488
+ const buildScript = runMigrateBuildScript({ root: process.cwd(), apply: !check });
489
+ const buildScriptOut = formatBuildScriptResult(buildScript);
490
+ if (buildScriptOut) console.log('\n' + buildScriptOut);
491
+
415
492
  // Route findings are advisory; token migrations and the data heal gate the
416
493
  // exit code.
417
494
  if (result.status === 'no-path') process.exit(1);
@@ -28,6 +28,7 @@ import { tmpdir } from 'node:os';
28
28
  import { basename, dirname, join, relative, resolve } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { discoverComponents, resolveComponentPaths } from './check-component.mjs';
31
+ import { resolveSourceDataDir, scrapeSettingsField, settingsFilePath } from './lib/dataDir.mjs';
31
32
  import { lineOf } from './lib/findings.mjs';
32
33
  import { settingsPageViewports } from './lib/pageRoutes.mjs';
33
34
 
@@ -74,6 +75,7 @@ const HARD_FAILURE_RULES = new Set(['tests-not-installed', 'tests-setup', 'tests
74
75
  * coverage against. */
75
76
  const ALL_CONTRACT_RULES = [
76
77
  'contract-registry',
78
+ 'contract-behavior',
77
79
  'contract-listed',
78
80
  'contract-alias',
79
81
  'contract-states',
@@ -183,100 +185,10 @@ function resolveTestingEntry(name) {
183
185
  );
184
186
  }
185
187
 
186
- function settingsFilePath(root) {
187
- for (const name of ['live-tokens.testing.ts', 'live-tokens.testing.mts', 'live-tokens.testing.js', 'live-tokens.testing.mjs']) {
188
- const path = join(root, name);
189
- if (existsSync(path)) return path;
190
- }
191
- return null;
192
- }
193
-
194
- /**
195
- * A generated config's own extensionless relative imports (Vite/Playwright's
196
- * loaders resolve those, matching how this repo's own `vite.config.ts` and
197
- * `live-tokens.testing.ts` are written) only get that treatment for a
198
- * *static* import. A dynamic `import()` of the same path, called after a Vite
199
- * config finishes loading, runs through plain Node resolution instead and
200
- * fails on the same files: verified against `vitest.contract.config.ts` with
201
- * both a `file://` URL and a plain absolute path as the dynamic specifier,
202
- * identical `ERR_MODULE_NOT_FOUND` both times. So neither `settings.viteConfig`
203
- * nor `settings.dataDir` (the resolved values `resolveTestingConfig` computes)
204
- * has a reader here on purpose — reading either would mean importing the
205
- * settings file dynamically first, which reintroduces exactly this failure.
206
- * Regexing the settings file's *source text* for a field, imprecise as that
207
- * is, is what stays inside the static-import constraint: a wrong guess still
208
- * fails loudly (a bad `viteConfig` throws on its own static import in the
209
- * generated file; a bad `dataDir` throws "no data directory at ..." below),
210
- * never as a silent pass. A settings-level `dataDir` this cannot see at all
211
- * is worse than one resolved this imprecisely, since a project that names its
212
- * data directory only in `live-tokens.testing.ts` would otherwise have its
213
- * contracts checked against whatever happens to sit at the default path.
214
- */
215
- function stripComments(text) {
216
- // Block comments first: a `//` inside one (`/* // note */`) must not seed a
217
- // second, overlapping strip.
218
- return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
219
- }
220
-
221
- /** Measured across eleven settings-file shapes: a commented-out `dataDir:`
222
- * above the real one won the regex, because the regex only sees text
223
- * position, never comment syntax, and a comment naming the field with
224
- * nothing else present invented a setting out of prose. Comments are
225
- * stripped before the field ever gets a chance to match. A field present in
226
- * live code as a template literal, a computed value, or an import throws
227
- * instead of falling through to a default silently: the two guesses this
228
- * feeds, `viteConfig` and `dataDir`, can each resolve to a real path that
229
- * simply names the wrong tree, which then reads as a clean run. */
230
- function scrapeSettingsField(settingsPath, fieldName) {
231
- if (!settingsPath) return null;
232
- let text;
233
- try {
234
- text = readFileSync(settingsPath, 'utf8');
235
- } catch {
236
- return null;
237
- }
238
- const live = stripComments(text);
239
- const literal = new RegExp(`\\b${fieldName}\\s*:\\s*['"]([^'"]+)['"]`).exec(live);
240
- if (literal) return literal[1];
241
- if (new RegExp(`\\b${fieldName}\\s*:`).test(live)) {
242
- throw new Error(
243
- `"${fieldName}" in ${settingsPath} is set to something other than a plain string literal, ` +
244
- `so --tests cannot read it statically. Use a literal string, or remove the key to fall back to the default.`,
245
- );
246
- }
247
- return null;
248
- }
249
-
250
188
  function guessViteConfigPath(root, settingsPath) {
251
189
  return resolve(root, scrapeSettingsField(settingsPath, 'viteConfig') ?? 'vite.config.ts');
252
190
  }
253
191
 
254
- /**
255
- * `dataDir` as the plugin resolves it: a `dataDir` field scraped from the
256
- * settings file (see above), else `live-tokens.config.json`'s own key, else
257
- * the default. Mirrors `resolveTestingConfig`'s own `configuredDataDir`
258
- * fallback (`src/testing/config.ts`) rather than importing it: that module
259
- * compiles into `src/testing-js`, which does not exist until `build:testing`
260
- * runs, and importing the *source* `.ts` module to reach it hits the same
261
- * extensionless-import failure documented above — measured with
262
- * `src/testing-js` moved aside, `resolveTestingEntry`'s `.ts` fallback threw
263
- * exactly that trying to load `vitest.ts`. Duplicating this small a resolver
264
- * has precedent in this file already, at `SESSION_FILES`.
265
- */
266
- export function resolveSourceDataDir(root, settingsPath) {
267
- const scraped = scrapeSettingsField(settingsPath, 'dataDir');
268
- if (scraped) return resolve(root, scraped);
269
- try {
270
- const parsed = JSON.parse(readFileSync(join(root, 'live-tokens.config.json'), 'utf8'));
271
- if (parsed && typeof parsed === 'object' && typeof parsed.dataDir === 'string') {
272
- return resolve(root, parsed.dataDir);
273
- }
274
- } catch {
275
- // Missing or unparseable reads as absent, matching the plugin's own resolver.
276
- }
277
- return resolve(root, 'src/live-tokens/data');
278
- }
279
-
280
192
  // ─── data isolation ─────────────────────────────────────────────────────────
281
193
 
282
194
  function copyIsolatedDataDir(sourceDataDir) {
@@ -423,6 +335,7 @@ const settings = resolveTestingConfig(${settingsExpr}, ${JSON.stringify(root)});
423
335
 
424
336
  export default createVitestConfig(viteConfigModule.default ?? viteConfigModule, {
425
337
  registrySetup: settings.registrySetup,
338
+ contractsModule: settings.contractsModule,
426
339
  });
427
340
  `,
428
341
  );
@@ -945,6 +858,12 @@ export function mapRegistryViolation(root, sourceDataDir, componentId, text) {
945
858
  return { file: relative(root, target), line: findTokenLine(target, token) };
946
859
  }
947
860
 
861
+ /** `component-behavior.contract.ts` shares the same `describe(id) > it(case)`
862
+ * shape `registry.contract.ts` uses (see `structuralRule`'s Playwright
863
+ * analogue), so the file name — not the describe titles — is what tells the
864
+ * two suites' assertions apart. */
865
+ const BEHAVIOR_FILE_PREFIX = 'component-behavior.contract';
866
+
948
867
  /** Mirrors the Playwright side's zero-collection check: a file that failed
949
868
  * before it ran a single assertion (`assertionResults` empty, `status`
950
869
  * 'failed') carries its own `message` — measured against a bad
@@ -979,16 +898,43 @@ export function mapVitestResults(report, { root, sourceDataDir, stderr } = {}) {
979
898
  const coverage = {};
980
899
  for (const file of files) {
981
900
  if (collectionFailures.includes(file)) continue;
901
+ const isBehaviorFile = basename(file.name ?? '').startsWith(BEHAVIOR_FILE_PREFIX);
902
+ const passRule = isBehaviorFile ? 'contract-behavior' : 'contract-registry';
982
903
  for (const assertion of file.assertionResults ?? []) {
983
904
  const componentId = assertion.ancestorTitles.length >= 2 ? assertion.ancestorTitles[1] : null;
984
905
  if (assertion.status !== 'failed') {
985
906
  if (assertion.status === 'passed' && componentId) {
986
907
  coverage[componentId] ??= {};
987
- coverage[componentId]['contract-registry'] = { status: 'passed' };
908
+ coverage[componentId][passRule] = { status: 'passed' };
988
909
  }
989
910
  continue;
990
911
  }
991
912
  const text = (assertion.failureMessages ?? []).join('\n');
913
+ // `component-behavior.contract.ts` throws `ContractViolation` directly
914
+ // rather than a registry-style violation array, and Vitest's own stack
915
+ // frames follow the message on their own line with no blank line
916
+ // between (measured; unlike Playwright's ANSI code frame), so only the
917
+ // first line is the message `messageBlock`'s blank-line split expects.
918
+ if (isBehaviorFile) {
919
+ const violation = VIOLATION_RE.exec(text.split('\n')[0].trim());
920
+ const finalRule = violation?.[1] ?? 'contract-behavior';
921
+ const id = violation?.[2] ?? componentId;
922
+ const message = violation?.[3] ?? text.split('\n')[0].trim();
923
+ const token = extractToken(message);
924
+ const { file: f, line } = artifactForContractRule(root, sourceDataDir, finalRule, id, token);
925
+ findings.push({
926
+ rule: finalRule,
927
+ file: f,
928
+ line,
929
+ message: id ? `${id}: ${message}` : message,
930
+ context: { suite: 'vitest', title: assertion.fullName },
931
+ });
932
+ if (id) {
933
+ coverage[id] ??= {};
934
+ coverage[id][finalRule] = { status: 'failed' };
935
+ }
936
+ continue;
937
+ }
992
938
  const violations = componentId ? extractViolationArray(text) : [];
993
939
  if (violations.length > 0) {
994
940
  for (const violation of violations) {
@@ -0,0 +1,32 @@
1
+ // The design checks `vite build` runs through themeFileApi: both static
2
+ // checkers with no repairs, under the project's own severities. The same bar
3
+ // `check-page --no-fix` and `check-component --no-fix` hold.
4
+
5
+ import { COMPONENT_RULES, checkComponent, discoverComponents } from '../check-component.mjs';
6
+ import { PAGE_RULES, checkPages, discoverPages } from '../check-page.mjs';
7
+ import { checkTokensCssMigrations } from '../rules/tokens.mjs';
8
+ import { applySeverity, countBySeverity, formatFindings, readChecksConfig } from './findings.mjs';
9
+ import { loadVocabulary } from './tokenVocabulary.mjs';
10
+
11
+ export async function runBuildChecks({ root = process.cwd(), engine } = {}) {
12
+ const config = readChecksConfig(root);
13
+ const vocabulary = loadVocabulary({ root });
14
+ const migration = await checkTokensCssMigrations({ root, apply: false, engine });
15
+ const pages = checkPages(discoverPages(root), { root, vocabulary });
16
+ const ids = discoverComponents(root);
17
+ const components = ids.map((id) => checkComponent(id, root, { vocabulary }));
18
+
19
+ const pageFindings = applySeverity([...migration.findings, ...pages.findings], PAGE_RULES, {}, config, { exclude: true });
20
+ const componentFindings = applySeverity(components.flatMap((r) => r.findings), COMPONENT_RULES, {}, config);
21
+ const page = countBySeverity(pageFindings);
22
+ const component = countBySeverity(componentFindings);
23
+
24
+ return {
25
+ errors: page.errors + component.errors,
26
+ warnings: page.warnings + component.warnings,
27
+ report: [
28
+ formatFindings(pageFindings, { label: 'check-page', checked: pages.checked }),
29
+ formatFindings(componentFindings, { label: 'check-component', checked: ids.length }),
30
+ ].join('\n'),
31
+ };
32
+ }
@@ -4,25 +4,121 @@
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
- import { relative } from 'node:path';
8
- import { CONTRACT_SCALES } from './tokenVocabulary.mjs';
9
-
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. */
14
- function descriptionOf(source) {
15
- const m = source.match(/^\s*<!--([\s\S]*?)-->/);
16
- if (!m) return '';
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);
7
+ import { join, relative } from 'node:path';
8
+ import { CONTRACT_SCALES, PKG_ROOT } from './tokenVocabulary.mjs';
9
+
10
+ const PKG = '@motion-proto/live-tokens';
11
+
12
+ // The one deep import with a public equivalent: a component by file.
13
+ const DEEP_COMPONENT = new RegExp(`^${PKG}/src/system/components/([A-Za-z0-9]+\\.svelte)$`);
14
+
15
+ let subpaths = null;
16
+
17
+ /** Everything the package's `exports` map makes importable, as specifiers. */
18
+ export function publicSubpaths() {
19
+ if (subpaths) return subpaths;
20
+ try {
21
+ const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
22
+ subpaths = Object.keys(pkg.exports ?? {}).map((key) => (key === '.' ? PKG : `${PKG}/${key.slice(2)}`));
23
+ } catch {
24
+ subpaths = [];
23
25
  }
24
- if (lines.length) lines[0] = lines[0].replace(/^\S+\.svelte\s*(?:[—–-]+|\.)\s*/, '');
25
- return lines.join('\n');
26
+ return subpaths;
27
+ }
28
+
29
+ /**
30
+ * What a `deep-import` finding's repair needs. A component file has one public
31
+ * specifier, so the rewrite is mechanical; anything else reaches for internals
32
+ * that may have no public equivalent at all, which leaves the choice open.
33
+ */
34
+ export function deepImportRepair(specifier) {
35
+ const m = DEEP_COMPONENT.exec(specifier);
36
+ if (!m) return { details: { specifier, exports: publicSubpaths() }, repair: 'choice' };
37
+ const publicSpecifier = `${PKG}/components/${m[1]}`;
38
+ return { details: { specifier, public: publicSpecifier, patch: { from: specifier, to: publicSpecifier } } };
39
+ }
40
+
41
+ const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/;
42
+
43
+ function literalValue(raw) {
44
+ const quote = raw[0];
45
+ const inner = raw.slice(1, -1);
46
+ if (quote === '`' && inner.includes('${')) return undefined;
47
+ return inner.replace(/\s+/g, ' ').trim();
48
+ }
49
+
50
+ // Brace matching skips over quoted literals so a `}` inside a description
51
+ // (or a description containing a stray brace) never closes the object early.
52
+ function findBalanced(text, openIndex) {
53
+ let depth = 0;
54
+ for (let i = openIndex; i < text.length; i++) {
55
+ const ch = text[i];
56
+ if (ch === "'" || ch === '"' || ch === '`') {
57
+ const quote = ch;
58
+ i++;
59
+ while (i < text.length && text[i] !== quote) i += text[i] === '\\' ? 2 : 1;
60
+ continue;
61
+ }
62
+ if (ch === '{') depth++;
63
+ else if (ch === '}') {
64
+ depth--;
65
+ if (depth === 0) return { content: text.slice(openIndex + 1, i), end: i + 1 };
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function parseFieldObject(body) {
72
+ const fields = {};
73
+ const re = new RegExp(`([A-Za-z_$][A-Za-z0-9_$]*)\\s*:\\s*(${STRING_LITERAL.source})`, 'g');
74
+ let m;
75
+ while ((m = re.exec(body))) {
76
+ if (m[1] in fields) continue;
77
+ const value = literalValue(m[2]);
78
+ if (value !== undefined) fields[m[1]] = value;
79
+ }
80
+ return fields;
81
+ }
82
+
83
+ /**
84
+ * Bounded, non-evaluating parse of the runtime file's `catalogue` export:
85
+ * `key: <string literal>` pairs inside the `<script module>` block's
86
+ * `export const catalogue = { ... }`, the same way `builtInIds` and
87
+ * `componentProps` read the rest of the vocabulary without importing the
88
+ * module. A field given as an identifier, a template with `${}`, or a
89
+ * concatenation is not a string literal, so it is silently absent rather
90
+ * than evaluated. Returns `null` when the file has no such export.
91
+ */
92
+ export function catalogueOf(source) {
93
+ const moduleBlock = source.match(/<script\s+module[^>]*>([\s\S]*?)<\/script>/);
94
+ if (!moduleBlock) return null;
95
+ const exportMatch = /export\s+const\s+catalogue\s*=\s*\{/.exec(moduleBlock[1]);
96
+ if (!exportMatch) return null;
97
+ const openIndex = exportMatch.index + exportMatch[0].length - 1;
98
+ const balanced = findBalanced(moduleBlock[1], openIndex);
99
+ if (!balanced) return null;
100
+
101
+ let body = balanced.content;
102
+ let props;
103
+ const propsMatch = /\bprops\s*:\s*\{/.exec(body);
104
+ if (propsMatch) {
105
+ const propsOpen = propsMatch.index + propsMatch[0].length - 1;
106
+ const propsBalanced = findBalanced(body, propsOpen);
107
+ if (propsBalanced) {
108
+ const parsedProps = parseFieldObject(propsBalanced.content);
109
+ if (Object.keys(parsedProps).length) props = parsedProps;
110
+ body = body.slice(0, propsMatch.index) + body.slice(propsBalanced.end);
111
+ }
112
+ }
113
+
114
+ const fields = parseFieldObject(body);
115
+ const catalogue = {
116
+ description: fields.description,
117
+ useFor: fields.useFor,
118
+ notFor: fields.notFor,
119
+ };
120
+ if (props) catalogue.props = props;
121
+ return catalogue;
26
122
  }
27
123
 
28
124
  function scaleOf(name) {
@@ -33,6 +129,41 @@ function scaleOf(name) {
33
129
  return hit ?? stem.split('-')[0];
34
130
  }
35
131
 
132
+ // Cached per vocabulary: a checker asks for a scale once per finding, and the
133
+ // values come from re-reading tokens.css.
134
+ const scalesCache = new WeakMap();
135
+
136
+ /**
137
+ * Every design token grouped by its scale, each with the value tokens.css
138
+ * declares for it. The candidates a `color-literal` or `dimension-literal`
139
+ * repair picks from, and what `describeTokens` prints.
140
+ */
141
+ export function tokenScales(vocab) {
142
+ const cached = scalesCache.get(vocab);
143
+ if (cached) return cached;
144
+
145
+ const values = new Map();
146
+ if (vocab.tokensCssPath) {
147
+ const css = readFileSync(vocab.tokensCssPath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ' ');
148
+ for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
149
+ if (!values.has(m[1])) values.set(m[1], m[2].trim());
150
+ }
151
+ }
152
+ const byScale = new Map();
153
+ for (const name of vocab.themeTokens) {
154
+ const scale = scaleOf(name);
155
+ if (!byScale.has(scale)) byScale.set(scale, []);
156
+ byScale.get(scale).push({ name, value: values.get(name) ?? '' });
157
+ }
158
+ scalesCache.set(vocab, byScale);
159
+ return byScale;
160
+ }
161
+
162
+ /** One scale's tokens, empty when the project has no such scale. */
163
+ export function scaleTokens(vocab, scale) {
164
+ return (scale && tokenScales(vocab).get(scale)) || [];
165
+ }
166
+
36
167
  export function describeComponents(vocab, { root = process.cwd() } = {}) {
37
168
  const out = [];
38
169
  for (const entry of vocab.components.values()) {
@@ -50,7 +181,7 @@ export function describeComponents(vocab, { root = process.cwd() } = {}) {
50
181
  origin: entry.origin,
51
182
  file: relative(root, entry.file),
52
183
  registered: vocab.builtIn.has(entry.id) || vocab.registered.has(entry.id),
53
- description: descriptionOf(source),
184
+ catalogue: catalogueOf(source),
54
185
  variants: entry.props?.enums.get('variant') ? [...entry.props.enums.get('variant')] : [],
55
186
  props,
56
187
  tokens: [...entry.tokens].map(([name, value]) => ({ name, default: value })),
@@ -60,22 +191,9 @@ export function describeComponents(vocab, { root = process.cwd() } = {}) {
60
191
  }
61
192
 
62
193
  export function describeTokens(vocab, { root = process.cwd() } = {}) {
63
- const values = new Map();
64
- if (vocab.tokensCssPath) {
65
- const css = readFileSync(vocab.tokensCssPath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ' ');
66
- for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
67
- if (!values.has(m[1])) values.set(m[1], m[2].trim());
68
- }
69
- }
70
- const byScale = new Map();
71
- for (const name of vocab.themeTokens) {
72
- const scale = scaleOf(name);
73
- if (!byScale.has(scale)) byScale.set(scale, []);
74
- byScale.get(scale).push({ name, value: values.get(name) ?? '' });
75
- }
76
194
  return {
77
195
  tokensCss: vocab.tokensCssPath ? relative(root, vocab.tokensCssPath) : null,
78
- scales: [...byScale].map(([scale, tokens]) => ({ scale, tokens })),
196
+ scales: [...tokenScales(vocab)].map(([scale, tokens]) => ({ scale, tokens })),
79
197
  components: [...vocab.components.values()].map((c) => ({
80
198
  id: c.id,
81
199
  tokens: [...c.tokens].map(([name, value]) => ({ name, default: value })),
@@ -83,7 +201,16 @@ export function describeTokens(vocab, { root = process.cwd() } = {}) {
83
201
  };
84
202
  }
85
203
 
86
- const describeLines = (c) => (c.description ? c.description.split('\n') : []);
204
+ function describeLines(c) {
205
+ if (!c.catalogue) return [];
206
+ const { description, useFor, notFor, props } = c.catalogue;
207
+ const lines = [];
208
+ if (description) lines.push(description);
209
+ if (useFor) lines.push(`Use for: ${useFor}`);
210
+ if (notFor) lines.push(`Not for: ${notFor}`);
211
+ for (const [prop, text] of Object.entries(props ?? {})) lines.push(`${prop}: ${text}`);
212
+ return lines;
213
+ }
87
214
 
88
215
  export function formatComponents(list, { id } = {}) {
89
216
  const lines = [];