@motion-proto/live-tokens 0.78.0 → 0.80.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 (172) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +49 -38
  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 +271 -0
  13. package/README.md +10 -16
  14. package/bin/check-component.mjs +154 -551
  15. package/bin/check-page.mjs +57 -541
  16. package/bin/cli.mjs +93 -16
  17. package/bin/contractRunner.mjs +37 -91
  18. package/bin/lib/buildChecks.mjs +32 -0
  19. package/bin/lib/catalogue.mjs +161 -34
  20. package/bin/lib/componentSource.mjs +152 -0
  21. package/bin/lib/cssValues.mjs +9 -0
  22. package/bin/lib/dataDir.mjs +126 -0
  23. package/bin/lib/findings.mjs +103 -12
  24. package/bin/lib/fixers.mjs +64 -0
  25. package/bin/lib/geometry.mjs +92 -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/migrate-build-script.mjs +66 -0
  30. package/bin/migrate.mjs +5 -0
  31. package/bin/rules/componentStructure.mjs +344 -0
  32. package/bin/rules/componentUse.mjs +313 -0
  33. package/bin/rules/importsAndRoutes.mjs +136 -0
  34. package/bin/rules/testRuns.mjs +122 -0
  35. package/bin/rules/tokens.mjs +426 -0
  36. package/bin/setup-claude.mjs +1 -2
  37. package/dist-plugin/{chunk-PDNL4NC5.js → chunk-D4WRIKEZ.js} +7 -2
  38. package/dist-plugin/{chunk-SWXRVZKT.js → chunk-REBHE3ZM.js} +414 -1
  39. package/dist-plugin/index.cjs +470 -19
  40. package/dist-plugin/index.d.cts +1 -0
  41. package/dist-plugin/index.d.ts +1 -0
  42. package/dist-plugin/index.js +87 -55
  43. package/dist-plugin/migrateData/index.cjs +422 -4
  44. package/dist-plugin/migrateData/index.js +2 -2
  45. package/dist-plugin/setColors/index.cjs +414 -1
  46. package/dist-plugin/setColors/index.d.cts +1 -1
  47. package/dist-plugin/setColors/index.d.ts +1 -1
  48. package/dist-plugin/setColors/index.js +1 -1
  49. package/dist-plugin/setGeometry/index.cjs +423 -13
  50. package/dist-plugin/setGeometry/index.d.cts +3 -3
  51. package/dist-plugin/setGeometry/index.d.ts +3 -3
  52. package/dist-plugin/setGeometry/index.js +10 -13
  53. package/dist-plugin/setType/index.d.cts +1 -1
  54. package/dist-plugin/setType/index.d.ts +1 -1
  55. package/dist-plugin/{themeTypes-BxRtuN5V.d.cts → themeTypes-B8_Idrp4.d.cts} +2 -2
  56. package/dist-plugin/{themeTypes-BxRtuN5V.d.ts → themeTypes-B8_Idrp4.d.ts} +2 -2
  57. package/package.json +2 -2
  58. package/src/editor/component-editor/CalloutEditor.svelte +2 -2
  59. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +14 -15
  60. package/src/editor/component-editor/CornerBadgeEditor.svelte +14 -14
  61. package/src/editor/component-editor/DialogEditor.svelte +5 -5
  62. package/src/editor/component-editor/InlineEditActionsEditor.svelte +2 -2
  63. package/src/editor/component-editor/RadioButtonEditor.svelte +21 -21
  64. package/src/editor/component-editor/SectionDividerEditor.svelte +7 -7
  65. package/src/editor/component-editor/SegmentedControlEditor.svelte +5 -5
  66. package/src/editor/component-editor/SideNavigationEditor.svelte +25 -25
  67. package/src/editor/component-editor/TabBarEditor.svelte +6 -6
  68. package/src/editor/component-editor/TableEditor.svelte +6 -6
  69. package/src/editor/component-editor/ToggleEditor.svelte +2 -2
  70. package/src/editor/component-editor/index.ts +3 -0
  71. package/src/editor/component-editor/registry.ts +57 -1
  72. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +14 -14
  73. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +3 -0
  74. package/src/editor/component-editor/scaffolding/types.ts +15 -0
  75. package/src/editor/core/components/adjustAliases.ts +4 -4
  76. package/src/editor/core/components/aliasKinds.ts +20 -19
  77. package/src/editor/core/sketch/sketchLayer.ts +3 -3
  78. package/src/editor/core/themes/migrateComponentConfig.ts +14 -6
  79. package/src/editor/core/themes/migrations/2026-09-13-badge-brand.ts +42 -0
  80. package/src/editor/core/themes/migrations/2026-09-13-collapsiblesection-open.ts +33 -0
  81. package/src/editor/core/themes/migrations/2026-09-13-cornerbadge-prefix.ts +70 -0
  82. package/src/editor/core/themes/migrations/2026-09-13-hairline.ts +92 -0
  83. package/src/editor/core/themes/migrations/2026-09-13-indicator.ts +54 -0
  84. package/src/editor/core/themes/migrations/2026-09-13-sectiondivider-surface.ts +36 -0
  85. package/src/editor/core/themes/migrations/2026-09-13-selected-state.ts +124 -0
  86. package/src/editor/core/themes/migrations/2026-09-13-toggle-label.ts +31 -0
  87. package/src/editor/core/themes/migrations/index.ts +16 -0
  88. package/src/editor/core/themes/themeService.ts +3 -3
  89. package/src/editor/core/themes/themeTypes.ts +13 -15
  90. package/src/editor/docs/Docs.svelte +1 -1
  91. package/src/editor/docs/content/light-and-dark.md +3 -3
  92. package/src/editor/docs/content.generated.ts +1 -1
  93. package/src/editor/index.ts +1 -0
  94. package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
  95. package/src/editor/skill-atlas/SkillAtlas.svelte +4 -4
  96. package/src/editor/skill-atlas/TreeNodeCard.svelte +4 -4
  97. package/src/editor/skill-atlas/skillSources.generated.ts +11 -14
  98. package/src/editor/skill-atlas/skillTrees.ts +1 -3
  99. package/src/editor/skill-atlas/trees/check-compliance.ts +37 -149
  100. package/src/editor/skill-atlas/trees/create-component.ts +62 -64
  101. package/src/editor/skill-atlas/trees/create-page.ts +38 -17
  102. package/src/editor/skill-atlas/trees/pick-component.ts +3 -3
  103. package/src/editor/skill-atlas/trees/set-geometry.ts +1 -1
  104. package/src/editor/ui/UIPaletteSelector.svelte +11 -15
  105. package/src/editor/ui/variantScales.ts +8 -8
  106. package/src/live-tokens/data/themes/autumn.json +188 -188
  107. package/src/live-tokens/data/themes/halloween.json +188 -188
  108. package/src/live-tokens/data/themes/midnight-study.json +245 -245
  109. package/src/live-tokens/data/themes/ocean.json +188 -188
  110. package/src/live-tokens/data/themes/royal-velvet.json +188 -188
  111. package/src/live-tokens/data/themes/sketchy.json +188 -188
  112. package/src/live-tokens/data/themes/spring-meadow.json +188 -188
  113. package/src/live-tokens/data/themes/sunset.json +188 -188
  114. package/src/system/components/Badge.svelte +27 -23
  115. package/src/system/components/Button.svelte +13 -7
  116. package/src/system/components/Callout.svelte +16 -11
  117. package/src/system/components/Card.svelte +22 -15
  118. package/src/system/components/CodeSnippet.svelte +10 -6
  119. package/src/system/components/CollapsibleSection.svelte +81 -76
  120. package/src/system/components/CornerBadge.svelte +78 -72
  121. package/src/system/components/Dialog.svelte +21 -18
  122. package/src/system/components/IconButton.svelte +13 -9
  123. package/src/system/components/Image.svelte +14 -8
  124. package/src/system/components/ImageLightbox.svelte +10 -6
  125. package/src/system/components/InlineEditActions.svelte +17 -14
  126. package/src/system/components/Input.svelte +13 -7
  127. package/src/system/components/MenuSelect.svelte +13 -7
  128. package/src/system/components/Notification.svelte +17 -11
  129. package/src/system/components/Panel.svelte +13 -6
  130. package/src/system/components/ProgressBar.svelte +10 -5
  131. package/src/system/components/RadioButton.svelte +33 -30
  132. package/src/system/components/SectionDivider.svelte +28 -21
  133. package/src/system/components/SegmentedControl.svelte +27 -23
  134. package/src/system/components/SideNavigation.svelte +175 -171
  135. package/src/system/components/Slider.svelte +11 -7
  136. package/src/system/components/TabBar.svelte +53 -49
  137. package/src/system/components/Table.svelte +20 -15
  138. package/src/system/components/Toggle.svelte +14 -10
  139. package/src/system/components/Tooltip.svelte +10 -6
  140. package/src/system/styles/CONVENTIONS.md +3 -4
  141. package/src/testing-js/chunk-3UKGXCDL.js +48 -0
  142. package/src/testing-js/chunk-3UKGXCDL.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-GNIUPIU2.js → chunk-U7OJE5DU.js} +18 -18
  146. package/src/testing-js/chunk-U7OJE5DU.js.map +1 -0
  147. package/src/testing-js/{chunk-4JQX6WWL.js → chunk-ZMZQZ33J.js} +474 -166
  148. package/src/testing-js/chunk-ZMZQZ33J.js.map +1 -0
  149. package/src/testing-js/component-behavior.contract.js +155 -0
  150. package/src/testing-js/component-behavior.contract.js.map +1 -0
  151. package/src/testing-js/component-editor.contract.js +6 -4
  152. package/src/testing-js/component-editor.contract.js.map +1 -1
  153. package/src/testing-js/component-render.contract.js +14 -10
  154. package/src/testing-js/component-render.contract.js.map +1 -1
  155. package/src/testing-js/index.d.ts +44 -4
  156. package/src/testing-js/index.js +13 -7
  157. package/src/testing-js/index.js.map +1 -1
  158. package/src/testing-js/page-compliance.contract.js +48 -10
  159. package/src/testing-js/page-compliance.contract.js.map +1 -1
  160. package/src/testing-js/registry.contract.js +5 -3
  161. package/src/testing-js/registry.contract.js.map +1 -1
  162. package/src/testing-js/{vitest-C-wNWcoA.d.ts → vitest-BMLIbDs2.d.ts} +8 -2
  163. package/src/testing-js/vitest.d.ts +1 -1
  164. package/src/testing-js/vitest.js +2 -1
  165. package/template/package.json +1 -2
  166. package/template/vite.config.ts +3 -2
  167. package/.claude/skills/live-tokens-create-component/SKILL copy.md +0 -196
  168. package/.claude/skills/live-tokens-fix-findings/SKILL.md +0 -105
  169. package/src/editor/skill-atlas/trees/fix-findings.ts +0 -504
  170. package/src/testing-js/chunk-4JQX6WWL.js.map +0 -1
  171. package/src/testing-js/chunk-FAFOAWYL.js.map +0 -1
  172. package/src/testing-js/chunk-GNIUPIU2.js.map +0 -1
