@motion-proto/live-tokens 0.78.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +60 -31
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +23 -28
  3. package/.claude/skills/live-tokens-create-component/references/contract-tests.md +61 -0
  4. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +2 -2
  5. package/.claude/skills/live-tokens-create-component/references/token-naming.md +6 -15
  6. package/.claude/skills/live-tokens-create-page/SKILL.md +6 -4
  7. package/.claude/skills/live-tokens-create-theme/references/design-directions.md +1 -1
  8. package/.claude/skills/live-tokens-pick-component/SKILL.md +3 -3
  9. package/.claude/skills/live-tokens-set-colors/references/color-anchors.md +1 -1
  10. package/.claude/skills/live-tokens-set-geometry/SKILL.md +1 -1
  11. package/.claude/skills/live-tokens-set-geometry/references/geometry-anchors.md +1 -1
  12. package/CHANGELOG.md +231 -0
  13. package/README.md +9 -15
  14. package/bin/check-component.mjs +152 -551
  15. package/bin/check-page.mjs +55 -541
  16. package/bin/cli.mjs +59 -13
  17. package/bin/contractRunner.mjs +37 -91
  18. package/bin/lib/catalogue.mjs +161 -34
  19. package/bin/lib/componentSource.mjs +152 -0
  20. package/bin/lib/cssValues.mjs +9 -0
  21. package/bin/lib/dataDir.mjs +126 -0
  22. package/bin/lib/findings.mjs +103 -12
  23. package/bin/lib/fixers.mjs +64 -0
  24. package/bin/lib/geometry.mjs +92 -0
  25. package/bin/lib/pageSource.mjs +230 -0
  26. package/bin/lib/report.mjs +57 -59
  27. package/bin/lib/tokenVocabulary.mjs +104 -34
  28. package/bin/rules/componentStructure.mjs +344 -0
  29. package/bin/rules/componentUse.mjs +313 -0
  30. package/bin/rules/importsAndRoutes.mjs +136 -0
  31. package/bin/rules/testRuns.mjs +122 -0
  32. package/bin/rules/tokens.mjs +363 -0
  33. package/bin/setup-claude.mjs +1 -2
  34. package/dist-plugin/{chunk-PDNL4NC5.js → chunk-D4WRIKEZ.js} +7 -2
  35. package/dist-plugin/{chunk-SWXRVZKT.js → chunk-REBHE3ZM.js} +414 -1
  36. package/dist-plugin/index.cjs +432 -15
  37. package/dist-plugin/index.js +8 -9
  38. package/dist-plugin/migrateData/index.cjs +422 -4
  39. package/dist-plugin/migrateData/index.js +2 -2
  40. package/dist-plugin/setColors/index.cjs +414 -1
  41. package/dist-plugin/setColors/index.d.cts +1 -1
  42. package/dist-plugin/setColors/index.d.ts +1 -1
  43. package/dist-plugin/setColors/index.js +1 -1
  44. package/dist-plugin/setGeometry/index.cjs +423 -13
  45. package/dist-plugin/setGeometry/index.d.cts +3 -3
  46. package/dist-plugin/setGeometry/index.d.ts +3 -3
  47. package/dist-plugin/setGeometry/index.js +10 -13
  48. package/dist-plugin/setType/index.d.cts +1 -1
  49. package/dist-plugin/setType/index.d.ts +1 -1
  50. package/dist-plugin/{themeTypes-BxRtuN5V.d.cts → themeTypes-B8_Idrp4.d.cts} +2 -2
  51. package/dist-plugin/{themeTypes-BxRtuN5V.d.ts → themeTypes-B8_Idrp4.d.ts} +2 -2
  52. package/package.json +2 -2
  53. package/src/editor/component-editor/CalloutEditor.svelte +2 -2
  54. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +14 -15
  55. package/src/editor/component-editor/CornerBadgeEditor.svelte +14 -14
  56. package/src/editor/component-editor/DialogEditor.svelte +5 -5
  57. package/src/editor/component-editor/InlineEditActionsEditor.svelte +2 -2
  58. package/src/editor/component-editor/RadioButtonEditor.svelte +21 -21
  59. package/src/editor/component-editor/SectionDividerEditor.svelte +7 -7
  60. package/src/editor/component-editor/SegmentedControlEditor.svelte +5 -5
  61. package/src/editor/component-editor/SideNavigationEditor.svelte +25 -25
  62. package/src/editor/component-editor/TabBarEditor.svelte +6 -6
  63. package/src/editor/component-editor/TableEditor.svelte +6 -6
  64. package/src/editor/component-editor/ToggleEditor.svelte +2 -2
  65. package/src/editor/component-editor/index.ts +3 -0
  66. package/src/editor/component-editor/registry.ts +57 -1
  67. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +14 -14
  68. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +3 -0
  69. package/src/editor/component-editor/scaffolding/types.ts +15 -0
  70. package/src/editor/core/components/adjustAliases.ts +4 -4
  71. package/src/editor/core/components/aliasKinds.ts +20 -19
  72. package/src/editor/core/sketch/sketchLayer.ts +3 -3
  73. package/src/editor/core/themes/migrateComponentConfig.ts +14 -6
  74. package/src/editor/core/themes/migrations/2026-09-13-badge-brand.ts +42 -0
  75. package/src/editor/core/themes/migrations/2026-09-13-collapsiblesection-open.ts +33 -0
  76. package/src/editor/core/themes/migrations/2026-09-13-cornerbadge-prefix.ts +70 -0
  77. package/src/editor/core/themes/migrations/2026-09-13-hairline.ts +92 -0
  78. package/src/editor/core/themes/migrations/2026-09-13-indicator.ts +54 -0
  79. package/src/editor/core/themes/migrations/2026-09-13-sectiondivider-surface.ts +36 -0
  80. package/src/editor/core/themes/migrations/2026-09-13-selected-state.ts +124 -0
  81. package/src/editor/core/themes/migrations/2026-09-13-toggle-label.ts +31 -0
  82. package/src/editor/core/themes/migrations/index.ts +16 -0
  83. package/src/editor/core/themes/themeService.ts +3 -3
  84. package/src/editor/core/themes/themeTypes.ts +13 -15
  85. package/src/editor/docs/Docs.svelte +1 -1
  86. package/src/editor/docs/content/light-and-dark.md +3 -3
  87. package/src/editor/docs/content.generated.ts +1 -1
  88. package/src/editor/index.ts +1 -0
  89. package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
  90. package/src/editor/skill-atlas/SkillAtlas.svelte +4 -4
  91. package/src/editor/skill-atlas/TreeNodeCard.svelte +4 -4
  92. package/src/editor/skill-atlas/skillSources.generated.ts +11 -14
  93. package/src/editor/skill-atlas/skillTrees.ts +1 -3
  94. package/src/editor/skill-atlas/trees/check-compliance.ts +258 -93
  95. package/src/editor/skill-atlas/trees/create-component.ts +73 -62
  96. package/src/editor/skill-atlas/trees/create-page.ts +38 -17
  97. package/src/editor/skill-atlas/trees/pick-component.ts +3 -3
  98. package/src/editor/skill-atlas/trees/set-geometry.ts +1 -1
  99. package/src/editor/ui/UIPaletteSelector.svelte +11 -15
  100. package/src/editor/ui/variantScales.ts +8 -8
  101. package/src/live-tokens/data/themes/autumn.json +188 -188
  102. package/src/live-tokens/data/themes/halloween.json +188 -188
  103. package/src/live-tokens/data/themes/midnight-study.json +245 -245
  104. package/src/live-tokens/data/themes/ocean.json +188 -188
  105. package/src/live-tokens/data/themes/royal-velvet.json +188 -188
  106. package/src/live-tokens/data/themes/sketchy.json +188 -188
  107. package/src/live-tokens/data/themes/spring-meadow.json +188 -188
  108. package/src/live-tokens/data/themes/sunset.json +188 -188
  109. package/src/system/components/Badge.svelte +27 -23
  110. package/src/system/components/Button.svelte +13 -7
  111. package/src/system/components/Callout.svelte +16 -11
  112. package/src/system/components/Card.svelte +22 -15
  113. package/src/system/components/CodeSnippet.svelte +10 -6
  114. package/src/system/components/CollapsibleSection.svelte +68 -63
  115. package/src/system/components/CornerBadge.svelte +78 -72
  116. package/src/system/components/Dialog.svelte +21 -18
  117. package/src/system/components/IconButton.svelte +13 -9
  118. package/src/system/components/Image.svelte +14 -8
  119. package/src/system/components/ImageLightbox.svelte +10 -6
  120. package/src/system/components/InlineEditActions.svelte +17 -14
  121. package/src/system/components/Input.svelte +13 -7
  122. package/src/system/components/MenuSelect.svelte +13 -7
  123. package/src/system/components/Notification.svelte +17 -11
  124. package/src/system/components/Panel.svelte +13 -6
  125. package/src/system/components/ProgressBar.svelte +10 -5
  126. package/src/system/components/RadioButton.svelte +33 -30
  127. package/src/system/components/SectionDivider.svelte +28 -21
  128. package/src/system/components/SegmentedControl.svelte +27 -23
  129. package/src/system/components/SideNavigation.svelte +175 -171
  130. package/src/system/components/Slider.svelte +11 -7
  131. package/src/system/components/TabBar.svelte +53 -49
  132. package/src/system/components/Table.svelte +20 -15
  133. package/src/system/components/Toggle.svelte +14 -10
  134. package/src/system/components/Tooltip.svelte +10 -6
  135. package/src/system/styles/CONVENTIONS.md +3 -4
  136. package/src/testing-js/chunk-3UKGXCDL.js +48 -0
  137. package/src/testing-js/chunk-3UKGXCDL.js.map +1 -0
  138. package/src/testing-js/{chunk-FAFOAWYL.js → chunk-Q3YIAAG3.js} +17 -3
  139. package/src/testing-js/chunk-Q3YIAAG3.js.map +1 -0
  140. package/src/testing-js/{chunk-GNIUPIU2.js → chunk-U7OJE5DU.js} +18 -18
  141. package/src/testing-js/chunk-U7OJE5DU.js.map +1 -0
  142. package/src/testing-js/{chunk-4JQX6WWL.js → chunk-ZMZQZ33J.js} +474 -166
  143. package/src/testing-js/chunk-ZMZQZ33J.js.map +1 -0
  144. package/src/testing-js/component-behavior.contract.js +155 -0
  145. package/src/testing-js/component-behavior.contract.js.map +1 -0
  146. package/src/testing-js/component-editor.contract.js +6 -4
  147. package/src/testing-js/component-editor.contract.js.map +1 -1
  148. package/src/testing-js/component-render.contract.js +14 -10
  149. package/src/testing-js/component-render.contract.js.map +1 -1
  150. package/src/testing-js/index.d.ts +44 -4
  151. package/src/testing-js/index.js +13 -7
  152. package/src/testing-js/index.js.map +1 -1
  153. package/src/testing-js/page-compliance.contract.js +48 -10
  154. package/src/testing-js/page-compliance.contract.js.map +1 -1
  155. package/src/testing-js/registry.contract.js +5 -3
  156. package/src/testing-js/registry.contract.js.map +1 -1
  157. package/src/testing-js/{vitest-C-wNWcoA.d.ts → vitest-BMLIbDs2.d.ts} +8 -2
  158. package/src/testing-js/vitest.d.ts +1 -1
  159. package/src/testing-js/vitest.js +2 -1
  160. package/template/package.json +1 -1
  161. package/.claude/skills/live-tokens-create-component/SKILL copy.md +0 -196
  162. package/.claude/skills/live-tokens-fix-findings/SKILL.md +0 -105
  163. package/src/editor/skill-atlas/trees/fix-findings.ts +0 -504
  164. package/src/testing-js/chunk-4JQX6WWL.js.map +0 -1
  165. package/src/testing-js/chunk-FAFOAWYL.js.map +0 -1
  166. package/src/testing-js/chunk-GNIUPIU2.js.map +0 -1
