@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
@@ -12,442 +12,78 @@
12
12
 
13
13
  import { existsSync, readFileSync, statSync } from 'node:fs';
14
14
  import { join, relative, resolve, basename } from 'node:path';
15
- import { isExcluded, lineOf } from './lib/findings.mjs';
16
- import { blankStrings, hasColorLiteral, hasDimensionLiteral, stripVarFallbacks } from './lib/cssValues.mjs';
17
- import { isContractToken, loadVocabulary, walk } from './lib/tokenVocabulary.mjs';
15
+ import { assembleRules, isExcluded, lineOf } from './lib/findings.mjs';
16
+ import { codeRegion, inlineStyleRegions, styleRegions } from './lib/pageSource.mjs';
17
+ import { loadVocabulary, walk } from './lib/tokenVocabulary.mjs';
18
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
- 'raw-text-axis': 'error',
30
- 'dimension-literal': 'warn',
31
- 'hardcoded-columns': 'warn',
32
- 'missing-source': 'warn',
33
- 'control-size': 'warn',
34
- 'multiple-primary': 'warn',
35
- 'danger-without-dialog': 'warn',
36
- };
37
-
38
- // Directories that hold the system, not pages built on it.
39
- const NOT_PAGES = ['src/system', 'src/editor', 'src/lib', 'src/live-tokens'];
40
-
41
- export const COMPONENT_IMPORT =
42
- /(?:@motion-proto\/live-tokens\/components|[./][^'"]*\/system\/components)\/([A-Za-z0-9]+)\.svelte$/;
43
-
44
- const DEEP_IMPORT_PATTERNS = [
45
- /^@motion-proto\/live-tokens\/src\//,
46
- /node_modules\/@motion-proto\/live-tokens/,
47
- ];
48
-
49
- const TEXT_AXES = ['font-size', 'font-family', 'font-weight', 'line-height', 'letter-spacing'];
50
-
51
- // The single-axis scales in tokens.css. A text style bundle carries its axis
52
- // as a suffix (--body-md-font-size, --code-font-family), so no bundle name
53
- // matches, and neither does a custom property the page declares itself. A
54
- // weight alone cannot move the scale or the fonts, so --font-weight-* is not one.
55
- const SINGLE_AXIS_TOKEN =
56
- /^--(?:font-size|line-height|letter-spacing)-|^--font-(?:sans|serif|mono|display|editorial)$/;
57
-
58
- // The geometry the theme owns: spacing, stroke, radius, and shadow all have a
59
- // token scale, and `set-geometry` moves them. Sizing (a hero's height, a
60
- // column's minimum width, a max content width) is layout, has no scale, and
61
- // stays literal.
62
- const THEMED_GEOMETRY = /^(padding|margin|gap|row-gap|column-gap|border|outline|inset|top|right|bottom|left|box-shadow|text-shadow)(-|$)|-radius$/;
63
-
64
- // A local two-up or three-up is a layout. From four columns on, a hardcoded
65
- // count reads as a claim about the page grid, which `--columns-count` owns.
66
- const PAGE_GRID_COLUMNS = 4;
67
-
68
- /** Blank out comments, url() payloads, and string contents so none of them can match a rule. */
69
- function neutralise(css) {
70
- return blankStrings(
71
- css
72
- .replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
73
- .replace(/url\((?:[^()]|\([^()]*\))*\)/g, (m) => ' '.repeat(m.length)),
74
- );
75
- }
76
-
77
- /** `<style>` blocks with their absolute offset in the file; whole file for .css. */
78
- function styleRegions(text, file) {
79
- if (file.endsWith('.css')) return [{ text, offset: 0 }];
80
- const out = [];
81
- for (const m of text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
82
- out.push({ text: m[1], offset: m.index + m[0].indexOf(m[1]) });
83
- }
84
- return out;
85
- }
86
-
87
- /**
88
- * Inline styles in markup, as declaration lists the value rules can read: a
89
- * `style="..."` attribute verbatim, and a `style:prop="value"` directive
90
- * rewritten as `prop: value;`. A `{...}` expression is dynamic and skipped.
91
- */
92
- function inlineStyleRegions(code) {
93
- const out = [];
94
- for (const m of code.matchAll(/\sstyle=(["'])([^"']*)\1/g)) {
95
- out.push({ text: `${m[2]};`, offset: m.index + m[0].indexOf(m[2]) });
96
- }
97
- for (const m of code.matchAll(/\sstyle:([a-z-]+)=(["'])([^"']*)\2/g)) {
98
- out.push({ text: `${m[1]}: ${m[3]};`, offset: m.index + 1 });
99
- }
100
- return out;
101
- }
102
-
103
- /** Everything outside `<style>`: script and markup. */
104
- function codeRegion(text, file) {
105
- if (file.endsWith('.css')) return null;
106
- return text.replace(/<style[^>]*>[\s\S]*?<\/style>/g, (m) => ' '.repeat(m.length));
107
- }
108
-
109
- /**
110
- * Declarations in a stylesheet, with at-rule preludes excluded. A breakpoint in
111
- * `@media (max-width: 768px)` is structural geometry, not a themeable value.
112
- * A property name is read from its start, so `--heading-2xl` is one custom
113
- * property and never the property `xl`.
114
- */
115
- function declarations(css) {
116
- const body = css.replace(/@[a-z-]+[^;{]*(?=\{)/gi, (m) => ' '.repeat(m.length));
117
- const out = [];
118
- for (const m of body.matchAll(/(?<![\w-])((?:--)?[a-z][\w-]*)\s*:\s*([^;{}]+)[;}]/gi)) {
119
- out.push({ prop: m[1].toLowerCase(), value: m[2].trim(), index: m.index });
120
- }
121
- return out;
122
- }
123
-
124
- /**
125
- * The attributes of one component tag starting at `start` (the `<`), read with
126
- * `{}` depth and quotes tracked so an expression holding `>` does not end the
127
- * tag early. Returns null when the tag spreads an object, which makes its prop
128
- * set unknowable.
129
- */
130
- function tagAttributes(code, start) {
131
- let i = code.indexOf(' ', start);
132
- const tagEnd = (() => {
133
- let depth = 0;
134
- let quote = null;
135
- for (let j = start; j < code.length; j++) {
136
- const c = code[j];
137
- if (quote) {
138
- if (c === quote) quote = null;
139
- } else if (c === '"' || c === "'") quote = c;
140
- else if (c === '{') depth++;
141
- else if (c === '}') depth--;
142
- else if (c === '>' && depth === 0) return j;
143
- }
144
- return code.length;
145
- })();
146
- if (i === -1 || i > tagEnd) return { attrs: [], end: tagEnd };
147
- const attrs = [];
148
- while (i < tagEnd) {
149
- const c = code[i];
150
- if (/\s/.test(c) || c === '/') {
151
- i++;
152
- continue;
153
- }
154
- if (c === '{') {
155
- let depth = 0;
156
- let j = i;
157
- for (; j < tagEnd; j++) {
158
- if (code[j] === '{') depth++;
159
- else if (code[j] === '}' && --depth === 0) break;
160
- }
161
- const inner = code.slice(i + 1, j).trim();
162
- if (inner.startsWith('...')) return null;
163
- if (/^\w+$/.test(inner)) attrs.push({ name: inner, value: null, index: i });
164
- i = j + 1;
165
- continue;
166
- }
167
- const name = code.slice(i).match(/^[^\s=/>]+/)?.[0];
168
- if (!name) break;
169
- const at = i;
170
- i += name.length;
171
- let value = null;
172
- if (code[i] === '=') {
173
- i++;
174
- const q = code[i];
175
- if (q === '"' || q === "'") {
176
- const close = code.indexOf(q, i + 1);
177
- value = code.slice(i + 1, close === -1 ? tagEnd : close);
178
- i = close === -1 ? tagEnd : close + 1;
179
- } else if (q === '{') {
180
- let depth = 0;
181
- for (; i < tagEnd; i++) {
182
- if (code[i] === '{') depth++;
183
- else if (code[i] === '}' && --depth === 0) break;
184
- }
185
- i++;
186
- } else {
187
- const bare = code.slice(i).match(/^[^\s>]+/)?.[0] ?? '';
188
- value = bare;
189
- i += bare.length;
190
- }
191
- }
192
- attrs.push({ name, value, index: at });
193
- }
194
- return { attrs, end: tagEnd };
195
- }
196
-
197
- /**
198
- * Props a page passes that the component does not declare, values outside a
199
- * prop's union, and a shipped component the page sizes itself.
200
- */
201
- function checkComponentUsage(code, imports, add) {
202
- for (const [local, entry] of imports) {
203
- const props = entry.props;
204
- if (!props) continue;
205
- const re = new RegExp(`<${local}(?=[\\s/>])`, 'g');
206
- for (const m of code.matchAll(re)) {
207
- const tag = tagAttributes(code, m.index);
208
- if (!tag) continue;
209
- for (const { name, value, index } of tag.attrs) {
210
- if (name.includes(':') || name.startsWith('@') || name === 'children') continue;
211
- if (!props.props.has(name)) {
212
- add('unknown-prop', index, `${entry.name} has no prop '${name}'; it accepts ${[...props.props].join(', ')}`);
213
- continue;
214
- }
215
- const allowed = props.enums.get(name);
216
- if (allowed && value !== null && !allowed.has(value)) {
217
- add('unknown-prop-value', index, `${entry.name} ${name}="${value}" is not one of ${[...allowed].join(', ')}`);
218
- }
219
- if (name === 'size' && entry.origin === 'shipped') {
220
- add(
221
- 'control-size',
222
- index,
223
- `${entry.name} ${value === null ? 'is sized here' : `size="${value}"`}. Drop it for the shipped default, or retune ${entry.name} for the whole project in /live-tokens/components.`,
224
- );
225
- }
226
- }
227
- }
228
- }
229
- }
230
-
231
- /**
232
- * One finding when a page holds more than one primary Button, at the second of
233
- * them. Emphasis is what the variant carries, so a second primary leaves the
234
- * page with no single most important action. A Button with no variant is
235
- * primary, the component's default.
236
- */
237
- function checkPrimaryActions(code, imports, add) {
238
- const primaries = [];
239
- for (const [local, entry] of imports) {
240
- if (entry.id !== 'button') continue;
241
- for (const m of code.matchAll(new RegExp(`<${local}(?=[\\s/>])`, 'g'))) {
242
- const tag = tagAttributes(code, m.index);
243
- const variant = tag?.attrs.find((a) => a.name === 'variant');
244
- if (tag && (variant === undefined || variant.value === 'primary')) primaries.push(m.index);
245
- }
246
- }
247
- if (primaries.length < 2) return;
248
- primaries.sort((a, b) => a - b);
249
- add(
19
+ import * as componentUse from './rules/componentUse.mjs';
20
+ import * as importsAndRoutes from './rules/importsAndRoutes.mjs';
21
+ import * as testRuns from './rules/testRuns.mjs';
22
+ import * as tokenRules from './rules/tokens.mjs';
23
+
24
+ /** Every rule, with its default severity, its repair, and its guidance. Same
25
+ * three fields, same meanings, as `COMPONENT_RULES`. */
26
+ export const PAGE_RULES = assembleRules(
27
+ [
28
+ 'unknown-component',
29
+ 'unknown-prop',
30
+ 'unknown-prop-value',
31
+ 'deep-import',
32
+ 'unknown-token',
33
+ 'color-literal',
34
+ 'reserved-route',
35
+ 'site-css-in-main',
36
+ 'raw-text-axis',
37
+ 'dimension-literal',
38
+ 'hardcoded-columns',
39
+ 'missing-source',
40
+ 'control-size',
250
41
  'multiple-primary',
251
- primaries[1],
252
- `${primaries.length} primary Buttons in this page. Keep the most important action primary and make the other ${primaries.length - 1} secondary.`,
253
- );
254
- }
42
+ 'danger-without-dialog',
43
+ 'native-control',
44
+ 'property-override',
45
+ 'page-component-paint',
46
+ 'page-text-style',
47
+ 'page-contrast',
48
+ 'page-grid',
49
+ 'page-overflow',
50
+ 'tests-not-installed',
51
+ 'tests-setup',
52
+ 'tests-incomplete',
53
+ ],
54
+ componentUse.pageRules,
55
+ importsAndRoutes.pageRules,
56
+ tokenRules.pageRules,
57
+ testRuns.pageRules,
58
+ );
255
59
 
256
- /**
257
- * One finding when a page holds a danger Button and imports no Dialog. A
258
- * danger action destroys saved work, and the page has nothing to confirm it.
259
- */
260
- function checkDestructiveActions(code, imports, add) {
261
- if ([...imports.values()].some((entry) => entry.id === 'dialog')) return;
262
- for (const [local, entry] of imports) {
263
- if (entry.id !== 'button' && entry.id !== 'iconbutton') continue;
264
- for (const m of code.matchAll(new RegExp(`<${local}(?=[\\s/>])`, 'g'))) {
265
- const tag = tagAttributes(code, m.index);
266
- if (!tag?.attrs.some((a) => a.name === 'variant' && a.value === 'danger')) continue;
267
- add(
268
- 'danger-without-dialog',
269
- m.index,
270
- `${entry.name} variant="danger" with no Dialog in this page. A destructive action confirms in a Dialog before it runs.`,
271
- );
272
- return;
273
- }
274
- }
275
- }
60
+ // Directories that hold the system, not pages built on it.
61
+ export const NOT_PAGES = ['src/system', 'src/editor', 'src/lib', 'src/live-tokens'];
276
62
 
277
- /** The object literal enclosing `index`, found by balancing braces outward. */
278
- function enclosingObject(text, index) {
279
- let depth = 0;
280
- let start = -1;
281
- for (let i = index; i >= 0; i--) {
282
- const c = text[i];
283
- if (c === '}') depth++;
284
- else if (c === '{') {
285
- if (depth === 0) {
286
- start = i;
287
- break;
288
- }
289
- depth--;
290
- }
291
- }
292
- if (start === -1) return null;
293
- depth = 0;
294
- for (let i = start; i < text.length; i++) {
295
- const c = text[i];
296
- if (c === '{') depth++;
297
- else if (c === '}') {
298
- depth--;
299
- if (depth === 0) return text.slice(start, i + 1);
300
- }
301
- }
302
- return null;
303
- }
63
+ export { COMPONENT_IMPORT } from './rules/componentUse.mjs';
304
64
 
305
65
  function checkFile(file, text, vocab, root) {
306
66
  const rel = relative(root, file);
307
67
  const findings = [];
308
- const add = (rule, index, message) =>
309
- findings.push({ rule, file: rel, line: lineOf(text, index), message });
68
+ const add = (rule, index, message, extra = {}) =>
69
+ findings.push({ rule, file: rel, line: lineOf(text, index), message, ...extra });
310
70
 
311
71
  const code = codeRegion(text, file);
72
+ const regions = styleRegions(text, file);
73
+ const inlineRegions = code === null ? [] : inlineStyleRegions(code);
312
74
  if (code !== null) {
313
75
  const imports = new Map();
314
76
  for (const m of code.matchAll(/import\s+(?:([^'"]*?)\s+from\s+)?['"]([^'"]+)['"]/g)) {
315
- const spec = m[2];
316
- for (const pattern of DEEP_IMPORT_PATTERNS) {
317
- if (pattern.test(spec)) {
318
- add('deep-import', m.index, `deep import into package internals: ${spec}`);
319
- }
320
- }
321
- const comp = spec.match(COMPONENT_IMPORT);
322
- if (!comp) continue;
323
- const entry = vocab.components.get(comp[1].toLowerCase());
324
- if (!entry) {
325
- add(
326
- 'unknown-component',
327
- m.index,
328
- `'${comp[1]}' is not in the component catalogue; author it with live-tokens-create-component or pick a shipped one`,
329
- );
330
- continue;
331
- }
332
- const local = m[1]?.trim().match(/^(\w+)$/)?.[1];
333
- if (local) imports.set(local, entry);
334
- }
335
- checkComponentUsage(code, imports, add);
336
- checkPrimaryActions(code, imports, add);
337
- checkDestructiveActions(code, imports, add);
338
-
339
- for (const m of code.matchAll(/['"](\/live-tokens[^'"]*)['"]\s*:/g)) {
340
- add('reserved-route', m.index, `route '${m[1]}' is inside the reserved /live-tokens/* namespace`);
341
- }
342
-
343
- for (const m of code.matchAll(/\blazy\s*:/g)) {
344
- const entry = enclosingObject(code, m.index);
345
- if (entry && !/\bsource\s*:/.test(entry)) {
346
- add('missing-source', m.index, `route entry has no 'source', so Page Source cannot open it`);
347
- }
348
- }
349
-
350
- if (/^main\.(ts|js)$/.test(basename(file))) {
351
- for (const m of code.matchAll(/import\s+['"]([^'"]*site\.css)['"]/g)) {
352
- add(
353
- 'site-css-in-main',
354
- m.index,
355
- `site.css imported from main; import it from each page's <script> so it cannot leak into editor routes`,
356
- );
357
- }
77
+ const statement = { index: m.index, clause: m[1], specifier: m[2] };
78
+ importsAndRoutes.checkPageImport(statement, add);
79
+ componentUse.resolveComponentImport(statement, vocab, imports, add);
358
80
  }
81
+ componentUse.checkComponentTags(code, imports, add);
82
+ importsAndRoutes.checkRoutes({ file, code }, add);
359
83
  }
360
84
 
361
- // A page may also mint a custom property outside its <style> block — a
362
- // `style:--x={...}` directive or an el.style.setProperty call — and those are
363
- // just as declared as one written in CSS.
364
- const declaredHere = new Set();
365
- const regions = styleRegions(text, file);
366
- for (const region of regions) {
367
- for (const m of neutralise(region.text).matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/gim)) {
368
- declaredHere.add(m[1]);
369
- }
370
- }
371
- for (const m of text.matchAll(/(?:style:|setProperty\(\s*['"`]|['"`])(--[a-z0-9-]+)/g)) {
372
- declaredHere.add(m[1]);
373
- }
374
-
375
- for (const region of [...regions, ...(code === null ? [] : inlineStyleRegions(code))]) {
376
- const css = neutralise(region.text);
377
- const at = (i) => region.offset + i;
378
-
379
- for (const m of css.matchAll(/var\(\s*(--[a-z0-9-]+)/g)) {
380
- const name = m[1];
381
- if (declaredHere.has(name) || vocab.knows(name)) continue;
382
- add(
383
- 'unknown-token',
384
- at(m.index),
385
- isContractToken(name)
386
- ? `${name} has the shape of a design token but no longer exists. Check tokens.css for a rename`
387
- : `${name} is not a design token, a semantic property, or declared in this file`,
388
- );
389
- }
390
-
391
- for (const decl of declarations(css)) {
392
- const { prop, value, index } = decl;
393
- if (prop.startsWith('--')) continue;
394
-
395
- // A `var()` fallback only renders when the token is missing, so a literal
396
- // inside one is not the page's value.
397
- const painted = stripVarFallbacks(value);
398
- if (!TEXT_AXES.includes(prop) && hasColorLiteral(painted)) {
399
- add('color-literal', at(index), `${prop}: ${value}. Use a design token.`);
400
- continue;
401
- }
402
-
403
- if (TEXT_AXES.includes(prop) || prop === 'font') {
404
- const axis = [...painted.matchAll(/var\(\s*(--[a-z0-9-]+)/g)]
405
- .map((m) => m[1])
406
- .find((name) => SINGLE_AXIS_TOKEN.test(name));
407
- if (axis) {
408
- add(
409
- 'raw-text-axis',
410
- at(index),
411
- `${prop}: ${value}. ${axis} is one axis. Set every axis from one text style bundle (--heading-*, --body-*, --editorial-*, --code-*).`,
412
- );
413
- continue;
414
- }
415
-
416
- // Of the literals only absolute type values are a finding. `em`, `%`,
417
- // and a unitless line-height are relative to the inherited type, so
418
- // they ride whatever the theme sets rather than overriding it.
419
- if (
420
- !value.includes('var(') &&
421
- !/^(inherit|initial|unset|normal)$/.test(value) &&
422
- /\d(px|rem|pt)\b|^[a-z"']/i.test(value)
423
- ) {
424
- add(
425
- 'raw-text-axis',
426
- at(index),
427
- `${prop}: ${value}. Set type from a text style bundle (--heading-*, --body-*, --editorial-*, --code-*).`,
428
- );
429
- continue;
430
- }
431
- }
432
-
433
- if (THEMED_GEOMETRY.test(prop) && hasDimensionLiteral(painted)) {
434
- add(
435
- 'dimension-literal',
436
- at(index),
437
- `${prop}: ${value}. Use a --space-*, --radius-*, --border-width-*, or --shadow-* token.`,
438
- );
439
- }
440
-
441
- const columns = value.match(/\brepeat\(\s*(\d+)\s*,\s*1fr\s*\)/);
442
- if (columns && Number(columns[1]) >= PAGE_GRID_COLUMNS) {
443
- add(
444
- 'hardcoded-columns',
445
- at(index),
446
- `${prop}: ${value}. Use repeat(var(--columns-count), 1fr) so the page grid stays in step.`,
447
- );
448
- }
449
- }
450
- }
85
+ componentUse.checkPropertyOverrides({ text, regions, inlineRegions, vocab }, add);
86
+ tokenRules.checkPageValues({ text, regions, inlineRegions, vocab }, add);
451
87
 
452
88
  return findings;
453
89
  }
package/bin/cli.mjs CHANGED
@@ -18,15 +18,18 @@ import { writeSync } from 'node:fs';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import process from 'node:process';
21
- import { COMPONENT_RULES, COMPONENT_RULE_FIX, checkComponent, discoverComponents, formatReport } from './check-component.mjs';
21
+ import { COMPONENT_RULES, checkComponent, discoverComponents, formatReport } from './check-component.mjs';
22
22
  import { PAGE_RULES, checkPages, discoverPages } from './check-page.mjs';
23
+ import { resolvePageTestTargets } from './lib/pageRoutes.mjs';
23
24
  import { describeComponents, describeTokens, formatComponents, formatTokens } from './lib/catalogue.mjs';
25
+ import { applyFixes } from './lib/fixers.mjs';
24
26
  import { buildReport, formatReport as formatProjectReport } from './lib/report.mjs';
25
27
  import { loadVocabulary } from './lib/tokenVocabulary.mjs';
26
28
  import {
27
29
  applyCoverageSeverity,
28
30
  applySeverity,
29
31
  countBySeverity,
32
+ dedupeAliasFindings,
30
33
  formatFindings,
31
34
  parseCheckFlags,
32
35
  readChecksConfig,
@@ -74,10 +77,20 @@ Commands:
74
77
  @playwright/test, vitest, and happy-dom; a
75
78
  missing one is a tests-not-installed finding
76
79
  naming the install command
77
- check-page [paths...] Validate pages against the live-tokens-create-page
80
+ check-page [paths...] [--tests]
81
+ Validate pages against the live-tokens-create-page
78
82
  contract: catalogue components only, and every CSS
79
83
  value a design token. Checks every page under src/
80
- when given no paths.
84
+ when given no paths. --tests also opens each
85
+ page's own route in the consumer's own app and
86
+ proves, per shipped rule id, that the cascade
87
+ painted every component from its semantic
88
+ properties, every run of text sits in one shipped
89
+ text style, every text/surface pair meets AA,
90
+ sections sit on the page grid, and nothing
91
+ overflows. Needs @playwright/test, vitest, and
92
+ happy-dom; a missing one is a tests-not-installed
93
+ finding naming the install command
81
94
 
82
95
  check-component and check-page also accept:
83
96
  --json Machine-readable findings, for a skill to iterate
@@ -87,6 +100,15 @@ check-component and check-page also accept:
87
100
  (or set "checks": { "rules": {...} } in
88
101
  live-tokens.config.json; "checks": { "exclude":
89
102
  [...] } drops paths from discovery entirely)
103
+ --no-fix Report every finding and edit nothing, for a
104
+ build or CI. Without it, both checkers first
105
+ apply every finding whose repair is auto (a
106
+ deep import into a component, a raw dimension
107
+ with one nearest design token, a shipped
108
+ component's size prop, a semantic property
109
+ overridden in place), re-check, and list each
110
+ fix beside what remains. --tests runs after
111
+ the fixes land.
90
112
  set-colors <base-colors.json> [--dry-run]
91
113
  Build the theme's whole color identity from 10
92
114
  OKLCH base colors (see the live-tokens-set-colors
@@ -183,14 +205,14 @@ if (command === 'create') {
183
205
  }
184
206
  }
185
207
 
186
- function reportChecks(label, findings, checked, rules, opts, { coverage, hardFailure, fixes } = {}) {
208
+ function reportChecks(label, findings, checked, rules, opts, { coverage, hardFailure, exclude, fix } = {}) {
187
209
  const checksConfig = readChecksConfig(process.cwd());
188
- const resolved = applySeverity(findings, rules, opts, checksConfig, fixes ?? {});
210
+ const resolved = applySeverity(findings, rules, opts, checksConfig, { exclude });
189
211
  const resolvedCoverage = coverage ? applyCoverageSeverity(coverage, rules, opts, checksConfig) : coverage;
190
212
  console.log(
191
213
  opts.json
192
- ? toJson(resolved, { label, checked, coverage: resolvedCoverage })
193
- : formatFindings(resolved, { label, checked }),
214
+ ? toJson(resolved, { label, checked, coverage: resolvedCoverage, fix })
215
+ : [fix ? formatFixes(fix) : '', formatFindings(resolved, { label, checked })].filter(Boolean).join('\n'),
194
216
  );
195
217
  // Decision 2: a missing tool or a setup failure under --tests is an error
196
218
  // even if a project silenced its rule id, since silencing it would read as
@@ -198,6 +220,29 @@ function reportChecks(label, findings, checked, rules, opts, { coverage, hardFai
198
220
  process.exit(countBySeverity(resolved).errors === 0 && !hardFailure ? 0 : 1);
199
221
  }
200
222
 
223
+ function shiftNote(f) {
224
+ if (f.rule !== 'dimension-literal') return '';
225
+ const shifts = f.details.literals.map((l) => l.candidates[0].shift).map((n) => `${n > 0 ? '+' : ''}${n}px`);
226
+ return ` shift ${shifts.join(', ')}`;
227
+ }
228
+
229
+ /** Resolves severities and exclusions first, so a silenced rule or an excluded
230
+ * file is never edited. */
231
+ function applyStaticFixes(findings, rules, opts, { exclude } = {}) {
232
+ const root = process.cwd();
233
+ return applyFixes(applySeverity(findings, rules, opts, readChecksConfig(root), { exclude }), root);
234
+ }
235
+
236
+ function formatFixes({ applied, skipped }) {
237
+ const lines = [];
238
+ for (const f of applied) {
239
+ lines.push(` fixed ${f.file}:${f.line} ${f.details.patch.from} → ${f.details.patch.to || '(removed)'} [${f.rule}]${shiftNote(f)}`);
240
+ }
241
+ for (const f of skipped) lines.push(` skipped ${f.file}:${f.line} ${f.details.patch.from} [${f.rule}]`);
242
+ if (lines.length === 0) return '';
243
+ return [`${applied.length} fix(es) applied, ${skipped.length} skipped:`, ...lines, ''].join('\n');
244
+ }
245
+
201
246
  if (command === 'components') {
202
247
  const opts = parseCheckFlags(rest);
203
248
  const list = describeComponents(loadVocabulary());
@@ -246,21 +291,26 @@ if (command === 'check-component') {
246
291
  console.log('✓ check-component: no component authored under src/system/components yet.');
247
292
  process.exit(0);
248
293
  }
249
- const results = ids.map((id) => [id, checkComponent(id)]);
294
+ let results = ids.map((id) => [id, checkComponent(id)]);
295
+ const label = ids.length === 1 ? `check-component ${ids[0]}${opts.tests ? ' --tests' : ''}` : `check-component${opts.tests ? ' --tests' : ''}`;
296
+ let fix;
297
+ if (!opts.noFix) {
298
+ fix = applyStaticFixes(results.flatMap(([, r]) => r.findings), COMPONENT_RULES, opts);
299
+ if (fix.applied.length > 0) results = ids.map((id) => [id, checkComponent(id)]);
300
+ }
250
301
  if (!opts.tests && ids.length === 1 && !opts.json && !opts.strict && opts.off.length + opts.warn.length + opts.error.length === 0) {
251
302
  const [id, result] = results[0];
252
- console.log(formatReport(id, result));
303
+ console.log([fix ? formatFixes(fix) : '', formatReport(id, result)].filter(Boolean).join('\n'));
253
304
  process.exit(result.errors.length === 0 ? 0 : 1);
254
305
  }
255
- const label = ids.length === 1 ? `check-component ${ids[0]}${opts.tests ? ' --tests' : ''}` : `check-component${opts.tests ? ' --tests' : ''}`;
256
306
  if (!opts.tests) {
257
- reportChecks(label, results.flatMap(([, r]) => r.findings), ids.length, COMPONENT_RULES, opts, { fixes: COMPONENT_RULE_FIX });
307
+ reportChecks(label, results.flatMap(([, r]) => r.findings), ids.length, COMPONENT_RULES, opts, { fix });
258
308
  }
259
309
  const { hasHardFailure, runContractTests } = await import('./contractRunner.mjs');
260
310
  const testOutcome = await runContractTests(opts.rest[0], { root: process.cwd() });
261
- const findings = [...results.flatMap(([, r]) => r.findings), ...testOutcome.findings];
311
+ const findings = dedupeAliasFindings([...results.flatMap(([, r]) => r.findings), ...testOutcome.findings]);
262
312
  reportChecks(label, findings, Math.max(ids.length, 1), COMPONENT_RULES, opts, {
263
- fixes: COMPONENT_RULE_FIX,
313
+ fix,
264
314
  coverage: testOutcome.coverage,
265
315
  hardFailure: hasHardFailure(testOutcome.findings),
266
316
  });
@@ -269,8 +319,26 @@ if (command === 'check-component') {
269
319
  if (command === 'check-page') {
270
320
  const opts = parseCheckFlags(rest);
271
321
  const targets = opts.rest.length > 0 ? opts.rest : discoverPages(process.cwd());
272
- const { findings, checked } = checkPages(targets, { root: process.cwd() });
273
- reportChecks('check-page', findings, checked, PAGE_RULES, opts);
322
+ let { findings, checked } = checkPages(targets, { root: process.cwd() });
323
+ let fix;
324
+ if (!opts.noFix) {
325
+ fix = applyStaticFixes(findings, PAGE_RULES, opts, { exclude: true });
326
+ if (fix.applied.length > 0) ({ findings, checked } = checkPages(targets, { root: process.cwd() }));
327
+ }
328
+ if (!opts.tests) {
329
+ reportChecks('check-page', findings, checked, PAGE_RULES, opts, { exclude: true, fix });
330
+ }
331
+ const { hasHardFailure, runPageTests } = await import('./contractRunner.mjs');
332
+ const pageTargets = resolvePageTestTargets(opts.rest, process.cwd());
333
+ const testOutcome = await runPageTests(pageTargets, { root: process.cwd() });
334
+ const label = 'check-page --tests';
335
+ const allFindings = [...findings, ...testOutcome.findings];
336
+ reportChecks(label, allFindings, Math.max(checked, pageTargets.length), PAGE_RULES, opts, {
337
+ exclude: true,
338
+ fix,
339
+ coverage: testOutcome.coverage,
340
+ hardFailure: hasHardFailure(testOutcome.findings),
341
+ });
274
342
  }
275
343
 
276
344
  if (command === 'set-colors') {