@motion-proto/live-tokens 0.78.0 → 0.79.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 (166) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +60 -31
  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 +231 -0
  13. package/README.md +9 -15
  14. package/bin/check-component.mjs +152 -551
  15. package/bin/check-page.mjs +55 -541
  16. package/bin/cli.mjs +59 -13
  17. package/bin/contractRunner.mjs +37 -91
  18. package/bin/lib/catalogue.mjs +161 -34
  19. package/bin/lib/componentSource.mjs +152 -0
  20. package/bin/lib/cssValues.mjs +9 -0
  21. package/bin/lib/dataDir.mjs +126 -0
  22. package/bin/lib/findings.mjs +103 -12
  23. package/bin/lib/fixers.mjs +64 -0
  24. package/bin/lib/geometry.mjs +92 -0
  25. package/bin/lib/pageSource.mjs +230 -0
  26. package/bin/lib/report.mjs +57 -59
  27. package/bin/lib/tokenVocabulary.mjs +104 -34
  28. package/bin/rules/componentStructure.mjs +344 -0
  29. package/bin/rules/componentUse.mjs +313 -0
  30. package/bin/rules/importsAndRoutes.mjs +136 -0
  31. package/bin/rules/testRuns.mjs +122 -0
  32. package/bin/rules/tokens.mjs +363 -0
  33. package/bin/setup-claude.mjs +1 -2
  34. package/dist-plugin/{chunk-PDNL4NC5.js → chunk-D4WRIKEZ.js} +7 -2
  35. package/dist-plugin/{chunk-SWXRVZKT.js → chunk-REBHE3ZM.js} +414 -1
  36. package/dist-plugin/index.cjs +432 -15
  37. package/dist-plugin/index.js +8 -9
  38. package/dist-plugin/migrateData/index.cjs +422 -4
  39. package/dist-plugin/migrateData/index.js +2 -2
  40. package/dist-plugin/setColors/index.cjs +414 -1
  41. package/dist-plugin/setColors/index.d.cts +1 -1
  42. package/dist-plugin/setColors/index.d.ts +1 -1
  43. package/dist-plugin/setColors/index.js +1 -1
  44. package/dist-plugin/setGeometry/index.cjs +423 -13
  45. package/dist-plugin/setGeometry/index.d.cts +3 -3
  46. package/dist-plugin/setGeometry/index.d.ts +3 -3
  47. package/dist-plugin/setGeometry/index.js +10 -13
  48. package/dist-plugin/setType/index.d.cts +1 -1
  49. package/dist-plugin/setType/index.d.ts +1 -1
  50. package/dist-plugin/{themeTypes-BxRtuN5V.d.cts → themeTypes-B8_Idrp4.d.cts} +2 -2
  51. package/dist-plugin/{themeTypes-BxRtuN5V.d.ts → themeTypes-B8_Idrp4.d.ts} +2 -2
  52. package/package.json +2 -2
  53. package/src/editor/component-editor/CalloutEditor.svelte +2 -2
  54. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +14 -15
  55. package/src/editor/component-editor/CornerBadgeEditor.svelte +14 -14
  56. package/src/editor/component-editor/DialogEditor.svelte +5 -5
  57. package/src/editor/component-editor/InlineEditActionsEditor.svelte +2 -2
  58. package/src/editor/component-editor/RadioButtonEditor.svelte +21 -21
  59. package/src/editor/component-editor/SectionDividerEditor.svelte +7 -7
  60. package/src/editor/component-editor/SegmentedControlEditor.svelte +5 -5
  61. package/src/editor/component-editor/SideNavigationEditor.svelte +25 -25
  62. package/src/editor/component-editor/TabBarEditor.svelte +6 -6
  63. package/src/editor/component-editor/TableEditor.svelte +6 -6
  64. package/src/editor/component-editor/ToggleEditor.svelte +2 -2
  65. package/src/editor/component-editor/index.ts +3 -0
  66. package/src/editor/component-editor/registry.ts +57 -1
  67. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +14 -14
  68. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +3 -0
  69. package/src/editor/component-editor/scaffolding/types.ts +15 -0
  70. package/src/editor/core/components/adjustAliases.ts +4 -4
  71. package/src/editor/core/components/aliasKinds.ts +20 -19
  72. package/src/editor/core/sketch/sketchLayer.ts +3 -3
  73. package/src/editor/core/themes/migrateComponentConfig.ts +14 -6
  74. package/src/editor/core/themes/migrations/2026-09-13-badge-brand.ts +42 -0
  75. package/src/editor/core/themes/migrations/2026-09-13-collapsiblesection-open.ts +33 -0
  76. package/src/editor/core/themes/migrations/2026-09-13-cornerbadge-prefix.ts +70 -0
  77. package/src/editor/core/themes/migrations/2026-09-13-hairline.ts +92 -0
  78. package/src/editor/core/themes/migrations/2026-09-13-indicator.ts +54 -0
  79. package/src/editor/core/themes/migrations/2026-09-13-sectiondivider-surface.ts +36 -0
  80. package/src/editor/core/themes/migrations/2026-09-13-selected-state.ts +124 -0
  81. package/src/editor/core/themes/migrations/2026-09-13-toggle-label.ts +31 -0
  82. package/src/editor/core/themes/migrations/index.ts +16 -0
  83. package/src/editor/core/themes/themeService.ts +3 -3
  84. package/src/editor/core/themes/themeTypes.ts +13 -15
  85. package/src/editor/docs/Docs.svelte +1 -1
  86. package/src/editor/docs/content/light-and-dark.md +3 -3
  87. package/src/editor/docs/content.generated.ts +1 -1
  88. package/src/editor/index.ts +1 -0
  89. package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
  90. package/src/editor/skill-atlas/SkillAtlas.svelte +4 -4
  91. package/src/editor/skill-atlas/TreeNodeCard.svelte +4 -4
  92. package/src/editor/skill-atlas/skillSources.generated.ts +11 -14
  93. package/src/editor/skill-atlas/skillTrees.ts +1 -3
  94. package/src/editor/skill-atlas/trees/check-compliance.ts +258 -93
  95. package/src/editor/skill-atlas/trees/create-component.ts +73 -62
  96. package/src/editor/skill-atlas/trees/create-page.ts +38 -17
  97. package/src/editor/skill-atlas/trees/pick-component.ts +3 -3
  98. package/src/editor/skill-atlas/trees/set-geometry.ts +1 -1
  99. package/src/editor/ui/UIPaletteSelector.svelte +11 -15
  100. package/src/editor/ui/variantScales.ts +8 -8
  101. package/src/live-tokens/data/themes/autumn.json +188 -188
  102. package/src/live-tokens/data/themes/halloween.json +188 -188
  103. package/src/live-tokens/data/themes/midnight-study.json +245 -245
  104. package/src/live-tokens/data/themes/ocean.json +188 -188
  105. package/src/live-tokens/data/themes/royal-velvet.json +188 -188
  106. package/src/live-tokens/data/themes/sketchy.json +188 -188
  107. package/src/live-tokens/data/themes/spring-meadow.json +188 -188
  108. package/src/live-tokens/data/themes/sunset.json +188 -188
  109. package/src/system/components/Badge.svelte +27 -23
  110. package/src/system/components/Button.svelte +13 -7
  111. package/src/system/components/Callout.svelte +16 -11
  112. package/src/system/components/Card.svelte +22 -15
  113. package/src/system/components/CodeSnippet.svelte +10 -6
  114. package/src/system/components/CollapsibleSection.svelte +68 -63
  115. package/src/system/components/CornerBadge.svelte +78 -72
  116. package/src/system/components/Dialog.svelte +21 -18
  117. package/src/system/components/IconButton.svelte +13 -9
  118. package/src/system/components/Image.svelte +14 -8
  119. package/src/system/components/ImageLightbox.svelte +10 -6
  120. package/src/system/components/InlineEditActions.svelte +17 -14
  121. package/src/system/components/Input.svelte +13 -7
  122. package/src/system/components/MenuSelect.svelte +13 -7
  123. package/src/system/components/Notification.svelte +17 -11
  124. package/src/system/components/Panel.svelte +13 -6
  125. package/src/system/components/ProgressBar.svelte +10 -5
  126. package/src/system/components/RadioButton.svelte +33 -30
  127. package/src/system/components/SectionDivider.svelte +28 -21
  128. package/src/system/components/SegmentedControl.svelte +27 -23
  129. package/src/system/components/SideNavigation.svelte +175 -171
  130. package/src/system/components/Slider.svelte +11 -7
  131. package/src/system/components/TabBar.svelte +53 -49
  132. package/src/system/components/Table.svelte +20 -15
  133. package/src/system/components/Toggle.svelte +14 -10
  134. package/src/system/components/Tooltip.svelte +10 -6
  135. package/src/system/styles/CONVENTIONS.md +3 -4
  136. package/src/testing-js/chunk-3UKGXCDL.js +48 -0
  137. package/src/testing-js/chunk-3UKGXCDL.js.map +1 -0
  138. package/src/testing-js/{chunk-FAFOAWYL.js → chunk-Q3YIAAG3.js} +17 -3
  139. package/src/testing-js/chunk-Q3YIAAG3.js.map +1 -0
  140. package/src/testing-js/{chunk-GNIUPIU2.js → chunk-U7OJE5DU.js} +18 -18
  141. package/src/testing-js/chunk-U7OJE5DU.js.map +1 -0
  142. package/src/testing-js/{chunk-4JQX6WWL.js → chunk-ZMZQZ33J.js} +474 -166
  143. package/src/testing-js/chunk-ZMZQZ33J.js.map +1 -0
  144. package/src/testing-js/component-behavior.contract.js +155 -0
  145. package/src/testing-js/component-behavior.contract.js.map +1 -0
  146. package/src/testing-js/component-editor.contract.js +6 -4
  147. package/src/testing-js/component-editor.contract.js.map +1 -1
  148. package/src/testing-js/component-render.contract.js +14 -10
  149. package/src/testing-js/component-render.contract.js.map +1 -1
  150. package/src/testing-js/index.d.ts +44 -4
  151. package/src/testing-js/index.js +13 -7
  152. package/src/testing-js/index.js.map +1 -1
  153. package/src/testing-js/page-compliance.contract.js +48 -10
  154. package/src/testing-js/page-compliance.contract.js.map +1 -1
  155. package/src/testing-js/registry.contract.js +5 -3
  156. package/src/testing-js/registry.contract.js.map +1 -1
  157. package/src/testing-js/{vitest-C-wNWcoA.d.ts → vitest-BMLIbDs2.d.ts} +8 -2
  158. package/src/testing-js/vitest.d.ts +1 -1
  159. package/src/testing-js/vitest.js +2 -1
  160. package/template/package.json +1 -1
  161. package/.claude/skills/live-tokens-create-component/SKILL copy.md +0 -196
  162. package/.claude/skills/live-tokens-fix-findings/SKILL.md +0 -105
  163. package/src/editor/skill-atlas/trees/fix-findings.ts +0 -504
  164. package/src/testing-js/chunk-4JQX6WWL.js.map +0 -1
  165. package/src/testing-js/chunk-FAFOAWYL.js.map +0 -1
  166. package/src/testing-js/chunk-GNIUPIU2.js.map +0 -1