@@ -0,0 +1,230 @@
1
+ import { blankStrings } from './cssValues.mjs';
2
+
3
+ /** Blank out comments, url() payloads, and string contents so none of them can match a rule. */
4
+ export function neutralise(css) {
5
+ return blankStrings(
6
+ css
7
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
8
+ .replace(/url\((?:[^()]|\([^()]*\))*\)/g, (m) => ' '.repeat(m.length)),
9
+ );
10
+ }
11
+
12
+ /** `<style>` blocks with their absolute offset in the file; whole file for .css.
13
+ * `site` is what a repair would edit, which the finding carries. */
14
+ export function styleRegions(text, file) {
15
+ if (file.endsWith('.css')) return [{ text, offset: 0, site: 'declaration' }];
16
+ const out = [];
17
+ for (const m of text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
18
+ out.push({ text: m[1], offset: m.index + m[0].indexOf(m[1]), site: 'declaration' });
19
+ }
20
+ return out;
21
+ }
22
+
23
+ /**
24
+ * Inline styles in markup, as declaration lists the value rules can read: a
25
+ * `style="..."` attribute verbatim, and a `style:prop="value"` directive
26
+ * rewritten as `prop: value;`. A `{...}` expression is dynamic and skipped.
27
+ * The directive keeps its source text as `raw`, since the rewritten
28
+ * declaration is not text a patch would find on disk.
29
+ */
30
+ export function inlineStyleRegions(code) {
31
+ const out = [];
32
+ for (const m of code.matchAll(/\sstyle=(["'])([^"']*)\1/g)) {
33
+ out.push({ text: `${m[2]};`, offset: m.index + m[0].indexOf(m[2]), site: 'attribute' });
34
+ }
35
+ for (const m of code.matchAll(/\sstyle:([a-z-]+)=(["'])([^"']*)\2/g)) {
36
+ out.push({ text: `${m[1]}: ${m[3]};`, offset: m.index + 1, site: 'directive', raw: m[0].slice(1) });
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /** Everything outside `<style>`: script and markup. */
42
+ export function codeRegion(text, file) {
43
+ if (file.endsWith('.css')) return null;
44
+ return text.replace(/<style[^>]*>[\s\S]*?<\/style>/g, (m) => ' '.repeat(m.length));
45
+ }
46
+
47
+ /**
48
+ * Declarations in a stylesheet, with at-rule preludes excluded. A breakpoint in
49
+ * `@media (max-width: 768px)` is structural geometry, not a themeable value.
50
+ * A property name is read from its start, so `--heading-2xl` is one custom
51
+ * property and never the property `xl`.
52
+ */
53
+ export function declarations(css) {
54
+ const body = css.replace(/@[a-z-]+[^;{]*(?=\{)/gi, (m) => ' '.repeat(m.length));
55
+ const out = [];
56
+ for (const m of body.matchAll(/(?<![\w-])((?:--)?[a-z][\w-]*)\s*:\s*([^;{}]+)[;}]/gi)) {
57
+ out.push({ prop: m[1].toLowerCase(), value: m[2].trim(), index: m.index, text: m[0].slice(0, -1).trimEnd() });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** Each custom property the regions declare, passed to `at` with its offset in
63
+ * the file, its region's site, and the neutralised text from the match on,
64
+ * which a deletion patch bounds itself against. */
65
+ export function styleBlockDeclarations(regionList, at) {
66
+ for (const region of regionList) {
67
+ const clean = neutralise(region.text);
68
+ for (const m of clean.matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/gim)) {
69
+ at(m[1], region.offset + m.index, region.site, clean.slice(m.index));
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Every custom property a page declares, at the earliest offset it appears. A
76
+ * page may also mint one outside its <style> block, in a `style:--x={...}`
77
+ * directive or an el.style.setProperty call, and those are just as declared as
78
+ * one written in CSS. A bare quoted name counts too, so a name the page only
79
+ * reads through getPropertyValue("--x") is never an unknown token.
80
+ */
81
+ export function pageDeclaredNames(text, regions) {
82
+ const sites = new Map();
83
+ const declareAt = (name, index) => {
84
+ if (!sites.has(name) || index < sites.get(name)) sites.set(name, index);
85
+ };
86
+ styleBlockDeclarations(regions, declareAt);
87
+ for (const m of text.matchAll(/(?:style:|setProperty\(\s*['"`]|['"`])(--[a-z0-9-]+)/g)) {
88
+ declareAt(m[1], m.index);
89
+ }
90
+ return sites;
91
+ }
92
+
93
+ /** A dimension-literal patch anchored at the declaration the finding names:
94
+ * its property through its value in a style block or a `style` attribute,
95
+ * or the whole `style:` directive. A value-only patch (`8px` → `var(...)`)
96
+ * applied at the first `8px` at or after the finding's line, which in
97
+ * `width: 8px; padding: 8px` was a property the rule never flags. No patch
98
+ * when the source under the declaration differs from the text the census
99
+ * read (a comment or a string the neutralised copy blanked), since a `from`
100
+ * built from the blanked copy would never match the file. */
101
+ export function declarationPatch(text, region, decl, rewritten) {
102
+ if (region.site === 'directive') {
103
+ const from = region.raw;
104
+ const quote = from.at(-1);
105
+ const open = from.indexOf(quote);
106
+ const inner = from.slice(open + 1, -1);
107
+ if (inner.trim() !== decl.value) return null;
108
+ return { from, to: from.slice(0, open + 1) + inner.replace(decl.value, rewritten) + quote };
109
+ }
110
+ const start = region.offset + decl.index;
111
+ const from = text.slice(start, start + decl.text.length);
112
+ if (from !== decl.text) return null;
113
+ return { from, to: from.slice(0, -decl.value.length) + rewritten };
114
+ }
115
+
116
+ /**
117
+ * The attributes of one component tag starting at `start` (the `<`), read with
118
+ * `{}` depth and quotes tracked so an expression holding `>` does not end the
119
+ * tag early. Returns null when the tag spreads an object, which makes its prop
120
+ * set unknowable.
121
+ */
122
+ export function tagAttributes(code, start) {
123
+ const tagEnd = (() => {
124
+ let depth = 0;
125
+ let quote = null;
126
+ for (let j = start; j < code.length; j++) {
127
+ const c = code[j];
128
+ if (quote) {
129
+ if (c === quote) quote = null;
130
+ } else if (c === '"' || c === "'") quote = c;
131
+ else if (c === '{') depth++;
132
+ else if (c === '}') depth--;
133
+ else if (c === '>' && depth === 0) return j;
134
+ }
135
+ return code.length;
136
+ })();
137
+ // The tag name carries no whitespace of its own, so the first whitespace
138
+ // character at or after `start` — space, tab, or a wrapped newline — is
139
+ // always the boundary right after it, never only a literal space on the
140
+ // same line.
141
+ let i = -1;
142
+ for (let k = start; k < tagEnd; k++) {
143
+ if (/\s/.test(code[k])) {
144
+ i = k;
145
+ break;
146
+ }
147
+ }
148
+ if (i === -1) return { attrs: [], end: tagEnd };
149
+ const attrs = [];
150
+ while (i < tagEnd) {
151
+ const c = code[i];
152
+ if (/\s/.test(c) || c === '/') {
153
+ i++;
154
+ continue;
155
+ }
156
+ if (c === '{') {
157
+ let depth = 0;
158
+ let j = i;
159
+ for (; j < tagEnd; j++) {
160
+ if (code[j] === '{') depth++;
161
+ else if (code[j] === '}' && --depth === 0) break;
162
+ }
163
+ const inner = code.slice(i + 1, j).trim();
164
+ if (inner.startsWith('...')) return null;
165
+ if (/^\w+$/.test(inner)) attrs.push({ name: inner, value: null, index: i, end: j + 1 });
166
+ i = j + 1;
167
+ continue;
168
+ }
169
+ const name = code.slice(i).match(/^[^\s=/>]+/)?.[0];
170
+ if (!name) break;
171
+ const at = i;
172
+ i += name.length;
173
+ // Horizontal whitespace around `=` reads as the same attribute — Svelte
174
+ // itself accepts `size = "small"` — but a newline on either side does
175
+ // not: that's a bare boolean attribute followed by unrelated markup.
176
+ let j = i;
177
+ while (j < tagEnd && /[^\S\n]/.test(code[j])) j++;
178
+ let value = null;
179
+ if (code[j] === '=') {
180
+ i = j + 1;
181
+ while (i < tagEnd && /[^\S\n]/.test(code[i])) i++;
182
+ const q = code[i];
183
+ if (q === '"' || q === "'") {
184
+ const close = code.indexOf(q, i + 1);
185
+ value = code.slice(i + 1, close === -1 ? tagEnd : close);
186
+ i = close === -1 ? tagEnd : close + 1;
187
+ } else if (q === '{') {
188
+ let depth = 0;
189
+ for (; i < tagEnd; i++) {
190
+ if (code[i] === '{') depth++;
191
+ else if (code[i] === '}' && --depth === 0) break;
192
+ }
193
+ i++;
194
+ } else {
195
+ const bare = code.slice(i).match(/^[^\s>]+/)?.[0] ?? '';
196
+ value = bare;
197
+ i += bare.length;
198
+ }
199
+ }
200
+ // `end` marks where this attribute's own text stops, so a fixer can delete
201
+ // exactly it without disturbing whatever follows in the tag.
202
+ attrs.push({ name, value, index: at, end: i });
203
+ }
204
+ return { attrs, end: tagEnd };
205
+ }
206
+
207
+ /**
208
+ * `{@html ...}` blanked, brace-balanced, so a tag textually present inside a
209
+ * raw HTML string is not mistaken for markup the page authored.
210
+ */
211
+ export function blankHtmlExpressions(code) {
212
+ let out = code;
213
+ const re = /\{@html\b/g;
214
+ let m;
215
+ while ((m = re.exec(out)) !== null) {
216
+ const start = m.index;
217
+ let depth = 0;
218
+ let i = start;
219
+ for (; i < out.length; i++) {
220
+ if (out[i] === '{') depth++;
221
+ else if (out[i] === '}' && --depth === 0) {
222
+ i++;
223
+ break;
224
+ }
225
+ }
226
+ out = out.slice(0, start) + ' '.repeat(i - start) + out.slice(i);
227
+ re.lastIndex = start;
228
+ }
229
+ return out;
230
+ }
@@ -9,29 +9,6 @@ import { relative } from 'node:path';
9
9
  import { COMPONENT_RULES, checkComponent, discoverComponents } from '../check-component.mjs';
10
10
  import { COMPONENT_IMPORT, PAGE_RULES, checkPages, discoverPages } from '../check-page.mjs';
11
11
  import { applySeverity, readChecksConfig } from './findings.mjs';
12
- import { extractGlobalRootBlocks } from './tokenVocabulary.mjs';
13
-
14
- const SIDES = ['-top', '-right', '-bottom', '-left'];
15
-
16
- /**
17
- * Tokens a component declares that nothing in its file reads. A read is the
18
- * name appearing outside the `:global(:root)` block: in a `var()`, in a `style:`
19
- * directive, or as the string a padding mixin takes. SCSS interpolation
20
- * (`--badge-#{$v}-surface`) reads every token the pattern covers. A per-side
21
- * padding is read through its parent.
22
- */
23
- export function unreadTokens(source, tokens) {
24
- let body = source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/<!--[\s\S]*?-->/g, ' ');
25
- for (const block of extractGlobalRootBlocks(body)) body = body.replace(block, ' ');
26
- const patterns = [...body.matchAll(/--[a-z0-9-]*(?:#\{[^}]*\}[a-z0-9-]*)+/g)].map(
27
- (m) => new RegExp(`^${m[0].replace(/[.*+?^()|[\]\\]/g, '\\$&').replace(/#\{[^}]*\}/g, '[a-z0-9-]+')}$`),
28
- );
29
- const isRead = (name) => body.includes(name) || patterns.some((re) => re.test(name));
30
- return [...tokens].filter((name) => {
31
- const side = SIDES.find((s) => name.endsWith(s));
32
- return !isRead(name) && !(side && isRead(name.slice(0, -side.length)));
33
- });
34
- }
35
12
 
36
13
  function countByRule(findings) {
37
14
  const out = {};
@@ -39,34 +16,50 @@ function countByRule(findings) {
39
16
  return out;
40
17
  }
41
18
 
42
- function summarise(findings, rules, config) {
43
- const resolved = applySeverity(findings, rules, {}, config);
44
- const strict = applySeverity(findings, rules, { strict: true }, config);
19
+ const SEVERITY_ORDER = { error: 0, warn: 1 };
20
+
21
+ /**
22
+ * Errors first, then the rule with the most findings, then file and line. The
23
+ * rule tiebreak keeps one rule's findings together when two rules tie on count,
24
+ * so the list reads as a fix list rather than a file listing.
25
+ */
26
+ function sortFindings(findings) {
27
+ const counts = countByRule(findings);
28
+ return [...findings].sort(
29
+ (a, b) =>
30
+ SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
31
+ counts[b.rule] - counts[a.rule] ||
32
+ a.rule.localeCompare(b.rule) ||
33
+ a.file.localeCompare(b.file) ||
34
+ a.line - b.line,
35
+ );
36
+ }
37
+
38
+ function summarise(findings, rules, config, exclude) {
39
+ const resolved = sortFindings(applySeverity(findings, rules, {}, config, { exclude }));
40
+ const strict = applySeverity(findings, rules, { strict: true }, config, { exclude });
45
41
  return {
46
42
  errors: resolved.filter((f) => f.severity === 'error').length,
47
43
  warnings: resolved.filter((f) => f.severity === 'warn').length,
48
44
  strictErrors: strict.filter((f) => f.severity === 'error').length,
49
45
  byRule: countByRule(resolved),
50
- items: resolved.map((f) => ({ rule: f.rule, severity: f.severity, file: f.file, line: f.line, message: f.message })),
46
+ items: resolved,
51
47
  };
52
48
  }
53
49
 
54
50
  export function buildReport(vocab, { root = process.cwd() } = {}) {
55
51
  const config = readChecksConfig(root);
56
52
 
57
- const components = [...vocab.components.values()].map((c) => {
58
- const source = readFileSync(c.file, 'utf8');
59
- return {
60
- id: c.id,
61
- name: c.name,
62
- origin: c.origin,
63
- file: relative(root, c.file),
64
- registered: vocab.builtIn.has(c.id) || vocab.registered.has(c.id),
65
- described: /^\s*<!--[\s\S]*?-->/.test(source),
66
- tokens: c.tokens.size,
67
- unread: unreadTokens(source, c.tokens.keys()),
68
- };
69
- });
53
+ // What a component is, as facts. What is wrong with it is a finding:
54
+ // unread properties, a missing description, and an unregistered project
55
+ // component are rules now, each carrying its own severity and repair.
56
+ const components = [...vocab.components.values()].map((c) => ({
57
+ id: c.id,
58
+ origin: c.origin,
59
+ file: relative(root, c.file),
60
+ registered: vocab.builtIn.has(c.id) || vocab.registered.has(c.id),
61
+ tokens: c.tokens.size,
62
+ }));
70
63
 
71
64
  const pageFiles = discoverPages(root);
72
65
  const byPage = [];
@@ -92,6 +85,9 @@ export function buildReport(vocab, { root = process.cwd() } = {}) {
92
85
  const authored = discoverComponents(root);
93
86
  const componentFindings = authored.flatMap((id) => checkComponent(id, root, { vocabulary: vocab }).findings);
94
87
 
88
+ // Key order is the order a reader takes the report in, and `migrations` is
89
+ // filled by the caller (it needs the compiled engine), so it holds its place
90
+ // here rather than landing last.
95
91
  return {
96
92
  project: {
97
93
  root,
@@ -100,24 +96,29 @@ export function buildReport(vocab, { root = process.cwd() } = {}) {
100
96
  components: components.length,
101
97
  pages: pageFiles.length,
102
98
  },
99
+ migrations: null,
103
100
  components,
101
+ findings: {
102
+ pages: summarise(pageFindings, PAGE_RULES, config, true),
103
+ components: { checked: authored, ...summarise(componentFindings, COMPONENT_RULES, config) },
104
+ },
104
105
  usage: {
105
106
  byPage,
106
107
  byComponent,
107
108
  unusedShipped: byComponent.filter((c) => c.origin === 'shipped' && c.pages.length === 0).map((c) => c.id),
108
- customUnregistered: components.filter((c) => c.origin === 'custom' && !c.registered).map((c) => c.id),
109
109
  customUnused: byComponent.filter((c) => c.origin === 'custom' && c.pages.length === 0).map((c) => c.id),
110
110
  },
111
- findings: {
112
- pages: summarise(pageFindings, PAGE_RULES, config),
113
- components: { checked: authored, ...summarise(componentFindings, COMPONENT_RULES, config) },
114
- },
115
111
  };
116
112
  }
117
113
 
118
114
  const list = (items, max = 20) =>
119
115
  items.length <= max ? items.join(', ') : `${items.slice(0, max).join(', ')}, +${items.length - max} more`;
120
116
 
117
+ /** How a rule's findings are repaired. A rule that lowered its repair on some
118
+ * of them names both, so the line never overstates what code can do. */
119
+ const repairsFor = (items, rule) =>
120
+ [...new Set(items.filter((f) => f.rule === rule).map((f) => f.repair))].sort().join('/');
121
+
121
122
  export function formatReport(r) {
122
123
  const out = [];
123
124
  out.push(`Project: ${r.project.pages} page file(s), ${r.project.components} component(s), ${r.project.themeTokens} design tokens from ${r.project.tokensCss ?? '(no tokens.css)'}`);
@@ -128,14 +129,19 @@ export function formatReport(r) {
128
129
 
129
130
  out.push('');
130
131
  out.push('Components');
131
- const unread = r.components.filter((c) => c.unread.length);
132
- out.push(` tokens declared and read by their own CSS: ${r.components.reduce((n, c) => n + c.tokens - c.unread.length, 0)} of ${r.components.reduce((n, c) => n + c.tokens, 0)}`);
133
- for (const c of unread) out.push(` ${c.id}: ${c.unread.length} unread (${list(c.unread, 6)})`);
134
- const undescribed = r.components.filter((c) => !c.described).map((c) => c.id);
135
- if (undescribed.length) out.push(` no description comment: ${list(undescribed)}`);
132
+ out.push(` semantic properties declared: ${r.components.reduce((n, c) => n + c.tokens, 0)}`);
136
133
  const custom = r.components.filter((c) => c.origin === 'custom');
137
134
  out.push(` custom: ${custom.length}${custom.length ? ` (${list(custom.map((c) => c.id))})` : ''}`);
138
- if (r.usage.customUnregistered.length) out.push(` not registered: ${list(r.usage.customUnregistered)}`);
135
+
136
+ const section = (label, s) => {
137
+ out.push('');
138
+ out.push(`${label}: ${s.errors} error(s), ${s.warnings} warning(s); ${s.strictErrors} under --strict`);
139
+ for (const [rule, n] of Object.entries(s.byRule).sort((a, b) => b[1] - a[1])) {
140
+ out.push(` ${rule}: ${n} [${repairsFor(s.items, rule)}]`);
141
+ }
142
+ };
143
+ section('check-page', r.findings.pages);
144
+ section(`check-component (${r.findings.components.checked.length} authored)`, r.findings.components);
139
145
 
140
146
  out.push('');
141
147
  out.push('Usage');
@@ -146,13 +152,5 @@ export function formatReport(r) {
146
152
  out.push(` pages rendering no catalogue component: ${r.usage.byPage.filter((p) => p.components.length === 0).length}`);
147
153
  out.push(` shipped components used nowhere: ${r.usage.unusedShipped.length}${r.usage.unusedShipped.length ? ` (${list(r.usage.unusedShipped)})` : ''}`);
148
154
  if (r.usage.customUnused.length) out.push(` custom components used nowhere: ${list(r.usage.customUnused)}`);
149
-
150
- const section = (label, s) => {
151
- out.push('');
152
- out.push(`${label}: ${s.errors} error(s), ${s.warnings} warning(s); ${s.strictErrors} under --strict`);
153
- for (const [rule, n] of Object.entries(s.byRule).sort((a, b) => b[1] - a[1])) out.push(` ${rule}: ${n}`);
154
- };
155
- section('check-page', r.findings.pages);
156
- section(`check-component (${r.findings.components.checked.length} authored)`, r.findings.components);
157
155
  return out.join('\n');
158
156
  }
@@ -16,7 +16,7 @@
16
16
  // Reads files only. Nothing here may import dist-plugin at module top: CI runs
17
17
  // the suite before the plugin is built (see bin/engineLoadsLazily.test.ts).
18
18
 
19
- import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
19
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
20
20
  import { dirname, join, resolve } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { resolveTokensCssPath } from '../migrate.mjs';
@@ -25,9 +25,20 @@ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
25
25
 
26
26
  const SHIPPED_COMPONENTS_DIR = 'src/system/components';
27
27
 
28
- /** Token scales whose names are governed by the token contract (see TOKENS.md). */
28
+ // Shipped components keep their editor beside the other editors; a
29
+ // consumer-authored one sits next to its runtime. Probe both.
30
+ export const EDITOR_DIRS = ['src/system/components', 'src/editor/component-editor'];
31
+
32
+ function capitalize(id) {
33
+ return id.charAt(0).toUpperCase() + id.slice(1);
34
+ }
35
+
36
+ /** Token scales whose names are governed by the token contract (see TOKENS.md).
37
+ * `border-width` stands apart from `border` because the two measure different
38
+ * things: a stroke's width resolves on a length scale, its paint on a colour
39
+ * one, and a repair that offered the other's tokens would be nonsense. */
29
40
  export const CONTRACT_SCALES = [
30
- 'surface', 'text', 'border', 'color', 'space', 'radius', 'font', 'line-height',
41
+ 'surface', 'text', 'border', 'border-width', 'color', 'space', 'radius', 'font', 'line-height',
31
42
  'letter-spacing', 'shadow', 'blur', 'icon-size', 'scrim', 'tint', 'columns',
32
43
  'heading', 'body', 'editorial', 'eyebrow', 'code', 'easing', 'duration', 'zoom',
33
44
  'gradient', 'stroke',
@@ -144,26 +155,6 @@ function walk(dir, exts, out = []) {
144
155
  return out;
145
156
  }
146
157
 
147
- function componentFiles(roots) {
148
- const seen = new Set();
149
- const files = [];
150
- for (const dir of roots) {
151
- for (const file of walk(dir, ['.svelte'])) {
152
- if (file.endsWith('Editor.svelte')) continue;
153
- let key = file;
154
- try {
155
- key = realpathSync(file);
156
- } catch {
157
- // unreadable link; fall back to the path itself
158
- }
159
- if (seen.has(key)) continue;
160
- seen.add(key);
161
- files.push(file);
162
- }
163
- }
164
- return files;
165
- }
166
-
167
158
  /** The package's own component ids, read from the frozen registry that declares
168
159
  them. A shipped component is registered by the package rather than by the
169
160
  project, so it never appears in the project's own `registerComponent` scan. */
@@ -197,6 +188,73 @@ function registeredIds(root) {
197
188
  return ids;
198
189
  }
199
190
 
191
+ /**
192
+ * Every component this project can resolve on its own: a runtime under
193
+ * `src/system/components` or a configured `componentDirs` entry, plus every
194
+ * id `registeredIds` finds with no runtime anywhere, so a dangling
195
+ * registration is a fact in the inventory rather than a silent gap.
196
+ *
197
+ * Deliberately never looks under `pkgRoot` for a shipped id a project hasn't
198
+ * vendored — that is `resolveComponentPaths`'s own fallback for a single
199
+ * named id, not a batch this inventory would run over (a consumer's batch is
200
+ * their own components only; shipped runtimes sit in node_modules, where no
201
+ * inventory looks).
202
+ *
203
+ * The single scan every caller reads: `report`, the `check-component` batch,
204
+ * `resolveComponentPaths`, and the `components` verb.
205
+ */
206
+ export function componentInventory(root = process.cwd(), pkgRoot = PKG_ROOT) {
207
+ const built = builtInIds(root, pkgRoot);
208
+ const registered = registeredIds(root);
209
+ const dirs = [SHIPPED_COMPONENTS_DIR, ...(readProjectConfig(root).componentDirs ?? [])];
210
+ const entries = new Map();
211
+
212
+ const guessEditorPath = (Id) =>
213
+ EDITOR_DIRS.map((d) => join(root, d, `${Id}Editor.svelte`)).find(existsSync) ??
214
+ join(root, EDITOR_DIRS[0], `${Id}Editor.svelte`);
215
+
216
+ for (const dirRel of dirs) {
217
+ const dir = join(root, dirRel);
218
+ if (!existsSync(dir)) continue;
219
+ for (const file of walk(dir, ['.svelte'])) {
220
+ const fileName = file.slice(file.lastIndexOf('/') + 1);
221
+ if (fileName.endsWith('Editor.svelte')) continue;
222
+ const Id = fileName.replace('.svelte', '');
223
+ const id = Id.toLowerCase();
224
+ if (entries.has(id)) continue;
225
+ const editorPath = guessEditorPath(Id);
226
+ entries.set(id, {
227
+ id,
228
+ Id,
229
+ origin: built.has(id) ? 'shipped' : 'custom',
230
+ runtimePath: file,
231
+ editorPath,
232
+ registered: built.has(id) || registered.has(id),
233
+ runtimeExists: true,
234
+ editorExists: existsSync(editorPath),
235
+ });
236
+ }
237
+ }
238
+
239
+ for (const id of registered) {
240
+ if (entries.has(id)) continue;
241
+ const Id = capitalize(id);
242
+ const editorPath = guessEditorPath(Id);
243
+ entries.set(id, {
244
+ id,
245
+ Id,
246
+ origin: built.has(id) ? 'shipped' : 'custom',
247
+ runtimePath: join(root, SHIPPED_COMPONENTS_DIR, `${Id}.svelte`),
248
+ editorPath,
249
+ registered: true,
250
+ runtimeExists: false,
251
+ editorExists: existsSync(editorPath),
252
+ });
253
+ }
254
+
255
+ return entries;
256
+ }
257
+
200
258
  /**
201
259
  * Build the vocabulary for `root` (a consumer project, or this repo).
202
260
  *
@@ -217,12 +275,26 @@ export function loadVocabulary({ root = process.cwd(), pkgRoot = PKG_ROOT } = {}
217
275
 
218
276
  const componentTokens = new Set();
219
277
  const components = new Map();
220
- // A project's own components sit beside the shipped ones, plus any directory
221
- // `componentDirs` in live-tokens.config.json names.
222
- const own = [SHIPPED_COMPONENTS_DIR, ...(readProjectConfig(root).componentDirs ?? [])].map((d) => join(root, d));
223
- const shippedDir = join(pkgRoot, SHIPPED_COMPONENTS_DIR);
224
- const dirs = [shippedDir, ...own];
225
- for (const file of componentFiles(dirs)) {
278
+ const registered = registeredIds(root);
279
+ const builtIn = builtInIds(root, pkgRoot);
280
+
281
+ // The package's shipped directory first, so a shipped component's tokens
282
+ // are in the vocabulary even when a project hasn't vendored it — the one
283
+ // gap the inventory itself deliberately leaves, since it never looks under
284
+ // `pkgRoot`. Then the project's own inventory (its `src/system/components`
285
+ // plus any `componentDirs`), which overrides by id: a project's own copy of
286
+ // a shipped component wins over the package's. Reusing the inventory here,
287
+ // instead of a second directory scan, is what keeps `report.components` and
288
+ // `report.findings.components.checked` naming the same ids.
289
+ const filesById = new Map();
290
+ for (const entry of componentInventory(pkgRoot, pkgRoot).values()) {
291
+ if (entry.runtimeExists) filesById.set(entry.id, entry.runtimePath);
292
+ }
293
+ for (const entry of componentInventory(root, pkgRoot).values()) {
294
+ if (entry.runtimeExists) filesById.set(entry.id, entry.runtimePath);
295
+ }
296
+
297
+ for (const [id, file] of filesById) {
226
298
  const Id = file.slice(file.lastIndexOf('/') + 1).replace('.svelte', '');
227
299
  const src = readFileSync(file, 'utf8');
228
300
  const tokens = new Map();
@@ -231,17 +303,15 @@ export function loadVocabulary({ root = process.cwd(), pkgRoot = PKG_ROOT } = {}
231
303
  for (const n of declaredCustomProperties(clean)) componentTokens.add(n);
232
304
  for (const m of clean.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) if (!tokens.has(m[1])) tokens.set(m[1], m[2].trim());
233
305
  }
234
- components.set(Id.toLowerCase(), {
235
- id: Id.toLowerCase(),
306
+ components.set(id, {
307
+ id,
236
308
  name: Id,
237
309
  file,
238
- origin: file.startsWith(shippedDir) ? 'shipped' : 'custom',
310
+ origin: builtIn.has(id) ? 'shipped' : 'custom',
239
311
  props: componentProps(src),
240
312
  tokens,
241
313
  });
242
314
  }
243
- const registered = registeredIds(root);
244
- const builtIn = builtInIds(root, pkgRoot);
245
315
 
246
316
  return {
247
317
  themeTokens,