@motion-proto/live-tokens 0.68.1 → 0.70.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 (95) hide show
  1. package/.claude/skills/live-tokens-build-page/SKILL.md +16 -5
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +55 -19
  3. package/.claude/skills/live-tokens-create-component/references/token-naming.md +30 -1
  4. package/.claude/skills/live-tokens-fix-findings/SKILL.md +133 -0
  5. package/.claude/skills/live-tokens-pick-component/SKILL.md +10 -1
  6. package/CHANGELOG.md +236 -0
  7. package/README.md +13 -4
  8. package/bin/check-component.mjs +367 -63
  9. package/bin/check-page.mjs +409 -0
  10. package/bin/cli.mjs +107 -9
  11. package/bin/lib/catalogue.mjs +123 -0
  12. package/bin/lib/cssValues.mjs +50 -0
  13. package/bin/lib/findings.mjs +106 -0
  14. package/bin/lib/tokenVocabulary.mjs +240 -0
  15. package/dist-plugin/adjust/index.cjs +174 -23
  16. package/dist-plugin/adjust/index.js +68 -23
  17. package/dist-plugin/{chunk-2UX6EVVA.js → chunk-2YNERPXY.js} +1 -1
  18. package/dist-plugin/{chunk-NE6N66EE.js → chunk-GPIBU44G.js} +107 -1
  19. package/dist-plugin/{chunk-ZHPX7ZYQ.js → chunk-RFVYPNRO.js} +39 -1
  20. package/dist-plugin/generateColorsAndType/index.cjs +107 -1
  21. package/dist-plugin/generateColorsAndType/index.js +1 -1
  22. package/dist-plugin/index.cjs +146 -2
  23. package/dist-plugin/index.js +3 -3
  24. package/dist-plugin/migrateData/index.cjs +107 -1
  25. package/dist-plugin/migrateData/index.js +2 -2
  26. package/dist-plugin/tokensCssMigrations/index.cjs +39 -1
  27. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  28. package/package.json +3 -2
  29. package/src/app/site.css +4 -4
  30. package/src/editor/component-editor/ButtonEditor.svelte +71 -4
  31. package/src/editor/component-editor/CardEditor.svelte +6 -6
  32. package/src/editor/component-editor/DialogEditor.svelte +3 -3
  33. package/src/editor/component-editor/IconButtonEditor.svelte +68 -3
  34. package/src/editor/component-editor/ImageEditor.svelte +8 -8
  35. package/src/editor/component-editor/ImageLightboxEditor.svelte +12 -1
  36. package/src/editor/component-editor/MenuSelectEditor.svelte +63 -3
  37. package/src/editor/component-editor/SegmentedControlEditor.svelte +63 -3
  38. package/src/editor/component-editor/SideNavigationEditor.svelte +67 -3
  39. package/src/editor/component-editor/SliderEditor.svelte +186 -0
  40. package/src/editor/component-editor/TabBarEditor.svelte +63 -3
  41. package/src/editor/component-editor/registry.ts +10 -0
  42. package/src/editor/core/components/aliasKinds.ts +51 -28
  43. package/src/editor/core/sketch/sketchLayer.ts +16 -0
  44. package/src/editor/core/store/editorPersistence.ts +44 -1
  45. package/src/editor/core/store/editorRenderer.ts +2 -2
  46. package/src/editor/core/store/editorStore.ts +18 -18
  47. package/src/editor/core/store/editorTypes.ts +7 -6
  48. package/src/editor/core/themes/migrations/2026-09-01-gate-suffix-enabled.ts +31 -0
  49. package/src/editor/core/themes/migrations/2026-09-01-scrim-rename.ts +52 -0
  50. package/src/editor/core/themes/migrations/2026-09-01-tabbar-active-tint.ts +27 -0
  51. package/src/editor/core/themes/migrations/2026-09-01-tint-rename.ts +42 -0
  52. package/src/editor/core/themes/migrations/index.ts +16 -0
  53. package/src/editor/core/themes/slices/domainVars.ts +2 -2
  54. package/src/editor/core/themes/slices/washes.ts +107 -0
  55. package/src/editor/docs/content/editing-tokens.md +5 -3
  56. package/src/editor/docs/content.generated.ts +1 -1
  57. package/src/editor/index.ts +1 -1
  58. package/src/editor/pages/EditorShell.svelte +1 -1
  59. package/src/editor/ui/SurfacesTab.svelte +3 -3
  60. package/src/editor/ui/UITokenSelector.svelte +1 -0
  61. package/src/editor/ui/VariablesTab.svelte +2 -2
  62. package/src/editor/ui/sections/{OverlaysSection.svelte → WashesSection.svelte} +44 -43
  63. package/src/live-tokens/data/colors-and-type/autumn.json +6 -6
  64. package/src/live-tokens/data/colors-and-type/default.json +6 -6
  65. package/src/live-tokens/data/colors-and-type/halloween.json +6 -6
  66. package/src/live-tokens/data/colors-and-type/midnight-study.json +6 -6
  67. package/src/live-tokens/data/colors-and-type/ocean.json +6 -6
  68. package/src/live-tokens/data/colors-and-type/royal-velvet.json +6 -6
  69. package/src/live-tokens/data/colors-and-type/sketchy.json +6 -6
  70. package/src/live-tokens/data/colors-and-type/spring-meadow.json +6 -6
  71. package/src/live-tokens/data/colors-and-type/sunset.json +6 -6
  72. package/src/live-tokens/data/themes/autumn.json +81 -13
  73. package/src/live-tokens/data/themes/halloween.json +81 -13
  74. package/src/live-tokens/data/themes/midnight-study.json +81 -13
  75. package/src/live-tokens/data/themes/ocean.json +81 -13
  76. package/src/live-tokens/data/themes/royal-velvet.json +81 -13
  77. package/src/live-tokens/data/themes/sketchy.json +81 -13
  78. package/src/live-tokens/data/themes/spring-meadow.json +81 -13
  79. package/src/live-tokens/data/themes/sunset.json +81 -13
  80. package/src/live-tokens/data/tokens.generated.css +6 -6
  81. package/src/system/components/Button.svelte +28 -10
  82. package/src/system/components/Card.svelte +6 -6
  83. package/src/system/components/Dialog.svelte +3 -3
  84. package/src/system/components/IconButton.svelte +23 -7
  85. package/src/system/components/Image.svelte +6 -6
  86. package/src/system/components/MenuSelect.svelte +17 -1
  87. package/src/system/components/SegmentedControl.svelte +20 -2
  88. package/src/system/components/SideNavigation.svelte +22 -2
  89. package/src/system/components/Slider.svelte +348 -0
  90. package/src/system/components/TabBar.svelte +20 -2
  91. package/src/system/styles/CONVENTIONS.md +2 -2
  92. package/src/system/styles/tokens.css +12 -4
  93. package/template/package.json +3 -2
  94. package/template/src/pages/Home.svelte +1 -1
  95. package/src/editor/core/themes/slices/overlays.ts +0 -101
