@motion-proto/live-tokens 0.77.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 (177) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +60 -29
  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 +9 -6
  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 +284 -0
  13. package/README.md +9 -15
  14. package/bin/check-component.mjs +152 -551
  15. package/bin/check-page.mjs +56 -420
  16. package/bin/cli.mjs +83 -15
  17. package/bin/contractRunner.mjs +380 -120
  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/pageRoutes.mjs +309 -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/rules/componentStructure.mjs +344 -0
  30. package/bin/rules/componentUse.mjs +313 -0
  31. package/bin/rules/importsAndRoutes.mjs +136 -0
  32. package/bin/rules/testRuns.mjs +122 -0
  33. package/bin/rules/tokens.mjs +363 -0
  34. package/bin/setup-claude.mjs +1 -2
  35. package/dist-plugin/{chunk-PDNL4NC5.js → chunk-D4WRIKEZ.js} +7 -2
  36. package/dist-plugin/{chunk-SWXRVZKT.js → chunk-REBHE3ZM.js} +414 -1
  37. package/dist-plugin/index.cjs +432 -15
  38. package/dist-plugin/index.js +8 -9
  39. package/dist-plugin/migrateData/index.cjs +422 -4
  40. package/dist-plugin/migrateData/index.js +2 -2
  41. package/dist-plugin/setColors/index.cjs +414 -1
  42. package/dist-plugin/setColors/index.d.cts +1 -1
  43. package/dist-plugin/setColors/index.d.ts +1 -1
  44. package/dist-plugin/setColors/index.js +1 -1
  45. package/dist-plugin/setGeometry/index.cjs +423 -13
  46. package/dist-plugin/setGeometry/index.d.cts +3 -3
  47. package/dist-plugin/setGeometry/index.d.ts +3 -3
  48. package/dist-plugin/setGeometry/index.js +10 -13
  49. package/dist-plugin/setType/index.d.cts +1 -1
  50. package/dist-plugin/setType/index.d.ts +1 -1
  51. package/dist-plugin/{themeTypes-BxRtuN5V.d.cts → themeTypes-B8_Idrp4.d.cts} +2 -2
  52. package/dist-plugin/{themeTypes-BxRtuN5V.d.ts → themeTypes-B8_Idrp4.d.ts} +2 -2
  53. package/package.json +6 -4
  54. package/src/editor/component-editor/CalloutEditor.svelte +2 -2
  55. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +14 -15
  56. package/src/editor/component-editor/CornerBadgeEditor.svelte +14 -14
  57. package/src/editor/component-editor/DialogEditor.svelte +5 -5
  58. package/src/editor/component-editor/InlineEditActionsEditor.svelte +2 -2
  59. package/src/editor/component-editor/RadioButtonEditor.svelte +21 -21
  60. package/src/editor/component-editor/SectionDividerEditor.svelte +7 -7
  61. package/src/editor/component-editor/SegmentedControlEditor.svelte +5 -5
  62. package/src/editor/component-editor/SideNavigationEditor.svelte +25 -25
  63. package/src/editor/component-editor/TabBarEditor.svelte +6 -6
  64. package/src/editor/component-editor/TableEditor.svelte +6 -6
  65. package/src/editor/component-editor/ToggleEditor.svelte +2 -2
  66. package/src/editor/component-editor/index.ts +3 -0
  67. package/src/editor/component-editor/registry.ts +57 -1
  68. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +14 -14
  69. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +3 -0
  70. package/src/editor/component-editor/scaffolding/types.ts +15 -0
  71. package/src/editor/core/components/adjustAliases.ts +4 -4
  72. package/src/editor/core/components/aliasKinds.ts +20 -19
  73. package/src/editor/core/sketch/sketchLayer.ts +3 -3
  74. package/src/editor/core/themes/migrateComponentConfig.ts +14 -6
  75. package/src/editor/core/themes/migrations/2026-09-13-badge-brand.ts +42 -0
  76. package/src/editor/core/themes/migrations/2026-09-13-collapsiblesection-open.ts +33 -0
  77. package/src/editor/core/themes/migrations/2026-09-13-cornerbadge-prefix.ts +70 -0
  78. package/src/editor/core/themes/migrations/2026-09-13-hairline.ts +92 -0
  79. package/src/editor/core/themes/migrations/2026-09-13-indicator.ts +54 -0
  80. package/src/editor/core/themes/migrations/2026-09-13-sectiondivider-surface.ts +36 -0
  81. package/src/editor/core/themes/migrations/2026-09-13-selected-state.ts +124 -0
  82. package/src/editor/core/themes/migrations/2026-09-13-toggle-label.ts +31 -0
  83. package/src/editor/core/themes/migrations/index.ts +16 -0
  84. package/src/editor/core/themes/themeService.ts +3 -3
  85. package/src/editor/core/themes/themeTypes.ts +13 -15
  86. package/src/editor/docs/Docs.svelte +1 -1
  87. package/src/editor/docs/content/light-and-dark.md +3 -3
  88. package/src/editor/docs/content.generated.ts +1 -1
  89. package/src/editor/index.ts +1 -0
  90. package/src/editor/overlay/ColumnsOverlay.svelte +1 -1
  91. package/src/editor/overlay/LiveEditorOverlay.svelte +16 -0
  92. package/src/editor/overlay/LiveTokensRouter.svelte +1 -0
  93. package/src/editor/skill-atlas/SkillAtlas.svelte +4 -4
  94. package/src/editor/skill-atlas/TreeNodeCard.svelte +4 -4
  95. package/src/editor/skill-atlas/skillSources.generated.ts +11 -14
  96. package/src/editor/skill-atlas/skillTrees.ts +1 -3
  97. package/src/editor/skill-atlas/trees/check-compliance.ts +258 -93
  98. package/src/editor/skill-atlas/trees/create-component.ts +73 -62
  99. package/src/editor/skill-atlas/trees/create-page.ts +54 -33
  100. package/src/editor/skill-atlas/trees/pick-component.ts +3 -3
  101. package/src/editor/skill-atlas/trees/set-geometry.ts +1 -1
  102. package/src/editor/ui/UIPaletteSelector.svelte +11 -15
  103. package/src/editor/ui/variantScales.ts +8 -8
  104. package/src/live-tokens/data/themes/autumn.json +188 -188
  105. package/src/live-tokens/data/themes/halloween.json +188 -188
  106. package/src/live-tokens/data/themes/midnight-study.json +245 -245
  107. package/src/live-tokens/data/themes/ocean.json +188 -188
  108. package/src/live-tokens/data/themes/royal-velvet.json +188 -188
  109. package/src/live-tokens/data/themes/sketchy.json +188 -188
  110. package/src/live-tokens/data/themes/spring-meadow.json +188 -188
  111. package/src/live-tokens/data/themes/sunset.json +188 -188
  112. package/src/system/components/Badge.svelte +27 -23
  113. package/src/system/components/Button.svelte +13 -7
  114. package/src/system/components/Callout.svelte +16 -11
  115. package/src/system/components/Card.svelte +22 -15
  116. package/src/system/components/CodeSnippet.svelte +10 -6
  117. package/src/system/components/CollapsibleSection.svelte +68 -63
  118. package/src/system/components/CornerBadge.svelte +78 -72
  119. package/src/system/components/Dialog.svelte +21 -18
  120. package/src/system/components/IconButton.svelte +13 -9
  121. package/src/system/components/Image.svelte +14 -8
  122. package/src/system/components/ImageLightbox.svelte +10 -6
  123. package/src/system/components/InlineEditActions.svelte +17 -14
  124. package/src/system/components/Input.svelte +13 -7
  125. package/src/system/components/MenuSelect.svelte +13 -7
  126. package/src/system/components/Notification.svelte +17 -11
  127. package/src/system/components/Panel.svelte +13 -6
  128. package/src/system/components/ProgressBar.svelte +10 -5
  129. package/src/system/components/RadioButton.svelte +33 -30
  130. package/src/system/components/SectionDivider.svelte +28 -21
  131. package/src/system/components/SegmentedControl.svelte +27 -23
  132. package/src/system/components/SideNavigation.svelte +175 -171
  133. package/src/system/components/Slider.svelte +11 -7
  134. package/src/system/components/TabBar.svelte +53 -49
  135. package/src/system/components/Table.svelte +20 -15
  136. package/src/system/components/Toggle.svelte +14 -10
  137. package/src/system/components/Tooltip.svelte +10 -6
  138. package/src/system/styles/CONVENTIONS.md +3 -4
  139. package/src/testing-js/chunk-3UKGXCDL.js +48 -0
  140. package/src/testing-js/chunk-3UKGXCDL.js.map +1 -0
  141. package/src/testing-js/{chunk-ZMSX6CXR.js → chunk-NB3NZRBM.js} +13 -2
  142. package/src/testing-js/chunk-NB3NZRBM.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-7TI7Z6Y6.js → chunk-U7OJE5DU.js} +19 -19
  146. package/src/testing-js/chunk-U7OJE5DU.js.map +1 -0
  147. package/src/testing-js/{chunk-L73N4NSO.js → chunk-WIZ6W7UT.js} +2 -2
  148. package/src/testing-js/{chunk-4JQX6WWL.js → chunk-ZMZQZ33J.js} +474 -166
  149. package/src/testing-js/chunk-ZMZQZ33J.js.map +1 -0
  150. package/src/testing-js/component-alias.contract.js +2 -2
  151. package/src/testing-js/component-behavior.contract.js +155 -0
  152. package/src/testing-js/component-behavior.contract.js.map +1 -0
  153. package/src/testing-js/component-editor.contract.js +7 -5
  154. package/src/testing-js/component-editor.contract.js.map +1 -1
  155. package/src/testing-js/component-render.contract.js +15 -11
  156. package/src/testing-js/component-render.contract.js.map +1 -1
  157. package/src/testing-js/index.d.ts +50 -4
  158. package/src/testing-js/index.js +32 -8
  159. package/src/testing-js/index.js.map +1 -1
  160. package/src/testing-js/page-compliance.contract.js +867 -0
  161. package/src/testing-js/page-compliance.contract.js.map +1 -0
  162. package/src/testing-js/registry.contract.js +5 -3
  163. package/src/testing-js/registry.contract.js.map +1 -1
  164. package/src/testing-js/{vitest-BE6uGF31.d.ts → vitest-BMLIbDs2.d.ts} +29 -3
  165. package/src/testing-js/vitest.d.ts +1 -1
  166. package/src/testing-js/vitest.js +3 -2
  167. package/template/README.md +16 -9
  168. package/template/package.json +2 -2
  169. package/template/src/pages/Home.svelte +13 -0
  170. package/.claude/skills/live-tokens-create-component/SKILL copy.md +0 -196
  171. package/.claude/skills/live-tokens-fix-findings/SKILL.md +0 -98
  172. package/src/editor/skill-atlas/trees/fix-findings.ts +0 -469
  173. package/src/testing-js/chunk-4JQX6WWL.js.map +0 -1
  174. package/src/testing-js/chunk-7TI7Z6Y6.js.map +0 -1
  175. package/src/testing-js/chunk-FAFOAWYL.js.map +0 -1
  176. package/src/testing-js/chunk-ZMSX6CXR.js.map +0 -1
  177. /package/src/testing-js/{chunk-L73N4NSO.js.map → chunk-WIZ6W7UT.js.map} +0 -0