package/bin/cli.mjs CHANGED
@@ -18,16 +18,18 @@ 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,
@@ -98,6 +100,15 @@ check-component and check-page also accept:
98
100
  (or set "checks": { "rules": {...} } in
99
101
  live-tokens.config.json; "checks": { "exclude":
100
102
  [...] } drops paths from discovery entirely)
103
+ --no-fix Report every finding and edit nothing, for a
104
+ build or CI. Without it, both checkers first
105
+ apply every finding whose repair is auto (a
106
+ deep import into a component, a raw dimension
107
+ with one nearest design token, a shipped
108
+ component's size prop, a semantic property
109
+ overridden in place), re-check, and list each
110
+ fix beside what remains. --tests runs after
111
+ the fixes land.
101
112
  set-colors <base-colors.json> [--dry-run]
102
113
  Build the theme's whole color identity from 10
103
114
  OKLCH base colors (see the live-tokens-set-colors
@@ -194,14 +205,14 @@ if (command === 'create') {
194
205
  }
195
206
  }
196
207
 
197
- function reportChecks(label, findings, checked, rules, opts, { coverage, hardFailure, fixes } = {}) {
208
+ function reportChecks(label, findings, checked, rules, opts, { coverage, hardFailure, exclude, fix } = {}) {
198
209
  const checksConfig = readChecksConfig(process.cwd());
199
- const resolved = applySeverity(findings, rules, opts, checksConfig, fixes ?? {});
210
+ const resolved = applySeverity(findings, rules, opts, checksConfig, { exclude });
200
211
  const resolvedCoverage = coverage ? applyCoverageSeverity(coverage, rules, opts, checksConfig) : coverage;
201
212
  console.log(
202
213
  opts.json
203
- ? toJson(resolved, { label, checked, coverage: resolvedCoverage })
204
- : formatFindings(resolved, { label, checked }),
214
+ ? toJson(resolved, { label, checked, coverage: resolvedCoverage, fix })
215
+ : [fix ? formatFixes(fix) : '', formatFindings(resolved, { label, checked })].filter(Boolean).join('\n'),
205
216
  );
206
217
  // Decision 2: a missing tool or a setup failure under --tests is an error
207
218
  // even if a project silenced its rule id, since silencing it would read as
@@ -209,6 +220,29 @@ function reportChecks(label, findings, checked, rules, opts, { coverage, hardFai
209
220
  process.exit(countBySeverity(resolved).errors === 0 && !hardFailure ? 0 : 1);
210
221
  }
211
222
 
223
+ function shiftNote(f) {
224
+ if (f.rule !== 'dimension-literal') return '';
225
+ const shifts = f.details.literals.map((l) => l.candidates[0].shift).map((n) => `${n > 0 ? '+' : ''}${n}px`);
226
+ return ` shift ${shifts.join(', ')}`;
227
+ }
228
+
229
+ /** Resolves severities and exclusions first, so a silenced rule or an excluded
230
+ * file is never edited. */
231
+ function applyStaticFixes(findings, rules, opts, { exclude } = {}) {
232
+ const root = process.cwd();
233
+ return applyFixes(applySeverity(findings, rules, opts, readChecksConfig(root), { exclude }), root);
234
+ }
235
+
236
+ function formatFixes({ applied, skipped }) {
237
+ const lines = [];
238
+ for (const f of applied) {
239
+ lines.push(` fixed ${f.file}:${f.line} ${f.details.patch.from} → ${f.details.patch.to || '(removed)'} [${f.rule}]${shiftNote(f)}`);
240
+ }
241
+ for (const f of skipped) lines.push(` skipped ${f.file}:${f.line} ${f.details.patch.from} [${f.rule}]`);
242
+ if (lines.length === 0) return '';
243
+ return [`${applied.length} fix(es) applied, ${skipped.length} skipped:`, ...lines, ''].join('\n');
244
+ }
245
+
212
246
  if (command === 'components') {
213
247
  const opts = parseCheckFlags(rest);
214
248
  const list = describeComponents(loadVocabulary());
@@ -257,21 +291,26 @@ if (command === 'check-component') {
257
291
  console.log('✓ check-component: no component authored under src/system/components yet.');
258
292
  process.exit(0);
259
293
  }
260
- const results = ids.map((id) => [id, checkComponent(id)]);
294
+ let results = ids.map((id) => [id, checkComponent(id)]);
295
+ const label = ids.length === 1 ? `check-component ${ids[0]}${opts.tests ? ' --tests' : ''}` : `check-component${opts.tests ? ' --tests' : ''}`;
296
+ let fix;
297
+ if (!opts.noFix) {
298
+ fix = applyStaticFixes(results.flatMap(([, r]) => r.findings), COMPONENT_RULES, opts);
299
+ if (fix.applied.length > 0) results = ids.map((id) => [id, checkComponent(id)]);
300
+ }
261
301
  if (!opts.tests && ids.length === 1 && !opts.json && !opts.strict && opts.off.length + opts.warn.length + opts.error.length === 0) {
262
302
  const [id, result] = results[0];
263
- console.log(formatReport(id, result));
303
+ console.log([fix ? formatFixes(fix) : '', formatReport(id, result)].filter(Boolean).join('\n'));
264
304
  process.exit(result.errors.length === 0 ? 0 : 1);
265
305
  }
266
- const label = ids.length === 1 ? `check-component ${ids[0]}${opts.tests ? ' --tests' : ''}` : `check-component${opts.tests ? ' --tests' : ''}`;
267
306
  if (!opts.tests) {
268
- reportChecks(label, results.flatMap(([, r]) => r.findings), ids.length, COMPONENT_RULES, opts, { fixes: COMPONENT_RULE_FIX });
307
+ reportChecks(label, results.flatMap(([, r]) => r.findings), ids.length, COMPONENT_RULES, opts, { fix });
269
308
  }
270
309
  const { hasHardFailure, runContractTests } = await import('./contractRunner.mjs');
271
310
  const testOutcome = await runContractTests(opts.rest[0], { root: process.cwd() });
272
- const findings = [...results.flatMap(([, r]) => r.findings), ...testOutcome.findings];
311
+ const findings = dedupeAliasFindings([...results.flatMap(([, r]) => r.findings), ...testOutcome.findings]);
273
312
  reportChecks(label, findings, Math.max(ids.length, 1), COMPONENT_RULES, opts, {
274
- fixes: COMPONENT_RULE_FIX,
313
+ fix,
275
314
  coverage: testOutcome.coverage,
276
315
  hardFailure: hasHardFailure(testOutcome.findings),
277
316
  });
@@ -280,9 +319,14 @@ if (command === 'check-component') {
280
319
  if (command === 'check-page') {
281
320
  const opts = parseCheckFlags(rest);
282
321
  const targets = opts.rest.length > 0 ? opts.rest : discoverPages(process.cwd());
283
- const { findings, checked } = checkPages(targets, { root: process.cwd() });
322
+ let { findings, checked } = checkPages(targets, { root: process.cwd() });
323
+ let fix;
324
+ if (!opts.noFix) {
325
+ fix = applyStaticFixes(findings, PAGE_RULES, opts, { exclude: true });
326
+ if (fix.applied.length > 0) ({ findings, checked } = checkPages(targets, { root: process.cwd() }));
327
+ }
284
328
  if (!opts.tests) {
285
- reportChecks('check-page', findings, checked, PAGE_RULES, opts);
329
+ reportChecks('check-page', findings, checked, PAGE_RULES, opts, { exclude: true, fix });
286
330
  }
287
331
  const { hasHardFailure, runPageTests } = await import('./contractRunner.mjs');
288
332
  const pageTargets = resolvePageTestTargets(opts.rest, process.cwd());
@@ -290,6 +334,8 @@ if (command === 'check-page') {
290
334
  const label = 'check-page --tests';
291
335
  const allFindings = [...findings, ...testOutcome.findings];
292
336
  reportChecks(label, allFindings, Math.max(checked, pageTargets.length), PAGE_RULES, opts, {
337
+ exclude: true,
338
+ fix,
293
339
  coverage: testOutcome.coverage,
294
340
  hardFailure: hasHardFailure(testOutcome.findings),
295
341
  });
@@ -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) {
@@ -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 = [];