@motion-proto/live-tokens 0.67.1 → 0.69.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 (138) hide show
  1. package/.claude/skills/live-tokens-build-page/SKILL.md +16 -5
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +43 -8
  3. package/.claude/skills/live-tokens-create-component/references/token-naming.md +30 -1
  4. package/.claude/skills/live-tokens-fix-findings/SKILL.md +131 -0
  5. package/.claude/skills/live-tokens-pick-component/SKILL.md +2 -1
  6. package/CHANGELOG.md +239 -0
  7. package/README.md +11 -4
  8. package/bin/check-component.mjs +367 -63
  9. package/bin/check-page.mjs +409 -0
  10. package/bin/cli.mjs +57 -8
  11. package/bin/generate-theme.mjs +5 -4
  12. package/bin/lib/cssValues.mjs +50 -0
  13. package/bin/lib/findings.mjs +106 -0
  14. package/bin/lib/tokenVocabulary.mjs +213 -0
  15. package/dist-plugin/adjust/index.cjs +174 -23
  16. package/dist-plugin/adjust/index.d.cts +1 -1
  17. package/dist-plugin/adjust/index.d.ts +1 -1
  18. package/dist-plugin/adjust/index.js +68 -23
  19. package/dist-plugin/{chunk-J2JT4UEA.js → chunk-2YNERPXY.js} +26 -26
  20. package/dist-plugin/{chunk-NE6N66EE.js → chunk-GPIBU44G.js} +107 -1
  21. package/dist-plugin/{chunk-ZHPX7ZYQ.js → chunk-RFVYPNRO.js} +39 -1
  22. package/dist-plugin/{dataPaths-DZUzVv8H.d.ts → dataPaths-bJTCEO4H.d.cts} +1 -1
  23. package/dist-plugin/{dataPaths-DZUzVv8H.d.cts → dataPaths-bJTCEO4H.d.ts} +1 -1
  24. package/dist-plugin/fontPairing/index.d.cts +1 -1
  25. package/dist-plugin/fontPairing/index.d.ts +1 -1
  26. package/dist-plugin/generateColorsAndType/index.cjs +107 -1
  27. package/dist-plugin/generateColorsAndType/index.d.cts +1 -1
  28. package/dist-plugin/generateColorsAndType/index.d.ts +1 -1
  29. package/dist-plugin/generateColorsAndType/index.js +1 -1
  30. package/dist-plugin/index.cjs +173 -29
  31. package/dist-plugin/index.d.cts +1 -1
  32. package/dist-plugin/index.d.ts +1 -1
  33. package/dist-plugin/index.js +7 -7
  34. package/dist-plugin/migrateData/index.cjs +132 -26
  35. package/dist-plugin/migrateData/index.d.cts +1 -1
  36. package/dist-plugin/migrateData/index.d.ts +1 -1
  37. package/dist-plugin/migrateData/index.js +2 -2
  38. package/dist-plugin/tokensCssMigrations/index.cjs +39 -1
  39. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  40. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  41. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  42. package/package.json +6 -5
  43. package/src/app/site.css +4 -4
  44. package/src/editor/bootstrap.ts +6 -6
  45. package/src/editor/component-editor/ButtonEditor.svelte +71 -4
  46. package/src/editor/component-editor/CardEditor.svelte +6 -6
  47. package/src/editor/component-editor/DialogEditor.svelte +3 -3
  48. package/src/editor/component-editor/IconButtonEditor.svelte +68 -3
  49. package/src/editor/component-editor/ImageEditor.svelte +8 -8
  50. package/src/editor/component-editor/ImageLightboxEditor.svelte +13 -2
  51. package/src/editor/component-editor/MenuSelectEditor.svelte +63 -3
  52. package/src/editor/component-editor/SegmentedControlEditor.svelte +63 -3
  53. package/src/editor/component-editor/SideNavigationEditor.svelte +67 -3
  54. package/src/editor/component-editor/SliderEditor.svelte +186 -0
  55. package/src/editor/component-editor/TabBarEditor.svelte +63 -3
  56. package/src/editor/component-editor/registry.ts +10 -0
  57. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +4 -4
  58. package/src/editor/core/components/aliasKinds.ts +51 -28
  59. package/src/editor/core/fonts/fontLoader.ts +2 -2
  60. package/src/editor/core/preview/{lookPreview.ts → themePreview.ts} +27 -27
  61. package/src/editor/core/productionPulse.ts +3 -3
  62. package/src/editor/core/sketch/index.ts +52 -44
  63. package/src/editor/core/sketch/maskField.ts +9 -9
  64. package/src/editor/core/sketch/sketchLayer.ts +25 -9
  65. package/src/editor/core/sketch/sketchRegistry.ts +34 -34
  66. package/src/editor/core/sketch/sketchStore.ts +98 -83
  67. package/src/editor/core/sketch/sketchStyleService.ts +4 -4
  68. package/src/editor/core/sketch/sketchStyles.ts +28 -28
  69. package/src/editor/core/store/editorPersistence.ts +44 -1
  70. package/src/editor/core/store/editorRenderer.ts +2 -2
  71. package/src/editor/core/store/editorStore.ts +18 -18
  72. package/src/editor/core/store/editorTypes.ts +7 -6
  73. package/src/editor/core/themes/colorsAndTypeService.ts +1 -1
  74. package/src/editor/core/themes/loadRows.ts +8 -8
  75. package/src/editor/core/themes/migrations/2026-09-01-gate-suffix-enabled.ts +31 -0
  76. package/src/editor/core/themes/migrations/2026-09-01-scrim-rename.ts +52 -0
  77. package/src/editor/core/themes/migrations/2026-09-01-tabbar-active-tint.ts +27 -0
  78. package/src/editor/core/themes/migrations/2026-09-01-tint-rename.ts +42 -0
  79. package/src/editor/core/themes/migrations/index.ts +16 -0
  80. package/src/editor/core/themes/slices/domainVars.ts +2 -2
  81. package/src/editor/core/themes/slices/washes.ts +107 -0
  82. package/src/editor/core/themes/themeDocumentSync.ts +2 -2
  83. package/src/editor/core/themes/themeInit.ts +2 -2
  84. package/src/editor/core/themes/themeService.ts +17 -17
  85. package/src/editor/core/themes/{lookSummary.ts → themeSummary.ts} +9 -9
  86. package/src/editor/core/themes/themeTypes.ts +10 -6
  87. package/src/editor/docs/content/editing-tokens.md +5 -3
  88. package/src/editor/docs/content/getting-started.md +1 -1
  89. package/src/editor/docs/content/sketch-mode.md +22 -17
  90. package/src/editor/docs/content/themes-workflow.md +11 -11
  91. package/src/editor/docs/content/where-themes-live.md +6 -6
  92. package/src/editor/docs/content.generated.ts +5 -5
  93. package/src/editor/index.ts +2 -2
  94. package/src/editor/pages/EditorShell.svelte +1 -1
  95. package/src/editor/ui/SurfacesTab.svelte +3 -3
  96. package/src/editor/ui/ThemePanel.svelte +52 -52
  97. package/src/editor/ui/UITokenSelector.svelte +1 -0
  98. package/src/editor/ui/VariablesTab.svelte +2 -2
  99. package/src/editor/ui/sections/{OverlaysSection.svelte → WashesSection.svelte} +44 -43
  100. package/src/editor/ui/sketch/SketchPreview.svelte +2 -2
  101. package/src/editor/ui/sketch/SketchTab.svelte +64 -38
  102. package/src/live-tokens/data/colors-and-type/autumn.json +6 -6
  103. package/src/live-tokens/data/colors-and-type/default.json +6 -6
  104. package/src/live-tokens/data/colors-and-type/halloween.json +6 -6
  105. package/src/live-tokens/data/colors-and-type/midnight-study.json +6 -6
  106. package/src/live-tokens/data/colors-and-type/ocean.json +6 -6
  107. package/src/live-tokens/data/colors-and-type/royal-velvet.json +6 -6
  108. package/src/live-tokens/data/colors-and-type/sketchy.json +6 -6
  109. package/src/live-tokens/data/colors-and-type/spring-meadow.json +6 -6
  110. package/src/live-tokens/data/colors-and-type/sunset.json +6 -6
  111. package/src/live-tokens/data/sketch-styles/dry.json +5 -5
  112. package/src/live-tokens/data/sketch-styles/hatched.json +9 -9
  113. package/src/live-tokens/data/themes/autumn.json +82 -14
  114. package/src/live-tokens/data/themes/halloween.json +82 -14
  115. package/src/live-tokens/data/themes/midnight-study.json +82 -14
  116. package/src/live-tokens/data/themes/ocean.json +82 -14
  117. package/src/live-tokens/data/themes/royal-velvet.json +82 -14
  118. package/src/live-tokens/data/themes/sketchy.json +82 -14
  119. package/src/live-tokens/data/themes/spring-meadow.json +82 -14
  120. package/src/live-tokens/data/themes/sunset.json +82 -14
  121. package/src/live-tokens/data/tokens.generated.css +6 -6
  122. package/src/system/backdrop/backdrop.ts +1 -1
  123. package/src/system/components/Button.svelte +28 -10
  124. package/src/system/components/Card.svelte +6 -6
  125. package/src/system/components/Dialog.svelte +3 -3
  126. package/src/system/components/IconButton.svelte +23 -7
  127. package/src/system/components/Image.svelte +6 -6
  128. package/src/system/components/MenuSelect.svelte +17 -1
  129. package/src/system/components/SectionDivider.svelte +1 -1
  130. package/src/system/components/SegmentedControl.svelte +20 -2
  131. package/src/system/components/SideNavigation.svelte +22 -2
  132. package/src/system/components/Slider.svelte +348 -0
  133. package/src/system/components/TabBar.svelte +20 -2
  134. package/src/system/styles/CONVENTIONS.md +2 -2
  135. package/src/system/styles/tokens.css +12 -4
  136. package/template/package.json +3 -2
  137. package/template/src/pages/Home.svelte +1 -1
  138. 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,7 +3,8 @@
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
+ // check-component [id] Validate a component (or every authored one) against the create-component skill contract.
7
+ // check-page [paths...] Validate pages against the build-page skill contract.
7
8
  // generate-theme <brief> Build a theme from a 10-seed OKLCH brief and open it.