@@ -0,0 +1,344 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { extname, join, relative } from 'node:path';
3
+ import { catalogueOf } from '../lib/catalogue.mjs';
4
+ import { STATE_TOKENS, editorTokenRefs, readKnownSuffixes, tokenSuffix } from '../lib/componentSource.mjs';
5
+ import { PKG_ROOT, builtInIds } from '../lib/tokenVocabulary.mjs';
6
+
7
+ const RUNTIME = 'Wire the component as the recipe in live-tokens-create-component wires it.';
8
+
9
+ const PROPERTY_NAME =
10
+ 'Rename the semantic property to the name a shipped component uses for the same role. The vocabulary and the state model are in live-tokens-create-component.';
11
+
12
+ const EDITOR =
13
+ 'Fix the editor schema, states, or preview props by the Component editor section of live-tokens-create-component.';
14
+
15
+ export const componentRules = {
16
+ 'invalid-id': {
17
+ severity: 'error',
18
+ repair: 'authored',
19
+ guidance: `Give the component an id of lowercase letters and digits with no dashes, and use it in the runtime, the editor, and the registration. ${RUNTIME}`,
20
+ },
21
+ 'missing-file': {
22
+ severity: 'error',
23
+ repair: 'authored',
24
+ guidance: `Create the runtime or editor file the message names. ${RUNTIME}`,
25
+ },
26
+ 'missing-root-block': {
27
+ severity: 'error',
28
+ repair: 'authored',
29
+ guidance: `Declare the semantic properties in a :global(:root) block in the runtime's <style>. ${RUNTIME}`,
30
+ },
31
+ 'no-tokens': {
32
+ severity: 'error',
33
+ repair: 'authored',
34
+ guidance: `Declare the component's --<id>-* semantic properties in its :global(:root) block. ${RUNTIME}`,
35
+ },
36
+ 'missing-description': {
37
+ severity: 'warn',
38
+ repair: 'authored',
39
+ guidance:
40
+ "Add the catalogue export to the runtime's <script module> block, with description, useFor, and notFor as string literals. The message names the field that is missing or malformed, and `npx live-tokens components <id>` prints the entry once it is there.",
41
+ },
42
+ 'state-after-property': {
43
+ severity: 'error',
44
+ repair: 'authored',
45
+ guidance: `Move the state ahead of the property suffix, such as -hover-surface. ${PROPERTY_NAME}`,
46
+ },
47
+ 'disabled-is-terminal': {
48
+ severity: 'error',
49
+ repair: 'authored',
50
+ guidance: `Disabled is terminal, so a name that joins disabled with another state never paints. Delete the semantic property, or rename it. ${PROPERTY_NAME}`,
51
+ },
52
+ 'unknown-suffix': {
53
+ severity: 'error',
54
+ repair: 'authored',
55
+ guidance: `End the name in a suffix check-component accepts. references/token-naming.md in live-tokens-create-component lists every suffix, and the suffix selects the editor control. ${PROPERTY_NAME}`,
56
+ },
57
+ 'phantom-editor-token': {
58
+ severity: 'error',
59
+ repair: 'authored',
60
+ guidance: `The editor names a semantic property the runtime never declares. Point the row at a declared property, or declare the property in the runtime's :global(:root) block. ${EDITOR}`,
61
+ },
62
+ 'missing-component-const': {
63
+ severity: 'error',
64
+ repair: 'authored',
65
+ guidance: `Declare const component = '<id>' in the editor's <script module> block. ${EDITOR}`,
66
+ },
67
+ 'missing-all-tokens': {
68
+ severity: 'error',
69
+ repair: 'authored',
70
+ guidance: `Export allTokens from the editor's <script module> block. ${EDITOR}`,
71
+ },
72
+ 'missing-registration': {
73
+ severity: 'error',
74
+ repair: 'authored',
75
+ guidance:
76
+ 'Register the component in the shared module the Registration section of live-tokens-create-component wires up, importable by the app and by check-component --tests. The call is registerComponent({ id }) or an entry in bootLiveTokens({ components }).',
77
+ },
78
+ 'phantom-link': {
79
+ severity: 'warn',
80
+ repair: 'authored',
81
+ guidance: `Pass { component, variants } to buildTypeGroupTokens or buildTypeGroupFontTokens, so each slot keeps its own font keys. ${EDITOR}`,
82
+ },
83
+ };
84
+
85
+ /** True when `id` is one of the package's own components. */
86
+ function isBuiltIn(id) {
87
+ return builtInIds(process.cwd(), PKG_ROOT).has(id);
88
+ }
89
+
90
+ // Disabled is terminal: a disabled component cannot be hovered, focused, or
91
+ // selected, so a token naming both describes a state that never paints.
92
+ const TERMINAL_CONFLICTS = ['hover', 'focus', 'focused', 'selected', 'on', 'active', 'checked'];
93
+
94
+ export function checkId(id, record) {
95
+ if (/^[a-z][a-z0-9]*$/.test(id)) return true;
96
+ record('invalid-id', `id "${id}" is invalid; must be lowercase letters/digits, no dashes`);
97
+ return false;
98
+ }
99
+
100
+ /** The editor is checked first, so an id with neither file gets both findings
101
+ * rather than stopping silently on the first. */
102
+ export function checkFiles({ root, runtimePath, editorPath }, record) {
103
+ const editorMissing = !existsSync(editorPath);
104
+ if (editorMissing) {
105
+ record('missing-file', `editor missing: ${relative(root, editorPath)}`);
106
+ }
107
+ const runtimeMissing = !existsSync(runtimePath);
108
+ if (runtimeMissing) {
109
+ record('missing-file', `runtime missing: ${relative(root, runtimePath)}`);
110
+ }
111
+ return { editorMissing, runtimeMissing };
112
+ }
113
+
114
+ export function checkRuntime({ id, Id, root, runtimePath, runtime, blocks, intrinsic }, record) {
115
+ // Runtime: the file exports the `catalogue` that says what the component
116
+ // is for. Without it the component is pickable but unexplained.
117
+ const catalogue = catalogueOf(runtime);
118
+ if (!catalogue) {
119
+ record(
120
+ 'missing-description',
121
+ `${relative(root, runtimePath)}: has no catalogue export. Say what ${Id} is for, and what it is not for`,
122
+ );
123
+ } else {
124
+ for (const requiredField of ['description', 'useFor', 'notFor']) {
125
+ if (!catalogue[requiredField]) {
126
+ record('missing-description', `${relative(root, runtimePath)}: catalogue has no ${requiredField}`);
127
+ }
128
+ }
129
+ }
130
+
131
+ // Runtime: :global(:root) block present.
132
+ if (blocks.length === 0) {
133
+ record('missing-root-block', `${relative(root, runtimePath)}: missing :global(:root) declaration block`);
134
+ }
135
+
136
+ // Runtime: at least one --<id>-* token.
137
+ const tokens = extractTokensForId(blocks, id);
138
+ if (blocks.length > 0 && tokens.length === 0) {
139
+ record('no-tokens', `${relative(root, runtimePath)}: no --${id}-* tokens declared in :global(:root)`);
140
+ }
141
+
142
+ const known = readKnownSuffixes(root);
143
+
144
+ // Runtime: state-after-property anti-pattern. Report this first; if it fires
145
+ // for a token, skip the unknown-suffix error for the same token (the state-
146
+ // suffix wouldn't be in the suffix list anyway, so it's the same root cause).
147
+ const stateAfterTokens = new Set();
148
+ for (const token of tokens) {
149
+ if (intrinsic.some((re) => re.test(token))) continue;
150
+ const trailingState = detectStateAfterProperty(token, known);
151
+ if (trailingState) {
152
+ stateAfterTokens.add(token);
153
+ record(
154
+ 'state-after-property',
155
+ `${relative(root, runtimePath)}: ${token} has '${trailingState}' after the property; ` +
156
+ `state must come before property (e.g. -${trailingState}-surface)`,
157
+ runtime.indexOf(token),
158
+ );
159
+ }
160
+ }
161
+
162
+ for (const token of tokens) {
163
+ const segments = token.slice(2).split('-');
164
+ if (!segments.includes('disabled')) continue;
165
+ const conflict = TERMINAL_CONFLICTS.find((s) => segments.includes(s));
166
+ if (conflict) {
167
+ record(
168
+ 'disabled-is-terminal',
169
+ `${relative(root, runtimePath)}: ${token} combines 'disabled' with '${conflict}'; disabled is terminal, so that state never paints. Drop the token.`,
170
+ runtime.indexOf(token),
171
+ );
172
+ }
173
+ }
174
+
175
+ // Runtime: every token ends in a known suffix.
176
+ for (const token of tokens) {
177
+ if (stateAfterTokens.has(token) || intrinsic.some((re) => re.test(token))) continue;
178
+ if (!tokenSuffix(token, known)) {
179
+ record(
180
+ 'unknown-suffix',
181
+ `${relative(root, runtimePath)}: ${token} doesn't end in a known suffix`,
182
+ runtime.indexOf(token),
183
+ );
184
+ }
185
+ }
186
+ }
187
+
188
+ export function checkEditor({ id, root, runtimePath, editorPath, editor, declared }, record) {
189
+ // Editor: declares `const component = '<id>'` (module block).
190
+ const componentDecl = new RegExp(`\\bconst\\s+component\\s*=\\s*['"]${id}['"]`);
191
+ if (!componentDecl.test(editor)) {
192
+ record(
193
+ 'missing-component-const',
194
+ `${relative(root, editorPath)}: missing 'const component = "${id}"' in <script module>`,
195
+ );
196
+ }
197
+
198
+ // Editor: exports allTokens.
199
+ if (!/\bexport\s+const\s+allTokens\b/.test(editor)) {
200
+ record('missing-all-tokens', `${relative(root, editorPath)}: missing 'export const allTokens'`);
201
+ }
202
+
203
+ // Editor: every token a row names is one the runtime declares. A row that
204
+ // names nothing renders a control that edits nothing.
205
+ const refs = editorTokenRefs(editor);
206
+ for (const name of refs.literals) {
207
+ if (!declared.has(name)) {
208
+ record(
209
+ 'phantom-editor-token',
210
+ `${relative(root, editorPath)}: names ${name}, which ${relative(root, runtimePath)} never declares in :global(:root)`,
211
+ );
212
+ }
213
+ }
214
+ for (const [name, re] of refs.patterns) {
215
+ if (![...declared].some((d) => re.test(d))) {
216
+ record(
217
+ 'phantom-editor-token',
218
+ `${relative(root, editorPath)}: names ${name}, which matches nothing ${relative(root, runtimePath)} declares in :global(:root)`,
219
+ );
220
+ }
221
+ }
222
+
223
+ // Editor: phantom-link guard. The font type-group helpers fall back to bare
224
+ // `font-family`/`font-size`/… keys when called with a single argument (no
225
+ // derivation). Across more than one slot that silently links every slot's fonts
226
+ // into one tree. Passing `{ component, variants }` (a second arg) opts into
227
+ // distinct, structural keys and suppresses the check, so this only fires on the
228
+ // silent inference path. (The color helper no longer infers — a bare call there
229
+ // emits solo, un-grouped colors, which can't phantom-link.)
230
+ const colorPatterns = new Set();
231
+ for (const m of editor.matchAll(/colorVariable\s*:\s*[`'"]([^`'"]+)[`'"]/g)) {
232
+ colorPatterns.add(m[1].replace(/\$\{[^}]*\}/g, '*'));
233
+ }
234
+ const slots = colorPatterns.size;
235
+ const fontBare =
236
+ hasBareCall(editor, 'buildTypeGroupFontTokens') || hasBareCall(editor, 'buildTypeGroupTokens');
237
+ if (slots > 1 && fontBare) {
238
+ record(
239
+ 'phantom-link',
240
+ `${relative(root, editorPath)}: a type-group font helper is called across ${slots} slots without a derivation; ` +
241
+ `its bare font-family/font-size/… keys would phantom-link every slot's fonts. Pass { component, variants } to buildTypeGroupTokens/buildTypeGroupFontTokens.`,
242
+ );
243
+ }
244
+ }
245
+
246
+ /** The file under src/ that registers `id`, or null when none does. */
247
+ export function checkRegistration({ id, root }, record) {
248
+ // Registration: either a direct registerComponent({ id }) call or the id
249
+ // passed through bootLiveTokens({ components: [{ id }] }) — the standard
250
+ // scaffold boot. Accept both, somewhere under src/.
251
+ const srcFiles = findFilesRecursive(join(root, 'src'), ['.ts', '.js', '.svelte', '.mjs']);
252
+ const idLiteral = `id\\s*:\\s*['"]${id}['"]`;
253
+ const directPattern = new RegExp(`registerComponent\\s*\\(\\s*\\{[^}]*${idLiteral}`, 's');
254
+ const bootPattern = new RegExp(`bootLiveTokens\\s*\\([\\s\\S]*?components\\s*:\\s*\\[[\\s\\S]*?${idLiteral}`, 's');
255
+ let registrationFile = null;
256
+ for (const file of srcFiles) {
257
+ try {
258
+ const src = readFileSync(file, 'utf8');
259
+ if (directPattern.test(src) || bootPattern.test(src)) {
260
+ registrationFile = file;
261
+ break;
262
+ }
263
+ } catch {
264
+ // ignore unreadable files
265
+ }
266
+ }
267
+ // A first-party component is registered by membership in the package's own
268
+ // `builtInRegistry`, not by a `registerComponent` call, so look there too
269
+ // before calling it unregistered.
270
+ if (!registrationFile && isBuiltIn(id)) return null;
271
+
272
+ if (!registrationFile) {
273
+ record(
274
+ 'missing-registration',
275
+ `no registration for '${id}' under src/. Expected registerComponent({ id: '${id}', ... }) or bootLiveTokens({ components: [{ id: '${id}', ... }] })`,
276
+ );
277
+ }
278
+ return registrationFile;
279
+ }
280
+
281
+ /**
282
+ * `--<id>-*` tokens declared in the given blocks.
283
+ *
284
+ * Segments never end on a hyphen, so a trailing `-` cannot be mistaken for a
285
+ * token name.
286
+ */
287
+ function extractTokensForId(blocks, id) {
288
+ const tokens = new Set();
289
+ // Comments name tokens too (`the --card-hover-* tokens`); they are prose.
290
+ blocks = blocks.map((b) => b.replace(/\/\*[\s\S]*?\*\//g, ' '));
291
+ const re = new RegExp(`--${id}(?:-[a-z0-9]+)+`, 'g');
292
+ for (const block of blocks) {
293
+ for (const t of block.match(re) ?? []) tokens.add(t);
294
+ }
295
+ return [...tokens];
296
+ }
297
+
298
+ function detectStateAfterProperty(token, known) {
299
+ // e.g. --comp-part-surface-hover (wrong) vs --comp-part-hover-surface (right)
300
+ for (const state of STATE_TOKENS) {
301
+ if (token.endsWith(`-${state}`)) {
302
+ const head = token.slice(0, -(state.length + 1));
303
+ if (tokenSuffix(head, known)) return state;
304
+ }
305
+ }
306
+ return null;
307
+ }
308
+
309
+ // True if `source` calls `fnName(` at least once with a single argument (no
310
+ // top-level comma before the matching close paren). Brackets/braces are balanced
311
+ // so commas inside nested objects/arrays/calls don't count.
312
+ function hasBareCall(source, fnName) {
313
+ const needle = fnName + '(';
314
+ let idx = 0;
315
+ while ((idx = source.indexOf(needle, idx)) !== -1) {
316
+ let i = idx + needle.length;
317
+ let depth = 1;
318
+ let topComma = false;
319
+ for (; i < source.length && depth > 0; i++) {
320
+ const c = source[i];
321
+ if (c === '(' || c === '[' || c === '{') depth++;
322
+ else if (c === ')' || c === ']' || c === '}') depth--;
323
+ else if (c === ',' && depth === 1) topComma = true;
324
+ }
325
+ if (!topComma) return true;
326
+ idx = i;
327
+ }
328
+ return false;
329
+ }
330
+
331
+ function findFilesRecursive(dir, exts) {
332
+ if (!existsSync(dir)) return [];
333
+ const out = [];
334
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
335
+ if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue;
336
+ const full = join(dir, ent.name);
337
+ if (ent.isDirectory()) {
338
+ out.push(...findFilesRecursive(full, exts));
339
+ } else if (exts.includes(extname(ent.name))) {
340
+ out.push(full);
341
+ }
342
+ }
343
+ return out;
344
+ }
@@ -0,0 +1,313 @@
1
+ import { blankHtmlExpressions, styleBlockDeclarations, tagAttributes } from '../lib/pageSource.mjs';
2
+
3
+ export const pageRules = {
4
+ 'unknown-component': {
5
+ severity: 'error',
6
+ repair: 'authored',
7
+ guidance:
8
+ 'Read the live-tokens-pick-component skill for the shipped component that fits. When none fits, author one with live-tokens-create-component.',
9
+ },
10
+ 'unknown-prop': {
11
+ severity: 'error',
12
+ repair: 'choice',
13
+ guidance:
14
+ '`npx live-tokens components <id>` prints the declared props and their values, and `details.accepts` lists the props. Map the prop to one of them, or delete it.',
15
+ },
16
+ 'unknown-prop-value': {
17
+ severity: 'error',
18
+ repair: 'choice',
19
+ guidance: 'Use a value from the union the message lists, which `details.accepts` also holds.',
20
+ },
21
+ 'control-size': {
22
+ severity: 'warn',
23
+ repair: 'auto',
24
+ guidance:
25
+ "A run without --no-fix deletes each attribute it can bound whole. Delete the rest by hand. The shipped default is the page's size. When that default is wrong for the project, retune the component in /live-tokens/components.",
26
+ },
27
+ 'multiple-primary': {
28
+ severity: 'warn',
29
+ repair: 'authored',
30
+ guidance:
31
+ 'Keep the action that completes the main task primary. A Button with no variant counts as primary. Use secondary for supporting or related actions, and outline for unrelated or informational actions.',
32
+ },
33
+ 'danger-without-dialog': {
34
+ severity: 'warn',
35
+ repair: 'authored',
36
+ guidance:
37
+ "Open a Dialog from the danger Button or IconButton, and run the action from the Dialog's confirm. The rule fires once per page, when the page imports no Dialog. For other actions, assign emphasis by the action's relationship to the main task.",
38
+ },
39
+ 'native-control': {
40
+ severity: 'warn',
41
+ repair: 'authored',
42
+ guidance:
43
+ 'Replace the native element with the shipped component the message names: Button or IconButton, Input, or MenuSelect.',
44
+ },
45
+ 'property-override': {
46
+ severity: 'warn',
47
+ repair: 'auto',
48
+ guidance:
49
+ "A run without --no-fix deletes the declaration or the style: directive. A setProperty call is code around the value, so rewrite it by hand. Retune the component's semantic property for the whole project at /live-tokens/components.",
50
+ },
51
+ };
52
+
53
+ export const COMPONENT_IMPORT =
54
+ /(?:@motion-proto\/live-tokens\/components|[./][^'"]*\/system\/components)\/([A-Za-z0-9]+)\.svelte$/;
55
+
56
+ /** `unknown-component` for one import statement. A catalogue component's local
57
+ * name joins `imports`, which the tag rules read. */
58
+ export function resolveComponentImport({ index, clause, specifier }, vocab, imports, add) {
59
+ const comp = specifier.match(COMPONENT_IMPORT);
60
+ if (!comp) return;
61
+ const entry = vocab.components.get(comp[1].toLowerCase());
62
+ if (!entry) {
63
+ add(
64
+ 'unknown-component',
65
+ index,
66
+ `'${comp[1]}' is not in the component catalogue; author it with live-tokens-create-component or pick a shipped one`,
67
+ );
68
+ return;
69
+ }
70
+ const local = clause?.trim().match(/^(\w+)$/)?.[1];
71
+ if (local) imports.set(local, entry);
72
+ }
73
+
74
+ export function checkComponentTags(code, imports, add) {
75
+ checkComponentUsage(code, imports, add);
76
+ checkPrimaryActions(code, imports, add);
77
+ checkDestructiveActions(code, imports, add);
78
+ checkNativeControls(code, add);
79
+ }
80
+
81
+ // The shipped component that owns each native control's paint.
82
+ const NATIVE_CONTROLS = {
83
+ button: 'Button or IconButton',
84
+ input: 'Input',
85
+ select: 'MenuSelect',
86
+ textarea: 'Input',
87
+ };
88
+
89
+ /** A raw `<button>`, `<input>`, `<select>`, or `<textarea>` where a shipped
90
+ component belongs. `<input type="hidden">` paints nothing and is exempt.
91
+ A tag inside `<script>` is markup the page assembles into a string, not
92
+ markup it renders, so the script block is blanked the same way
93
+ `codeRegion` blanks `<style>`, keeping offsets aligned with `code`. */
94
+ function checkNativeControls(code, add) {
95
+ const scan = blankHtmlExpressions(code).replace(/<script[^>]*>[\s\S]*?<\/script>/g, (m) => ' '.repeat(m.length));
96
+ for (const tag of Object.keys(NATIVE_CONTROLS)) {
97
+ for (const m of scan.matchAll(new RegExp(`<${tag}(?=[\\s/>])`, 'g'))) {
98
+ const parsed = tagAttributes(scan, m.index);
99
+ if (tag === 'input' && parsed?.attrs.some((a) => a.name.toLowerCase() === 'type' && a.value?.toLowerCase() === 'hidden')) {
100
+ continue;
101
+ }
102
+ add(
103
+ 'native-control',
104
+ m.index,
105
+ `<${tag}> is a native control; use ${NATIVE_CONTROLS[tag]} so it paints from the theme.`,
106
+ );
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Props a page passes that the component does not declare, values outside a
113
+ * prop's union, and a shipped component the page sizes itself.
114
+ */
115
+ function checkComponentUsage(code, imports, add) {
116
+ for (const [local, entry] of imports) {
117
+ const props = entry.props;
118
+ if (!props) continue;
119
+ const re = new RegExp(`<${local}(?=[\\s/>])`, 'g');
120
+ for (const m of code.matchAll(re)) {
121
+ const tag = tagAttributes(code, m.index);
122
+ if (!tag) continue;
123
+ for (const { name, value, index, end } of tag.attrs) {
124
+ if (name.includes(':') || name.startsWith('@') || name === 'children') continue;
125
+ if (!props.props.has(name)) {
126
+ add('unknown-prop', index, `${entry.name} has no prop '${name}'; it accepts ${[...props.props].join(', ')}`, {
127
+ details: { accepts: [...props.props] },
128
+ });
129
+ continue;
130
+ }
131
+ const allowed = props.enums.get(name);
132
+ if (allowed && value !== null && !allowed.has(value)) {
133
+ add('unknown-prop-value', index, `${entry.name} ${name}="${value}" is not one of ${[...allowed].join(', ')}`, {
134
+ details: { accepts: [...allowed] },
135
+ });
136
+ }
137
+ if (name === 'size' && entry.origin === 'shipped') {
138
+ // An attribute no fixer can delete whole has nothing auto to
139
+ // apply, so it lowers to a choice.
140
+ const patch = attributeDeletion(code, index, end);
141
+ add(
142
+ 'control-size',
143
+ index,
144
+ `${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.`,
145
+ { details: { site: 'attribute', ...(patch ? { patch } : {}) }, ...(patch ? {} : { repair: 'choice' }) },
146
+ );
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ /** The exact text a fixer deletes for one attribute: its own span, plus one
154
+ * leading space or tab if present, so removal doesn't leave a double space or
155
+ * a lone trailing one before the tag's `>`. A span that crosses a newline
156
+ * yields no patch: the patch has to stay inside the line the finding names,
157
+ * and `applyFixes` applies a file's patches in line order on the assumption
158
+ * that none of them moves a later line. A span stopping at its own `=` is an
159
+ * attribute whose value starts on the next line, so deleting the span would
160
+ * leave the value behind as orphan markup. */
161
+ function attributeDeletion(code, index, end) {
162
+ const start = /[^\S\n]/.test(code[index - 1] ?? '') ? index - 1 : index;
163
+ const from = code.slice(start, end);
164
+ if (from.includes('\n') || from.endsWith('=')) return null;
165
+ return { from, to: '' };
166
+ }
167
+
168
+ /**
169
+ * One finding when a page holds more than one primary Button, at the second of
170
+ * them. Emphasis is what the variant carries, so a second primary leaves the
171
+ * page with no single most important action. A Button with no variant is
172
+ * primary, the component's default.
173
+ */
174
+ function checkPrimaryActions(code, imports, add) {
175
+ const primaries = [];
176
+ for (const [local, entry] of imports) {
177
+ if (entry.id !== 'button') continue;
178
+ for (const m of code.matchAll(new RegExp(`<${local}(?=[\\s/>])`, 'g'))) {
179
+ const tag = tagAttributes(code, m.index);
180
+ const variant = tag?.attrs.find((a) => a.name === 'variant');
181
+ if (tag && (variant === undefined || variant.value === 'primary')) primaries.push(m.index);
182
+ }
183
+ }
184
+ if (primaries.length < 2) return;
185
+ primaries.sort((a, b) => a - b);
186
+ add(
187
+ 'multiple-primary',
188
+ primaries[1],
189
+ `${primaries.length} primary Buttons in this page. Keep the most important action primary and make the other ${primaries.length - 1} secondary.`,
190
+ );
191
+ }
192
+
193
+ /**
194
+ * One finding when a page holds a danger Button and imports no Dialog. A
195
+ * danger action destroys saved work, and the page has nothing to confirm it.
196
+ */
197
+ function checkDestructiveActions(code, imports, add) {
198
+ if ([...imports.values()].some((entry) => entry.id === 'dialog')) return;
199
+ for (const [local, entry] of imports) {
200
+ if (entry.id !== 'button' && entry.id !== 'iconbutton') continue;
201
+ for (const m of code.matchAll(new RegExp(`<${local}(?=[\\s/>])`, 'g'))) {
202
+ const tag = tagAttributes(code, m.index);
203
+ if (!tag?.attrs.some((a) => a.name === 'variant' && a.value === 'danger')) continue;
204
+ add(
205
+ 'danger-without-dialog',
206
+ m.index,
207
+ `${entry.name} variant="danger" with no Dialog in this page. A destructive action confirms in a Dialog before it runs.`,
208
+ );
209
+ return;
210
+ }
211
+ }
212
+ }
213
+
214
+ // Which component's :global(:root) block declared each component token,
215
+ // cached per vocabulary since checkFile runs once per page.
216
+ const tokenOwnersCache = new WeakMap();
217
+ function componentTokenOwners(vocab) {
218
+ let owners = tokenOwnersCache.get(vocab);
219
+ if (owners) return owners;
220
+ owners = new Map();
221
+ for (const comp of vocab.components.values()) {
222
+ for (const name of comp.tokens.keys()) {
223
+ if (!owners.has(name)) owners.set(name, comp.name);
224
+ }
225
+ }
226
+ tokenOwnersCache.set(vocab, owners);
227
+ return owners;
228
+ }
229
+
230
+ /** A `--name: value;` CSS declaration, deleted whole. The match is anchored at
231
+ * `index`, which sits either on the name or on the boundary character before
232
+ * it (a style-block match keeps that char as its own delimiter), and the
233
+ * declaration stops at `}` and at a newline as well as at `;`, the gaps
234
+ * around its colon included. Unanchored and unbounded, the search ran past
235
+ * its own rule and deleted the next same-named declaration, or the text of a
236
+ * string literal further down the file. A declaration no `;` terminates
237
+ * inside its own block yields no patch, since deleting it whole would need
238
+ * one. A declaration wrapped onto a second line yields none either: the
239
+ * patch has to stay inside the line the finding names, since `applyFixes`
240
+ * assumes no patch moves a later line. The match runs on `clean`, the
241
+ * neutralised text from `index` on, and the span it bounds has to read the
242
+ * same in the file: matched on the raw text, a `;` inside a comment in the
243
+ * value ended the match there, and the deletion left the comment's tail
244
+ * behind.
245
+ */
246
+ function declarationDeletion(text, name, index, clean) {
247
+ const m = new RegExp(`^[;{]?\\s*(${name}[^\\S\\n]*:[^\\S\\n]*[^;}\\n]+;)`).exec(clean);
248
+ if (!m) return null;
249
+ const start = index + m[0].length - m[1].length;
250
+ const from = text.slice(start, start + m[1].length);
251
+ return from === m[1] ? { from, to: '' } : null;
252
+ }
253
+
254
+ /** A `style:--name="value"` directive, deleted whole. `overrideAt` always
255
+ * records `index` at the start of `style:` itself, so the match is anchored
256
+ * there and its length measures this directive. Unanchored, a directive whose
257
+ * value is an expression (`style:--name={r}`) matched a quoted namesake
258
+ * further down the file and cut that match's length out of this tag's markup.
259
+ * An expression value has no patch: the value is code, not text to delete.
260
+ */
261
+ function directiveDeletion(text, name, index) {
262
+ const m = new RegExp(`^style:${name}=(["'])[^"']*\\1`).exec(text.slice(index));
263
+ if (!m) return null;
264
+ const start = /[^\S\n]/.test(text[index - 1] ?? '') ? index - 1 : index;
265
+ const from = text.slice(start, index + m[0].length);
266
+ // A quoted value may run over a line, and deleting it would move every later
267
+ // finding's line out from under `applyFixes`.
268
+ if (from.includes('\n')) return null;
269
+ return { from, to: '' };
270
+ }
271
+
272
+ export function checkPropertyOverrides({ text, regions, inlineRegions, vocab }, add) {
273
+ // property-override needs a narrower set than `pageDeclaredNames`: its
274
+ // bare-quote alternative exists only to suppress unknown-token on a name any
275
+ // quoted string mentions, so it also matches a read like
276
+ // getPropertyValue("--x"). A real declaration is a style-block rule, an
277
+ // inline `style="--x: ..."` attribute, a `style:--x=` directive, or a
278
+ // setProperty('--x', ...) call.
279
+ const overrideSites = new Map();
280
+ const overrideAt = (name, index, site, clean) => {
281
+ if (!overrideSites.has(name) || index < overrideSites.get(name).index) overrideSites.set(name, { index, site, clean });
282
+ };
283
+ styleBlockDeclarations(regions, overrideAt);
284
+ styleBlockDeclarations(inlineRegions, overrideAt);
285
+ for (const m of text.matchAll(/(?:style:|setProperty\(\s*['"`])(--[a-z0-9-]+)/g)) {
286
+ overrideAt(m[1], m.index, m[0].startsWith('style:') ? 'directive' : 'script');
287
+ }
288
+
289
+ // A name the vocabulary already ties to a component is that component's
290
+ // token, so declaring it here is one instance overriding the whole
291
+ // project's retuning surface at /live-tokens/components.
292
+ for (const [name, { index, site, clean }] of overrideSites) {
293
+ if (!vocab.componentTokens.has(name)) continue;
294
+ const owner = componentTokenOwners(vocab).get(name) ?? 'a shipped component';
295
+ const patch =
296
+ site === 'declaration'
297
+ ? declarationDeletion(text, name, index, clean)
298
+ : site === 'directive'
299
+ ? directiveDeletion(text, name, index)
300
+ : null;
301
+ // A setProperty call is code around the value, not a value to delete, so
302
+ // it is always authored. Any other site with no patch (an inline
303
+ // attribute, or a declaration a fixer could not bound safely) has
304
+ // nothing auto to apply, so it is a choice rather than the rule's ceiling.
305
+ const repair = site === 'script' ? 'authored' : patch ? undefined : 'choice';
306
+ add(
307
+ 'property-override',
308
+ index,
309
+ `${name} overrides ${owner}'s token here instead of the whole project; retune it at /live-tokens/components.`,
310
+ { details: { site, ...(patch ? { patch } : {}) }, ...(repair ? { repair } : {}) },
311
+ );
312
+ }
313
+ }