@@ -0,0 +1,409 @@
1
+ // Static validator for a live-tokens page.
2
+ //
3
+ // Asserts that a page satisfies the contract described in the
4
+ // live-tokens-build-page skill: it is assembled from catalogue components, and
5
+ // every value in its CSS is a theme token rather than a literal. The rules and
6
+ // their default severities are in PAGE_RULES; each is overridable per project
7
+ // (live-tokens.config.json) or per run (--off/--warn/--error/--strict), because
8
+ // the line between "wrong" and "deliberate" moves with the project.
9
+ //
10
+ // Returns { findings, checked } — findings carry a stable `rule` id so a skill
11
+ // can parse --json output, fix, and re-run until the exit code is 0.
12
+
13
+ import { existsSync, readFileSync, statSync } from 'node:fs';
14
+ import { join, relative, resolve, basename } from 'node:path';
15
+ import { lineOf } from './lib/findings.mjs';
16
+ import { blankStrings, hasColorLiteral, hasDimensionLiteral, stripVarFallbacks } from './lib/cssValues.mjs';
17
+ import { isContractToken, loadVocabulary, walk } from './lib/tokenVocabulary.mjs';
18
+ import { resolveTokensCssPath } from './migrate.mjs';
19
+
20
+ export const PAGE_RULES = {
21
+ 'unknown-component': 'error',
22
+ 'unknown-prop': 'error',
23
+ 'unknown-prop-value': 'error',
24
+ 'deep-import': 'error',
25
+ 'unknown-token': 'error',
26
+ 'color-literal': 'error',
27
+ 'reserved-route': 'error',
28
+ 'site-css-in-main': 'error',
29
+ 'dimension-literal': 'warn',
30
+ 'hardcoded-columns': 'warn',
31
+ 'raw-text-axis': 'warn',
32
+ 'missing-source': 'warn',
33
+ };
34
+
35
+ // Directories that hold the system, not pages built on it.
36
+ const NOT_PAGES = ['src/system', 'src/editor', 'src/lib', 'src/live-tokens'];
37
+
38
+ const COMPONENT_IMPORT =
39
+ /(?:@motion-proto\/live-tokens\/components|[./][^'"]*\/system\/components)\/([A-Za-z0-9]+)\.svelte$/;
40
+
41
+ const DEEP_IMPORT_PATTERNS = [
42
+ /^@motion-proto\/live-tokens\/src\//,
43
+ /node_modules\/@motion-proto\/live-tokens/,
44
+ ];
45
+
46
+ const TEXT_AXES = ['font-size', 'font-family', 'font-weight', 'line-height', 'letter-spacing'];
47
+
48
+ // The geometry the theme owns: spacing, stroke, radius, and shadow all have a
49
+ // token scale, and `adjust` moves them. Sizing (a hero's height, a column's
50
+ // minimum width, a max content width) is layout, has no scale, and stays
51
+ // literal.
52
+ const THEMED_GEOMETRY = /^(padding|margin|gap|row-gap|column-gap|border|outline|inset|top|right|bottom|left|box-shadow|text-shadow)(-|$)|-radius$/;
53
+
54
+ // A local two-up or three-up is a layout. From four columns on, a hardcoded
55
+ // count reads as a claim about the page grid, which `--columns-count` owns.
56
+ const PAGE_GRID_COLUMNS = 4;
57
+
58
+ /** Blank out comments, url() payloads, and string contents so none of them can match a rule. */
59
+ function neutralise(css) {
60
+ return blankStrings(
61
+ css
62
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
63
+ .replace(/url\((?:[^()]|\([^()]*\))*\)/g, (m) => ' '.repeat(m.length)),
64
+ );
65
+ }
66
+
67
+ /** `<style>` blocks with their absolute offset in the file; whole file for .css. */
68
+ function styleRegions(text, file) {
69
+ if (file.endsWith('.css')) return [{ text, offset: 0 }];
70
+ const out = [];
71
+ for (const m of text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
72
+ out.push({ text: m[1], offset: m.index + m[0].indexOf(m[1]) });
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * Inline styles in markup, as declaration lists the value rules can read: a
79
+ * `style="..."` attribute verbatim, and a `style:prop="value"` directive
80
+ * rewritten as `prop: value;`. A `{...}` expression is dynamic and skipped.
81
+ */
82
+ function inlineStyleRegions(code) {
83
+ const out = [];
84
+ for (const m of code.matchAll(/\sstyle=(["'])([^"']*)\1/g)) {
85
+ out.push({ text: `${m[2]};`, offset: m.index + m[0].indexOf(m[2]) });
86
+ }
87
+ for (const m of code.matchAll(/\sstyle:([a-z-]+)=(["'])([^"']*)\2/g)) {
88
+ out.push({ text: `${m[1]}: ${m[3]};`, offset: m.index + 1 });
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /** Everything outside `<style>`: script and markup. */
94
+ function codeRegion(text, file) {
95
+ if (file.endsWith('.css')) return null;
96
+ return text.replace(/<style[^>]*>[\s\S]*?<\/style>/g, (m) => ' '.repeat(m.length));
97
+ }
98
+
99
+ /**
100
+ * Declarations in a stylesheet, with at-rule preludes excluded. A breakpoint in
101
+ * `@media (max-width: 768px)` is structural geometry, not a themeable value.
102
+ * A property name is read from its start, so `--heading-2xl` is one custom
103
+ * property and never the property `xl`.
104
+ */
105
+ function declarations(css) {
106
+ const body = css.replace(/@[a-z-]+[^;{]*(?=\{)/gi, (m) => ' '.repeat(m.length));
107
+ const out = [];
108
+ for (const m of body.matchAll(/(?<![\w-])((?:--)?[a-z][\w-]*)\s*:\s*([^;{}]+)[;}]/gi)) {
109
+ out.push({ prop: m[1].toLowerCase(), value: m[2].trim(), index: m.index });
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * The attributes of one component tag starting at `start` (the `<`), read with
116
+ * `{}` depth and quotes tracked so an expression holding `>` does not end the
117
+ * tag early. Returns null when the tag spreads an object, which makes its prop
118
+ * set unknowable.
119
+ */
120
+ function tagAttributes(code, start) {
121
+ let i = code.indexOf(' ', start);
122
+ const tagEnd = (() => {
123
+ let depth = 0;
124
+ let quote = null;
125
+ for (let j = start; j < code.length; j++) {
126
+ const c = code[j];
127
+ if (quote) {
128
+ if (c === quote) quote = null;
129
+ } else if (c === '"' || c === "'") quote = c;
130
+ else if (c === '{') depth++;
131
+ else if (c === '}') depth--;
132
+ else if (c === '>' && depth === 0) return j;
133
+ }
134
+ return code.length;
135
+ })();
136
+ if (i === -1 || i > tagEnd) return { attrs: [], end: tagEnd };
137
+ const attrs = [];
138
+ while (i < tagEnd) {
139
+ const c = code[i];
140
+ if (/\s/.test(c) || c === '/') {
141
+ i++;
142
+ continue;
143
+ }
144
+ if (c === '{') {
145
+ let depth = 0;
146
+ let j = i;
147
+ for (; j < tagEnd; j++) {
148
+ if (code[j] === '{') depth++;
149
+ else if (code[j] === '}' && --depth === 0) break;
150
+ }
151
+ const inner = code.slice(i + 1, j).trim();
152
+ if (inner.startsWith('...')) return null;
153
+ if (/^\w+$/.test(inner)) attrs.push({ name: inner, value: null, index: i });
154
+ i = j + 1;
155
+ continue;
156
+ }
157
+ const name = code.slice(i).match(/^[^\s=/>]+/)?.[0];
158
+ if (!name) break;
159
+ const at = i;
160
+ i += name.length;
161
+ let value = null;
162
+ if (code[i] === '=') {
163
+ i++;
164
+ const q = code[i];
165
+ if (q === '"' || q === "'") {
166
+ const close = code.indexOf(q, i + 1);
167
+ value = code.slice(i + 1, close === -1 ? tagEnd : close);
168
+ i = close === -1 ? tagEnd : close + 1;
169
+ } else if (q === '{') {
170
+ let depth = 0;
171
+ for (; i < tagEnd; i++) {
172
+ if (code[i] === '{') depth++;
173
+ else if (code[i] === '}' && --depth === 0) break;
174
+ }
175
+ i++;
176
+ } else {
177
+ const bare = code.slice(i).match(/^[^\s>]+/)?.[0] ?? '';
178
+ value = bare;
179
+ i += bare.length;
180
+ }
181
+ }
182
+ attrs.push({ name, value, index: at });
183
+ }
184
+ return { attrs, end: tagEnd };
185
+ }
186
+
187
+ /** Props a page passes that the component does not declare, or values outside a prop's union. */
188
+ function checkComponentUsage(code, imports, add) {
189
+ for (const [local, entry] of imports) {
190
+ const props = entry.props;
191
+ if (!props) continue;
192
+ const re = new RegExp(`<${local}(?=[\\s/>])`, 'g');
193
+ for (const m of code.matchAll(re)) {
194
+ const tag = tagAttributes(code, m.index);
195
+ if (!tag) continue;
196
+ for (const { name, value, index } of tag.attrs) {
197
+ if (name.includes(':') || name.startsWith('@') || name === 'children') continue;
198
+ if (!props.props.has(name)) {
199
+ add('unknown-prop', index, `${entry.name} has no prop '${name}'; it accepts ${[...props.props].join(', ')}`);
200
+ continue;
201
+ }
202
+ const allowed = props.enums.get(name);
203
+ if (allowed && value !== null && !allowed.has(value)) {
204
+ add('unknown-prop-value', index, `${entry.name} ${name}="${value}" is not one of ${[...allowed].join(', ')}`);
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ /** The object literal enclosing `index`, found by balancing braces outward. */
212
+ function enclosingObject(text, index) {
213
+ let depth = 0;
214
+ let start = -1;
215
+ for (let i = index; i >= 0; i--) {
216
+ const c = text[i];
217
+ if (c === '}') depth++;
218
+ else if (c === '{') {
219
+ if (depth === 0) {
220
+ start = i;
221
+ break;
222
+ }
223
+ depth--;
224
+ }
225
+ }
226
+ if (start === -1) return null;
227
+ depth = 0;
228
+ for (let i = start; i < text.length; i++) {
229
+ const c = text[i];
230
+ if (c === '{') depth++;
231
+ else if (c === '}') {
232
+ depth--;
233
+ if (depth === 0) return text.slice(start, i + 1);
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+
239
+ function checkFile(file, text, vocab, root) {
240
+ const rel = relative(root, file);
241
+ const findings = [];
242
+ const add = (rule, index, message) =>
243
+ findings.push({ rule, file: rel, line: lineOf(text, index), message });
244
+
245
+ const code = codeRegion(text, file);
246
+ if (code !== null) {
247
+ const imports = new Map();
248
+ for (const m of code.matchAll(/import\s+(?:([^'"]*?)\s+from\s+)?['"]([^'"]+)['"]/g)) {
249
+ const spec = m[2];
250
+ for (const pattern of DEEP_IMPORT_PATTERNS) {
251
+ if (pattern.test(spec)) {
252
+ add('deep-import', m.index, `deep import into package internals: ${spec}`);
253
+ }
254
+ }
255
+ const comp = spec.match(COMPONENT_IMPORT);
256
+ if (!comp) continue;
257
+ const entry = vocab.components.get(comp[1].toLowerCase());
258
+ if (!entry) {
259
+ add(
260
+ 'unknown-component',
261
+ m.index,
262
+ `'${comp[1]}' is not in the component catalogue; author it with live-tokens-create-component or pick a shipped one`,
263
+ );
264
+ continue;
265
+ }
266
+ const local = m[1]?.trim().match(/^(\w+)$/)?.[1];
267
+ if (local) imports.set(local, entry);
268
+ }
269
+ checkComponentUsage(code, imports, add);
270
+
271
+ for (const m of code.matchAll(/['"](\/live-tokens[^'"]*)['"]\s*:/g)) {
272
+ add('reserved-route', m.index, `route '${m[1]}' is inside the reserved /live-tokens/* namespace`);
273
+ }
274
+
275
+ for (const m of code.matchAll(/\blazy\s*:/g)) {
276
+ const entry = enclosingObject(code, m.index);
277
+ if (entry && !/\bsource\s*:/.test(entry)) {
278
+ add('missing-source', m.index, `route entry has no 'source', so Page Source cannot open it`);
279
+ }
280
+ }
281
+
282
+ if (/^main\.(ts|js)$/.test(basename(file))) {
283
+ for (const m of code.matchAll(/import\s+['"]([^'"]*site\.css)['"]/g)) {
284
+ add(
285
+ 'site-css-in-main',
286
+ m.index,
287
+ `site.css imported from main; import it from each page's <script> so it cannot leak into editor routes`,
288
+ );
289
+ }
290
+ }
291
+ }
292
+
293
+ // A page may also mint a custom property outside its <style> block — a
294
+ // `style:--x={...}` directive or an el.style.setProperty call — and those are
295
+ // just as declared as one written in CSS.
296
+ const declaredHere = new Set();
297
+ const regions = styleRegions(text, file);
298
+ for (const region of regions) {
299
+ for (const m of neutralise(region.text).matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/gim)) {
300
+ declaredHere.add(m[1]);
301
+ }
302
+ }
303
+ for (const m of text.matchAll(/(?:style:|setProperty\(\s*['"`]|['"`])(--[a-z0-9-]+)/g)) {
304
+ declaredHere.add(m[1]);
305
+ }
306
+
307
+ for (const region of [...regions, ...(code === null ? [] : inlineStyleRegions(code))]) {
308
+ const css = neutralise(region.text);
309
+ const at = (i) => region.offset + i;
310
+
311
+ for (const m of css.matchAll(/var\(\s*(--[a-z0-9-]+)/g)) {
312
+ const name = m[1];
313
+ if (declaredHere.has(name) || vocab.knows(name)) continue;
314
+ add(
315
+ 'unknown-token',
316
+ at(m.index),
317
+ isContractToken(name)
318
+ ? `${name} looks like a theme token but no longer exists; check tokens.css for a rename`
319
+ : `${name} is not a theme token, a component token, or declared in this file`,
320
+ );
321
+ }
322
+
323
+ for (const decl of declarations(css)) {
324
+ const { prop, value, index } = decl;
325
+ if (prop.startsWith('--')) continue;
326
+
327
+ // A `var()` fallback only renders when the token is missing, so a literal
328
+ // inside one is not the page's value.
329
+ const painted = stripVarFallbacks(value);
330
+ if (!TEXT_AXES.includes(prop) && hasColorLiteral(painted)) {
331
+ add('color-literal', at(index), `${prop}: ${value}. Use a theme token, not a colour literal.`);
332
+ continue;
333
+ }
334
+
335
+ // Only absolute type values are a finding. `em`, `%`, and a unitless
336
+ // line-height are relative to the inherited type, so they ride whatever
337
+ // the theme sets rather than overriding it.
338
+ if (
339
+ (TEXT_AXES.includes(prop) || prop === 'font') &&
340
+ !value.includes('var(') &&
341
+ !/^(inherit|initial|unset|normal)$/.test(value) &&
342
+ /\d(px|rem|pt)\b|^[a-z"']/i.test(value)
343
+ ) {
344
+ add(
345
+ 'raw-text-axis',
346
+ at(index),
347
+ `${prop}: ${value}. Set type from a text style bundle (--heading-*, --body-*, --editorial-*).`,
348
+ );
349
+ continue;
350
+ }
351
+
352
+ if (THEMED_GEOMETRY.test(prop) && hasDimensionLiteral(painted)) {
353
+ add(
354
+ 'dimension-literal',
355
+ at(index),
356
+ `${prop}: ${value}. Use a --space-*, --radius-*, --border-width-*, or --shadow-* token.`,
357
+ );
358
+ }
359
+
360
+ const columns = value.match(/\brepeat\(\s*(\d+)\s*,\s*1fr\s*\)/);
361
+ if (columns && Number(columns[1]) >= PAGE_GRID_COLUMNS) {
362
+ add(
363
+ 'hardcoded-columns',
364
+ at(index),
365
+ `${prop}: ${value}. Use repeat(var(--columns-count), 1fr) so the page grid stays in step.`,
366
+ );
367
+ }
368
+ }
369
+ }
370
+
371
+ return findings;
372
+ }
373
+
374
+ // Files that define the vocabulary rather than consume it.
375
+ const TOKEN_SOURCES = ['tokens.generated.css', 'fonts.css'];
376
+
377
+ /** Pages to check when the caller names none: every .svelte/.css under src/ that is not system code. */
378
+ export function discoverPages(root) {
379
+ const src = join(root, 'src');
380
+ if (!existsSync(src)) return [];
381
+ const tokensCss = resolveTokensCssPath(null, null, root);
382
+ return walk(src, ['.svelte', '.css', '.ts', '.js']).filter((f) => {
383
+ const rel = relative(root, f);
384
+ if (NOT_PAGES.some((d) => rel.startsWith(`${d}/`))) return false;
385
+ if (f === tokensCss || TOKEN_SOURCES.includes(basename(f))) return false;
386
+ if (/\.(test|spec)\.[tj]s$/.test(rel)) return false;
387
+ if (rel.endsWith('.ts') || rel.endsWith('.js')) return /main\.(ts|js)$/.test(rel);
388
+ return true;
389
+ });
390
+ }
391
+
392
+ export function checkPages(targets, { root = process.cwd(), vocabulary } = {}) {
393
+ const vocab = vocabulary ?? loadVocabulary({ root });
394
+ const files = [];
395
+ for (const t of targets) {
396
+ const full = resolve(root, t);
397
+ if (!existsSync(full)) continue;
398
+ if (statSync(full).isDirectory()) {
399
+ files.push(...walk(full, ['.svelte', '.css']));
400
+ } else {
401
+ files.push(full);
402
+ }
403
+ }
404
+ const findings = [];
405
+ for (const file of files) {
406
+ findings.push(...checkFile(file, readFileSync(file, 'utf8'), vocab, root));
407
+ }
408
+ return { findings, checked: files.length };
409
+ }
package/bin/cli.mjs CHANGED
@@ -3,17 +3,31 @@
3
3
  // Subcommands:
4
4
  // create <dir> Scaffold a new app that depends on this package.
5
5
  // setup-claude [--force] Copy bundled Claude Code skills into ./.claude/skills/.
6
- // check-component <id> Validate a component against the add-component skill contract.
6
+ // components [id] List every component the project has, shipped and its own, with props and tokens.
7
+ // tokens [--family <name>] List every theme token by family, with its value.
8
+ // check-component [id] Validate a component (or every authored one) against the create-component skill contract.
9
+ // check-page [paths...] Validate pages against the build-page skill contract.
7
10
  // generate-theme <brief> Build a theme from a 10-seed OKLCH brief and open it.
8
11
  // adjust <ops.json> Apply radius/padding/gap/border-width ops to the open buffer.
9
12
  // set-fonts <brief.json> Bind Google Fonts families to the theme's font stacks.
10
13
  // migrate [...] Reconcile tokens.css, the data tree, and route references.
11
14
 
12
- import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
15
+ import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeSync } from 'node:fs';
13
16
  import { dirname, join, resolve } from 'node:path';
14
17
  import { fileURLToPath } from 'node:url';
15
18
  import process from 'node:process';
16
- import { checkComponent, formatReport } from './check-component.mjs';
19
+ import { COMPONENT_RULES, checkComponent, discoverComponents, formatReport } from './check-component.mjs';
20
+ import { PAGE_RULES, checkPages, discoverPages } from './check-page.mjs';
21
+ import { describeComponents, describeTokens, formatComponents, formatTokens } from './lib/catalogue.mjs';
22
+ import { loadVocabulary } from './lib/tokenVocabulary.mjs';
23
+ import {
24
+ applySeverity,
25
+ countBySeverity,
26
+ formatFindings,
27
+ parseCheckFlags,
28
+ readChecksConfig,
29
+ toJson,
30
+ } from './lib/findings.mjs';
17
31
  import {
18
32
  runMigrate,
19
33
  formatMigrateResult,
@@ -32,8 +46,28 @@ Commands:
32
46
  create <dir> [--force] Scaffold a new Svelte + Vite app wired up with
33
47
  live-tokens (editor, components, theme tokens)
34
48
  setup-claude [--force] Install bundled Claude Code skills into ./.claude/skills/
35
- check-component <id> Validate <id>'s runtime, editor, and registration
49
+ components [id] [--json] List every component the project has, shipped and
50
+ its own (src/system/components plus any
51
+ "componentDirs" in live-tokens.config.json), with
52
+ the props each takes; with an id, that component's
53
+ props, variants, tokens, and defaults
54
+ tokens [--family <name>] [--json]
55
+ List every theme token the project's tokens.css
56
+ declares, by family, with its value
57
+ check-component [id] Validate <id>'s runtime, editor, and registration
36
58
  against the live-tokens-create-component contract
59
+ check-page [paths...] Validate pages against the live-tokens-build-page
60
+ contract: catalogue components only, and every CSS
61
+ value a theme token. Checks every page under src/
62
+ when given no paths.
63
+
64
+ Both check commands accept:
65
+ --json Machine-readable findings, for a skill to iterate
66
+ against until the exit code is 0
67
+ --strict Treat warnings as errors
68
+ --off=<rule,...> Silence rules; --warn=/--error= change severity
69
+ (or set "checks": { "rules": {...} } in
70
+ live-tokens.config.json)
37
71
  generate-theme <brief.json> [--no-activate] [--dry-run] [--carry-from <name>]
38
72
  Build a full theme from a 10-seed OKLCH brief
39
73
  (see the live-tokens-generate-theme skill),
@@ -79,6 +113,20 @@ Commands:
79
113
  either is pending; route findings are advisory).
80
114
  `;
81
115
 
116
+ // A large body written through console.log is cut at the pipe buffer when the
117
+ // process exits before stdout drains, so a query writes synchronously.
118
+ function writeOut(text) {
119
+ const buf = Buffer.from(`${text}\n`);
120
+ let offset = 0;
121
+ while (offset < buf.length) {
122
+ try {
123
+ offset += writeSync(1, buf, offset, buf.length - offset);
124
+ } catch (error) {
125
+ if (error.code !== 'EAGAIN') throw error;
126
+ }
127
+ }
128
+ }
129
+
82
130
  function fail(message, code = 1) {
83
131
  console.error(message);
84
132
  process.exit(code);
@@ -109,12 +157,61 @@ if (command === 'create' || command === 'init') {
109
157
  }
110
158
  }
111
159
 
160
+ function reportChecks(label, findings, checked, rules, opts) {
161
+ const resolved = applySeverity(findings, rules, opts, readChecksConfig(process.cwd()));
162
+ console.log(
163
+ opts.json
164
+ ? toJson(resolved, { label, checked })
165
+ : formatFindings(resolved, { label, checked }),
166
+ );
167
+ process.exit(countBySeverity(resolved).errors === 0 ? 0 : 1);
168
+ }
169
+
170
+ if (command === 'components') {
171
+ const opts = parseCheckFlags(rest);
172
+ const list = describeComponents(loadVocabulary());
173
+ const id = opts.rest[0];
174
+ if (id && !list.some((c) => c.id === id)) fail(formatComponents(list, { id }));
175
+ writeOut(opts.json ? JSON.stringify(id ? list.find((c) => c.id === id) : list, null, 2) : formatComponents(list, { id }));
176
+ process.exit(0);
177
+ }
178
+
179
+ if (command === 'tokens') {
180
+ const opts = parseCheckFlags(rest);
181
+ const at = opts.rest.indexOf('--family');
182
+ const family = at >= 0 ? opts.rest[at + 1] : undefined;
183
+ const desc = describeTokens(loadVocabulary());
184
+ if (family && !desc.families.some((f) => f.family === family)) fail(formatTokens(desc, { family }));
185
+ writeOut(
186
+ opts.json
187
+ ? JSON.stringify(family ? desc.families.find((f) => f.family === family) : desc, null, 2)
188
+ : formatTokens(desc, { family }),
189
+ );
190
+ process.exit(0);
191
+ }
192
+
112
193
  if (command === 'check-component') {
113
- const id = rest[0];
114
- if (!id) fail(`Usage: npx @motion-proto/live-tokens check-component <id>`);
115
- const result = checkComponent(id);
116
- console.log(formatReport(id, result));
117
- process.exit(result.errors.length === 0 ? 0 : 1);
194
+ const opts = parseCheckFlags(rest);
195
+ const ids = opts.rest.length > 0 ? [opts.rest[0]] : discoverComponents();
196
+ if (ids.length === 0) {
197
+ console.log('✓ check-component: no component authored under src/system/components yet.');
198
+ process.exit(0);
199
+ }
200
+ const results = ids.map((id) => [id, checkComponent(id)]);
201
+ if (ids.length === 1 && !opts.json && !opts.strict && opts.off.length + opts.warn.length + opts.error.length === 0) {
202
+ const [id, result] = results[0];
203
+ console.log(formatReport(id, result));
204
+ process.exit(result.errors.length === 0 ? 0 : 1);
205
+ }
206
+ const label = ids.length === 1 ? `check-component ${ids[0]}` : 'check-component';
207
+ reportChecks(label, results.flatMap(([, r]) => r.findings), ids.length, COMPONENT_RULES, opts);
208
+ }
209
+
210
+ if (command === 'check-page') {
211
+ const opts = parseCheckFlags(rest);
212
+ const targets = opts.rest.length > 0 ? opts.rest : discoverPages(process.cwd());
213
+ const { findings, checked } = checkPages(targets, { root: process.cwd() });
214
+ reportChecks('check-page', findings, checked, PAGE_RULES, opts);
118
215
  }
119
216
 
120
217
  if (command === 'generate-theme') {
@@ -270,6 +367,7 @@ const SAMPLE_PROMPTS = {
270
367
  'live-tokens-generate-theme': 'make me a bright and cheerful theme',
271
368
  'live-tokens-adjust-geometry': 'make the buttons pill shaped',
272
369
  'live-tokens-pair-fonts': 'pair some fonts for this theme',
370
+ 'live-tokens-fix-findings': 'make check:design pass',
273
371
  };
274
372
 
275
373
  const installedSamples = skills
@@ -0,0 +1,123 @@
1
+ // The registry as a query. Every component a project has, shipped or its own,
2
+ // with the props each takes and the tokens each declares, and every theme token
3
+ // grouped by family. Read from files through the same vocabulary the checkers
4
+ // use, so a skill or a script sees exactly what the checkers will hold it to.
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import { relative } from 'node:path';
8
+ import { CONTRACT_FAMILIES } from './tokenVocabulary.mjs';
9
+
10
+ /** The runtime file's leading HTML comment, which is where a component says what it is for. */
11
+ function descriptionOf(source) {
12
+ const m = source.match(/^\s*<!--([\s\S]*?)-->/);
13
+ if (!m) return '';
14
+ return m[1]
15
+ .split('\n')
16
+ .map((line) => line.trim())
17
+ .join(' ')
18
+ .replace(/\s+/g, ' ')
19
+ .trim()
20
+ .replace(/^\S+\.svelte\s*[—–-]+\s*/, '');
21
+ }
22
+
23
+ function familyOf(name) {
24
+ const stem = name.replace(/^--/, '');
25
+ const hit = CONTRACT_FAMILIES
26
+ .filter((f) => stem === f || stem.startsWith(`${f}-`))
27
+ .sort((a, b) => b.length - a.length)[0];
28
+ return hit ?? stem.split('-')[0];
29
+ }
30
+
31
+ export function describeComponents(vocab, { root = process.cwd() } = {}) {
32
+ const out = [];
33
+ for (const entry of vocab.components.values()) {
34
+ const source = readFileSync(entry.file, 'utf8');
35
+ const props = entry.props
36
+ ? [...entry.props.props].map((name) => ({
37
+ name,
38
+ type: entry.props.types.get(name) ?? '',
39
+ values: entry.props.enums.has(name) ? [...entry.props.enums.get(name)] : undefined,
40
+ }))
41
+ : [];
42
+ out.push({
43
+ id: entry.id,
44
+ name: entry.name,
45
+ origin: entry.origin,
46
+ file: relative(root, entry.file),
47
+ registered: entry.origin === 'shipped' || vocab.registered.has(entry.id),
48
+ description: descriptionOf(source),
49
+ variants: entry.props?.enums.get('variant') ? [...entry.props.enums.get('variant')] : [],
50
+ props,
51
+ tokens: [...entry.tokens].map(([name, value]) => ({ name, default: value })),
52
+ });
53
+ }
54
+ return out.sort((a, b) => a.origin.localeCompare(b.origin) || a.id.localeCompare(b.id));
55
+ }
56
+
57
+ export function describeTokens(vocab, { root = process.cwd() } = {}) {
58
+ const values = new Map();
59
+ if (vocab.tokensCssPath) {
60
+ const css = readFileSync(vocab.tokensCssPath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ' ');
61
+ for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
62
+ if (!values.has(m[1])) values.set(m[1], m[2].trim());
63
+ }
64
+ }
65
+ const byFamily = new Map();
66
+ for (const name of vocab.themeTokens) {
67
+ const family = familyOf(name);
68
+ if (!byFamily.has(family)) byFamily.set(family, []);
69
+ byFamily.get(family).push({ name, value: values.get(name) ?? '' });
70
+ }
71
+ return {
72
+ tokensCss: vocab.tokensCssPath ? relative(root, vocab.tokensCssPath) : null,
73
+ families: [...byFamily].map(([family, tokens]) => ({ family, tokens })),
74
+ components: [...vocab.components.values()].map((c) => ({
75
+ id: c.id,
76
+ tokens: [...c.tokens].map(([name, value]) => ({ name, default: value })),
77
+ })),
78
+ };
79
+ }
80
+
81
+ export function formatComponents(list, { id } = {}) {
82
+ const lines = [];
83
+ if (id) {
84
+ const c = list.find((x) => x.id === id);
85
+ if (!c) return `No component "${id}". Run \`live-tokens components\` for the list.`;
86
+ lines.push(`${c.name} (${c.id}, ${c.origin}${c.registered ? '' : ', NOT registered'}) ${c.file}`);
87
+ if (c.description) lines.push(` ${c.description}`);
88
+ if (c.props.length) {
89
+ lines.push(' props:');
90
+ for (const p of c.props) lines.push(` ${p.name}${p.values ? `: ${p.values.join(' | ')}` : p.type ? `: ${p.type}` : ''}`);
91
+ }
92
+ lines.push(` tokens (${c.tokens.length}):`);
93
+ for (const t of c.tokens) lines.push(` ${t.name}: ${t.default}`);
94
+ return lines.join('\n');
95
+ }
96
+ for (const c of list) {
97
+ const variants = c.variants.length ? ` variants: ${c.variants.join(', ')}` : '';
98
+ lines.push(`${c.id.padEnd(20)} ${c.origin.padEnd(8)} ${c.name}${c.registered ? '' : ' (NOT registered)'}${variants}`);
99
+ if (c.description) lines.push(`${''.padEnd(29)} ${c.description}`);
100
+ }
101
+ lines.push('');
102
+ lines.push(`${list.length} component(s). \`live-tokens components <id>\` prints one with its props and tokens.`);
103
+ return lines.join('\n');
104
+ }
105
+
106
+ export function formatTokens(desc, { family } = {}) {
107
+ const lines = [];
108
+ const families = family ? desc.families.filter((f) => f.family === family) : desc.families;
109
+ if (family && families.length === 0) {
110
+ return `No family "${family}". Families: ${desc.families.map((f) => f.family).join(', ')}.`;
111
+ }
112
+ lines.push(`Theme tokens from ${desc.tokensCss ?? '(no tokens.css found)'}`);
113
+ for (const f of families) {
114
+ lines.push('');
115
+ lines.push(`${f.family} (${f.tokens.length})`);
116
+ for (const t of f.tokens) lines.push(` ${t.name}: ${t.value}`);
117
+ }
118
+ if (!family) {
119
+ lines.push('');
120
+ lines.push(`Component tokens: ${desc.components.reduce((n, c) => n + c.tokens.length, 0)} across ${desc.components.length} component(s). \`live-tokens components <id>\` lists one component's.`);
121
+ }
122
+ return lines.join('\n');
123
+ }