8
9
  // adjust <ops.json> Apply radius/padding/gap/border-width ops to the open buffer.
9
10
  // set-fonts <brief.json> Bind Google Fonts families to the theme's font stacks.
@@ -13,7 +14,16 @@ import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
13
14
  import { dirname, join, resolve } from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import process from 'node:process';
16
- import { checkComponent, formatReport } from './check-component.mjs';
17
+ import { COMPONENT_RULES, checkComponent, discoverComponents, formatReport } from './check-component.mjs';
18
+ import { PAGE_RULES, checkPages, discoverPages } from './check-page.mjs';
19
+ import {
20
+ applySeverity,
21
+ countBySeverity,
22
+ formatFindings,
23
+ parseCheckFlags,
24
+ readChecksConfig,
25
+ toJson,
26
+ } from './lib/findings.mjs';
17
27
  import {
18
28
  runMigrate,
19
29
  formatMigrateResult,
@@ -32,8 +42,20 @@ Commands:
32
42
  create <dir> [--force] Scaffold a new Svelte + Vite app wired up with
33
43
  live-tokens (editor, components, theme tokens)
34
44
  setup-claude [--force] Install bundled Claude Code skills into ./.claude/skills/
35
- check-component <id> Validate <id>'s runtime, editor, and registration
45
+ check-component [id] Validate <id>'s runtime, editor, and registration
36
46
  against the live-tokens-create-component contract
47
+ check-page [paths...] Validate pages against the live-tokens-build-page
48
+ contract: catalogue components only, and every CSS
49
+ value a theme token. Checks every page under src/
50
+ when given no paths.
51
+
52
+ Both check commands accept:
53
+ --json Machine-readable findings, for a skill to iterate
54
+ against until the exit code is 0
55
+ --strict Treat warnings as errors
56
+ --off=<rule,...> Silence rules; --warn=/--error= change severity
57
+ (or set "checks": { "rules": {...} } in
58
+ live-tokens.config.json)
37
59
  generate-theme <brief.json> [--no-activate] [--dry-run] [--carry-from <name>]
38
60
  Build a full theme from a 10-seed OKLCH brief
39
61
  (see the live-tokens-generate-theme skill),
@@ -109,12 +131,38 @@ if (command === 'create' || command === 'init') {
109
131
  }
110
132
  }