@@ -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 = [];
@@ -0,0 +1,152 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PKG_ROOT, declaredCustomProperties, extractGlobalRootBlocks } from './tokenVocabulary.mjs';
4
+
5
+ // Property suffixes come from the editor's own kind table, so the checker and
6
+ // the picker can never disagree about what a name means. Read as text rather
7
+ // than imported: this module must load without the compiled engine (CI runs the
8
+ // suite before the plugin is built).
9
+ const ALIAS_KINDS = 'src/editor/core/components/aliasKinds.ts';
10
+
11
+ /** Each kind with the suffixes that name it, in the file's own order, since
12
+ * the first match wins there too. */
13
+ export function readKindRules(root) {
14
+ for (const base of [root, PKG_ROOT]) {
15
+ const path = join(base, ALIAS_KINDS);
16
+ if (!existsSync(path)) continue;
17
+ const src = readFileSync(path, 'utf8');
18
+ const block = src.match(/KIND_RULES[^=]*=\s*\[([\s\S]*?)\n\];/);
19
+ if (!block) continue;
20
+ const out = [];
21
+ for (const m of block[1].matchAll(/\{\s*kind:\s*'([a-z-]+)',\s*suffix:\s*\[([\s\S]*?)\]/g)) {
22
+ out.push({ kind: m[1], suffixes: [...m[2].matchAll(/'-([a-z0-9-]+)'/g)].map((n) => n[1]) });
23
+ }
24
+ if (out.length > 0) return out;
25
+ }
26
+ return [];
27
+ }
28
+
29
+ export function readKnownSuffixes(root) {
30
+ return [...new Set(readKindRules(root).flatMap((r) => r.suffixes))];
31
+ }
32
+
33
+ /**
34
+ * The token scale a property draws its value from, by way of the editor's own
35
+ * kind for it. A kind with no scale behind it — a length, a duration, a font
36
+ * axis — has none, so a literal there has no candidate to offer.
37
+ */
38
+ const KIND_SCALE = {
39
+ 'text-color': 'text',
40
+ surface: 'surface',
41
+ border: 'border',
42
+ radius: 'radius',
43
+ padding: 'space',
44
+ gap: 'space',
45
+ 'hairline-inset': 'space',
46
+ 'border-width': 'border-width',
47
+ 'hairline-width': 'border-width',
48
+ 'indicator-width': 'border-width',
49
+ shadow: 'shadow',
50
+ };
51
+
52
+ export const COLOR_SCALES = ['text', 'surface', 'border'];
53
+ export const GEOMETRY_SCALES = ['space', 'radius', 'border-width', 'shadow'];
54
+
55
+ export function tokenScale(token, kindRules, wanted) {
56
+ const bare = SIDE_SUFFIXES.find((x) => token.endsWith(x)) ? token.slice(0, token.lastIndexOf('-')) : token;
57
+ const rule = kindRules.find((r) => r.suffixes.some((s) => bare.endsWith(`-${s}`)));
58
+ const scale = rule ? (KIND_SCALE[rule.kind] ?? null) : null;
59
+ return wanted.includes(scale) ? scale : null;
60
+ }
61
+
62
+ // Per-side padding names (`--card-body-padding-top`) are written by the padding
63
+ // selector, never declared by hand, and belong with their parent.
64
+ export const SIDE_SUFFIXES = ['-top', '-right', '-bottom', '-left'];
65
+
66
+ // State tokens that must come *before* the property, never after.
67
+ export const STATE_TOKENS = ['hover', 'disabled', 'selected', 'focus', 'active', 'focused'];
68
+
69
+ export function tokenSuffix(token, known) {
70
+ const bare = SIDE_SUFFIXES.find((x) => token.endsWith(x)) ? token.slice(0, token.lastIndexOf('-')) : token;
71
+ for (const suffix of known) {
72
+ if (bare.endsWith(`-${suffix}`)) return suffix;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Token patterns the editor declares in `intrinsics` — the only tokens allowed a
79
+ * bare keyword instead of a theme token.
80
+ *
81
+ * Matched on each spec's `variable`, not its `key`: the two need not agree
82
+ * (Image's `zoom` key declares `--image-zoom-enabled`), and `variable` is what
83
+ * actually names the token. A `${...}` hole stands for a variant segment.
84
+ */
85
+ export function intrinsicMatchers(editor) {
86
+ const block = editor.match(/export\s+const\s+intrinsics[^=]*=\s*\[([\s\S]*?)\];/);
87
+ if (!block) return [];
88
+ const out = [];
89
+ for (const m of block[1].matchAll(/\bvariable\s*:[^`'"]*[`'"]([^`'"]+)[`'"]/g)) {
90
+ const pattern = m[1]
91
+ .replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === '$' ? '$' : `\\${c}`))
92
+ .replace(/\$\\\{[^}]*\\\}/g, '[a-z0-9-]+')
93
+ .replace(/\$\{[^}]*\}/g, '[a-z0-9-]+');
94
+ out.push(new RegExp(`^${pattern}$`));
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Every custom property the `:global(:root)` blocks declare, comments ignored. */
100
+ export function declaredTokens(blocks) {
101
+ const declared = new Set();
102
+ for (const block of blocks) {
103
+ for (const n of declaredCustomProperties(block.replace(/\/\*[\s\S]*?\*\//g, ' '))) declared.add(n);
104
+ }
105
+ return declared;
106
+ }
107
+
108
+ /**
109
+ * Properties a component declares that nothing in its own file reads. A read is
110
+ * the name appearing outside the `:global(:root)` block: in a `var()`, in a
111
+ * `style:` directive, or as the string a padding mixin takes. SCSS interpolation
112
+ * (`--badge-#{$v}-surface`) reads every property the pattern covers. A per-side
113
+ * padding is read through its parent.
114
+ */
115
+ export function unreadTokens(source, tokens) {
116
+ let body = source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/<!--[\s\S]*?-->/g, ' ');
117
+ for (const block of extractGlobalRootBlocks(body)) body = body.replace(block, ' ');
118
+ const patterns = [...body.matchAll(/--[a-z0-9-]*(?:#\{[^}]*\}[a-z0-9-]*)+/g)].map(
119
+ (m) => new RegExp(`^${m[0].replace(/[.*+?^()|[\]\\]/g, '\\$&').replace(/#\{[^}]*\}/g, '[a-z0-9-]+')}$`),
120
+ );
121
+ const isRead = (name) => body.includes(name) || patterns.some((re) => re.test(name));
122
+ return [...tokens].filter((name) => {
123
+ const side = SIDE_SUFFIXES.find((s) => name.endsWith(s));
124
+ return !isRead(name) && !(side && isRead(name.slice(0, -side.length)));
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Every token the editor names in a row: `variable: '--x'`, the type-group
130
+ * `colorVariable` / `familyVariable` / ... keys, and the arrow form intrinsics
131
+ * use. A `${...}` hole stands for a variant segment. Per-side padding names are
132
+ * written by the padding selector rather than declared, so they resolve to
133
+ * their parent.
134
+ */
135
+ export function editorTokenRefs(editor) {
136
+ const literals = new Set();
137
+ const patterns = [];
138
+ for (const m of editor.matchAll(/\b(?:variable|[a-zA-Z]+Variable)\s*:\s*(?:\([^)]*\)\s*=>\s*)?[`'"](--(?:\$\{[^}]*\}|[^`'"])+)[`'"]/g)) {
139
+ const name = stripSide(m[1]);
140
+ if (name.includes('${')) {
141
+ patterns.push([name, new RegExp(`^${name.replace(/[.*+?^()|[\]\\]/g, '\\$&').replace(/\$\{[^}]*\}/g, '[a-z0-9-]+')}$`)]);
142
+ } else {
143
+ literals.add(name);
144
+ }
145
+ }
146
+ return { literals, patterns };
147
+ }
148
+
149
+ function stripSide(token) {
150
+ const side = SIDE_SUFFIXES.find((x) => token.endsWith(x));
151
+ return side ? token.slice(0, -side.length) : token;
152
+ }
@@ -48,3 +48,12 @@ export function stripVarFallbacks(value) {
48
48
  export function blankStrings(css) {
49
49
  return css.replace(/(["'])(?:\\.|(?!\1)[^\\])*\1/g, (m) => m[0] + ' '.repeat(m.length - 2) + m[0]);
50
50
  }
51
+
52
+ /** The token scale a CSS property draws its colour from. The role inside that
53
+ * scale stays the user's choice. */
54
+ export function colorScaleOfProperty(prop) {
55
+ if (prop === 'color') return 'text';
56
+ if (/^background/.test(prop)) return 'surface';
57
+ if (/^(border|outline)(-|$)/.test(prop)) return 'border';
58
+ return null;
59
+ }
@@ -0,0 +1,126 @@
1
+ // Resolves the project's live-tokens data directory and its optional
2
+ // `live-tokens.testing.ts` settings file, statically — no engine import, no
3
+ // dynamic import of the consumer's own config. Shared by `contractRunner.mjs`
4
+ // (which isolates a copy of this directory before spawning the test tools)
5
+ // and the `config-token` rule in `bin/rules/tokens.mjs` (which reads
6
+ // `component-configs/<id>/default.json` under it directly), so the two agree
7
+ // on which tree a project's saved assignments live in.
8
+
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join, resolve } from 'node:path';
11
+
12
+ export function settingsFilePath(root) {
13
+ for (const name of ['live-tokens.testing.ts', 'live-tokens.testing.mts', 'live-tokens.testing.js', 'live-tokens.testing.mjs']) {
14
+ const path = join(root, name);
15
+ if (existsSync(path)) return path;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ /**
21
+ * A generated config's own extensionless relative imports (Vite/Playwright's
22
+ * loaders resolve those, matching how this repo's own `vite.config.ts` and
23
+ * `live-tokens.testing.ts` are written) only get that treatment for a
24
+ * *static* import. A dynamic `import()` of the same path, called after a Vite
25
+ * config finishes loading, runs through plain Node resolution instead and
26
+ * fails on the same files: verified against `vitest.contract.config.ts` with
27
+ * both a `file://` URL and a plain absolute path as the dynamic specifier,
28
+ * identical `ERR_MODULE_NOT_FOUND` both times. So neither `settings.viteConfig`
29
+ * nor `settings.dataDir` (the resolved values `resolveTestingConfig` computes)
30
+ * has a reader here on purpose — reading either would mean importing the
31
+ * settings file dynamically first, which reintroduces exactly this failure.
32
+ * Regexing the settings file's *source text* for a field, imprecise as that
33
+ * is, is what stays inside the static-import constraint: a wrong guess still
34
+ * fails loudly (a bad `viteConfig` throws on its own static import in the
35
+ * generated file; a bad `dataDir` throws "no data directory at ..." below),
36
+ * never as a silent pass. A settings-level `dataDir` this cannot see at all
37
+ * is worse than one resolved this imprecisely, since a project that names its
38
+ * data directory only in `live-tokens.testing.ts` would otherwise have its
39
+ * contracts checked against whatever happens to sit at the default path.
40
+ */
41
+ function stripComments(text) {
42
+ // Block comments first: a `//` inside one (`/* // note */`) must not seed a
43
+ // second, overlapping strip.
44
+ return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
45
+ }
46
+
47
+ /** Measured across eleven settings-file shapes: a commented-out `dataDir:`
48
+ * above the real one won the regex, because the regex only sees text
49
+ * position, never comment syntax, and a comment naming the field with
50
+ * nothing else present invented a setting out of prose. Comments are
51
+ * stripped before the field ever gets a chance to match. A field present in
52
+ * live code as a template literal, a computed value, or an import throws
53
+ * instead of falling through to a default silently: the two guesses this
54
+ * feeds, `viteConfig` and `dataDir`, can each resolve to a real path that
55
+ * simply names the wrong tree, which then reads as a clean run. */
56
+ export function scrapeSettingsField(settingsPath, fieldName) {
57
+ if (!settingsPath) return null;
58
+ let text;
59
+ try {
60
+ text = readFileSync(settingsPath, 'utf8');
61
+ } catch {
62
+ return null;
63
+ }
64
+ const live = stripComments(text);
65
+ const literal = new RegExp(`\\b${fieldName}\\s*:\\s*['"]([^'"]+)['"]`).exec(live);
66
+ if (literal) return literal[1];
67
+ if (new RegExp(`\\b${fieldName}\\s*:`).test(live)) {
68
+ throw new Error(
69
+ `"${fieldName}" in ${settingsPath} is set to something other than a plain string literal, ` +
70
+ `so it cannot be read statically. Use a literal string, or remove the key to fall back to the default.`,
71
+ );
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * `dataDir` as the plugin resolves it: a `dataDir` field scraped from the
78
+ * settings file (see above), else `live-tokens.config.json`'s own key, else
79
+ * the default. Mirrors `resolveTestingConfig`'s own `configuredDataDir`
80
+ * fallback (`src/testing/config.ts`) rather than importing it: that module
81
+ * compiles into `src/testing-js`, which does not exist until `build:testing`
82
+ * runs, and importing the *source* `.ts` module to reach it hits the same
83
+ * extensionless-import failure documented above — measured with
84
+ * `src/testing-js` moved aside, `resolveTestingEntry`'s `.ts` fallback threw
85
+ * exactly that trying to load `vitest.ts`. Duplicating this small a resolver
86
+ * has precedent in this module already, in `settingsFilePath`.
87
+ */
88
+ export function resolveSourceDataDir(root, settingsPath) {
89
+ const scraped = scrapeSettingsField(settingsPath, 'dataDir');
90
+ if (scraped) return resolve(root, scraped);
91
+ try {
92
+ const parsed = JSON.parse(readFileSync(join(root, 'live-tokens.config.json'), 'utf8'));
93
+ if (parsed && typeof parsed === 'object' && typeof parsed.dataDir === 'string') {
94
+ return resolve(root, parsed.dataDir);
95
+ }
96
+ } catch {
97
+ // Missing or unparseable reads as absent, matching the plugin's own resolver.
98
+ }
99
+ return resolve(root, 'src/live-tokens/data');
100
+ }
101
+
102
+ /**
103
+ * `component-configs/<id>/default.json` under the source data directory, as
104
+ * `{ path, text, data }`, or null when there is none to read. A non-literal
105
+ * `dataDir` in `live-tokens.testing.ts` (a template literal, a computed value)
106
+ * is only resolvable at `--tests` time, when the settings module itself runs;
107
+ * `resolveSourceDataDir` throws rather than guess. A plain-Node rule has no
108
+ * settings module to run, so it reads that as no config instead of taking the
109
+ * whole static lint down with it. Unparseable JSON reads as no config too.
110
+ */
111
+ export function readComponentConfig(root, id) {
112
+ let dataDir;
113
+ try {
114
+ dataDir = resolveSourceDataDir(root, settingsFilePath(root));
115
+ } catch {
116
+ return null;
117
+ }
118
+ const path = join(dataDir, 'component-configs', id, 'default.json');
119
+ if (!existsSync(path)) return null;
120
+ const text = readFileSync(path, 'utf8');
121
+ try {
122
+ return { path, text, data: JSON.parse(text) };
123
+ } catch {
124
+ return null;
125
+ }
126
+ }