111
133
 
134
+ function reportChecks(label, findings, checked, rules, opts) {
135
+ const resolved = applySeverity(findings, rules, opts, readChecksConfig(process.cwd()));
136
+ console.log(
137
+ opts.json
138
+ ? toJson(resolved, { label, checked })
139
+ : formatFindings(resolved, { label, checked }),
140
+ );
141
+ process.exit(countBySeverity(resolved).errors === 0 ? 0 : 1);
142
+ }
143
+
112
144
  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);
145
+ const opts = parseCheckFlags(rest);
146
+ const ids = opts.rest.length > 0 ? [opts.rest[0]] : discoverComponents();
147
+ if (ids.length === 0) {
148
+ console.log('✓ check-component: no component authored under src/system/components yet.');
149
+ process.exit(0);
150
+ }
151
+ const results = ids.map((id) => [id, checkComponent(id)]);
152
+ if (ids.length === 1 && !opts.json && !opts.strict && opts.off.length + opts.warn.length + opts.error.length === 0) {
153
+ const [id, result] = results[0];
154
+ console.log(formatReport(id, result));
155
+ process.exit(result.errors.length === 0 ? 0 : 1);
156
+ }
157
+ const label = ids.length === 1 ? `check-component ${ids[0]}` : 'check-component';
158
+ reportChecks(label, results.flatMap(([, r]) => r.findings), ids.length, COMPONENT_RULES, opts);
159
+ }
160
+
161
+ if (command === 'check-page') {
162
+ const opts = parseCheckFlags(rest);
163
+ const targets = opts.rest.length > 0 ? opts.rest : discoverPages(process.cwd());
164
+ const { findings, checked } = checkPages(targets, { root: process.cwd() });
165
+ reportChecks('check-page', findings, checked, PAGE_RULES, opts);
118
166
  }
119
167
 
120
168
  if (command === 'generate-theme') {
@@ -270,6 +318,7 @@ const SAMPLE_PROMPTS = {
270
318
  'live-tokens-generate-theme': 'make me a bright and cheerful theme',
271
319
  'live-tokens-adjust-geometry': 'make the buttons pill shaped',
272
320
  'live-tokens-pair-fonts': 'pair some fonts for this theme',
321
+ 'live-tokens-fix-findings': 'make check:design pass',
273
322
  };
274
323
 
275
324
  const installedSamples = skills
@@ -3,7 +3,7 @@
3
3
  // Reads a seed brief (JSON), builds a full validated colors-and-type layer via
4
4
  // the compiled engine (dist-plugin/generateColorsAndType — the CLI never imports
5
5
  // TS sources), enforces the AA contrast gate, and saves the result as a theme:
6
- // <themesDir>/<slug>.json, the document that carries the whole look by value.
6
+ // <themesDir>/<slug>.json, the document that carries the whole theme by value.
7
7
  // Unless --no-activate it then opens that theme the way the dev server's apply
8
8
  // door does — existing `_working` buffers are cleared and
9
9
  // `themes/_active.json` names it. Nothing else moves, so generating a theme
@@ -21,9 +21,10 @@ import { fileURLToPath } from 'node:url';
21
21
  const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
22
22
  const ENGINE = resolve(pkgRoot, 'dist-plugin/generateColorsAndType/index.js');
23
23
  const packageDataDir = join(pkgRoot, 'src/live-tokens/data');
24
- // Source of truth: vite-plugin/themes/normalizeTheme.ts. This copy cannot
25
- // import TS, so `check:preset-themes` (Wave 5) is what catches a drift.
26
- const THEME_SCHEMA_VERSION = 4;
24
+ // Source of truth: src/editor/core/themes/themeTypes.ts, which
25
+ // normalizeTheme.ts re-exports. This copy cannot import TS, so
26
+ // `check:preset-themes` and generate-theme.test.ts are what catch a drift.
27
+ const THEME_SCHEMA_VERSION = 5;
27
28
 
28
29
  async function loadEngine() {
29
30
  if (!existsSync(ENGINE)) {
@@ -0,0 +1,50 @@
1
+ // Value-level parsing shared by check-page and check-component, so the two
2
+ // checkers can never disagree about what counts as a literal.
3
+
4
+ // `transparent` and `currentcolor` are left out: neither paints a colour of its own.
5
+ const NAMED_COLORS =
6
+ 'aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen';
7
+ const NAMED_COLOR = new RegExp(`(?<![\\w-])(?:${NAMED_COLORS.split(' ').join('|')})(?![\\w-])`);
8
+
9
+ /** True when `value` paints a literal colour: hex, a colour function, or a named colour. */
10
+ export function hasColorLiteral(value) {
11
+ return /#[0-9a-f]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(|\boklab\(/i.test(value) || NAMED_COLOR.test(value);
12
+ }
13
+
14
+ /** True when `value` pins a non-zero px or rem length outside any token. */
15
+ export function hasDimensionLiteral(value) {
16
+ return [...value.matchAll(/(?<![\w.-])(\d*\.?\d+)(px|rem)\b/g)].some((d) => parseFloat(d[1]) !== 0);
17
+ }
18
+
19
+ /**
20
+ * Replace `var(--x, <fallback>)` with `var(--x)`. A fallback only renders when
21
+ * the token is missing, so its literals are not the value that paints.
22
+ */
23
+ export function stripVarFallbacks(value) {
24
+ let out = '';
25
+ for (let i = 0; i < value.length; i++) {
26
+ if (!value.startsWith('var(', i)) {
27
+ out += value[i];
28
+ continue;
29
+ }
30
+ let depth = 0;
31
+ let comma = -1;
32
+ let j = i;
33
+ for (; j < value.length; j++) {
34
+ const c = value[j];
35
+ if (c === '(') depth++;
36
+ else if (c === ')') {
37
+ depth--;
38
+ if (depth === 0) break;
39
+ } else if (c === ',' && depth === 1 && comma === -1) comma = j;
40
+ }
41
+ out += comma === -1 ? value.slice(i, j + 1) : `${value.slice(i, comma)})`;
42
+ i = j;
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Blank string literal contents so a colour word inside `content: "..."` never matches a rule. */
48
+ export function blankStrings(css) {
49
+ return css.replace(/(["'])(?:\\.|(?!\1)[^\\])*\1/g, (m) => m[0] + ' '.repeat(m.length - 2) + m[0]);
50
+ }