@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
@@ -14,378 +14,117 @@
14
14
  // token, so the component repaints when the theme changes. A value with no
15
15
  // token behind it must be a declared intrinsic (a structural keyword the
16
16
  // editor exports in `intrinsics`), never a literal.
17
+ // - every property the runtime declares is read by the runtime's own CSS
18
+ // - the runtime exports a `catalogue` the CLI and the registry both read
17
19
  //
18
20
  // Returns { errors, warnings, findings }. `errors`/`warnings` are the message
19
- // strings; `findings` carries the same items with a stable `rule` id and line
20
- // number for --json consumers.
21
-
22
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
23
- import { extname, join, relative } from 'node:path';
24
- import { hasColorLiteral, hasDimensionLiteral, stripVarFallbacks } from './lib/cssValues.mjs';
25
- import { lineOf } from './lib/findings.mjs';
26
- import { PKG_ROOT, builtInIds, declaredCustomProperties, extractGlobalRootBlocks, isContractToken, loadVocabulary } from './lib/tokenVocabulary.mjs';
27
-
28
- export const COMPONENT_RULES = {
29
- 'invalid-id': 'error',
30
- 'missing-file': 'error',
31
- 'missing-root-block': 'error',
32
- 'no-tokens': 'error',
33
- 'state-after-property': 'error',
34
- 'disabled-is-terminal': 'error',
35
- 'unknown-suffix': 'error',
36
- 'phantom-editor-token': 'error',
37
- 'color-literal': 'error',
38
- 'missing-component-const': 'error',
39
- 'missing-all-tokens': 'error',
40
- 'deep-import': 'error',
41
- 'missing-registration': 'error',
42
- 'unknown-token-ref': 'error',
43
- 'default-not-token': 'error',
44
- 'phantom-link': 'warn',
45
- 'dimension-literal': 'warn',
46
- // `--tests` (bin/contractRunner.mjs). Fixed by design decision 8 so
47
- // `fix-findings` can map them; every one is an error, including the setup
48
- // rules, which `--tests` treats as never-silenceable (see cli.mjs).
49
- 'contract-registry': 'error',
50
- 'contract-render': 'error',
51
- 'contract-alias': 'error',
52
- 'contract-persist': 'error',
53
- 'contract-theme': 'error',
54
- 'contract-states': 'error',
55
- 'contract-interaction': 'error',
56
- 'contract-listed': 'error',
57
- 'contract-sketch': 'error',
58
- 'contract-missing': 'error',
59
- 'tests-not-installed': 'error',
60
- 'tests-setup': 'error',
61
- 'tests-incomplete': 'error',
62
- };
21
+ // strings; `findings` carries the same items with a stable `rule` id, a line
22
+ // number, and, where a repair needs more than the message, `details`.
23
+
24
+ import { existsSync, readFileSync } from 'node:fs';
25
+ import { join, relative } from 'node:path';
26
+ import { declaredTokens, intrinsicMatchers } from './lib/componentSource.mjs';
27
+ import { assembleRules, lineOf } from './lib/findings.mjs';
28
+ import {
29
+ EDITOR_DIRS,
30
+ PKG_ROOT,
31
+ builtInIds,
32
+ componentInventory,
33
+ extractGlobalRootBlocks,
34
+ loadVocabulary,
35
+ } from './lib/tokenVocabulary.mjs';
36
+ import * as componentStructure from './rules/componentStructure.mjs';
37
+ import * as importsAndRoutes from './rules/importsAndRoutes.mjs';
38
+ import * as testRuns from './rules/testRuns.mjs';
39
+ import * as tokenRules from './rules/tokens.mjs';
40
+
41
+ export { unreadTokens } from './lib/componentSource.mjs';
63
42
 
64
43
  /**
65
- * Where a rule is fixed, as a stable slug carried on the finding. The skills
66
- * resolve the slug to one of their own headings, so adding a rule here costs
67
- * no skill edit as long as it reuses a slug. Two skills used to enumerate every
68
- * rule id in their own tables, with nothing holding the two in agreement or
69
- * checking either against this list.
44
+ * Every rule, with its default severity, its repair, and the guidance every
45
+ * finding of it carries.
46
+ *
47
+ * `repair` is the ceiling: `auto` when code can rewrite the site, `choice` when
48
+ * code can name the candidates but not pick one, `authored` when a person
49
+ * writes the repair. A finding may lower its rule's `repair`; none raises it.
70
50
  */
71
- export const COMPONENT_RULE_FIX = {
72
- 'unknown-suffix': 'property-name',
73
- 'state-after-property': 'property-name',
74
- 'disabled-is-terminal': 'property-name',
75
- 'default-not-token': 'property-token',
76
- 'color-literal': 'property-token',
77
- 'dimension-literal': 'property-token',
78
- 'unknown-token-ref': 'property-token',
79
- 'contract-theme': 'property-token',
80
- 'invalid-id': 'runtime',
81
- 'missing-file': 'runtime',
82
- 'missing-root-block': 'runtime',
83
- 'no-tokens': 'runtime',
84
- 'contract-persist': 'runtime-defaults',
85
- 'missing-component-const': 'editor',
86
- 'missing-all-tokens': 'editor',
87
- 'phantom-editor-token': 'editor',
88
- 'phantom-link': 'editor',
89
- 'deep-import': 'editor',
90
- 'contract-render': 'editor',
91
- 'contract-alias': 'editor',
92
- 'contract-states': 'editor',
93
- 'contract-interaction': 'editor',
94
- 'missing-registration': 'registration',
95
- 'contract-registry': 'registration',
96
- 'contract-listed': 'registration',
97
- 'contract-sketch': 'sketch',
98
- 'contract-missing': 'coverage',
99
- 'tests-not-installed': 'tooling',
100
- 'tests-setup': 'tooling',
101
- 'tests-incomplete': 'coverage',
102
- };
103
-
104
- // Shipped components keep their editor beside the other editors; a
105
- // consumer-authored one sits next to its runtime. Probe both.
106
- const EDITOR_DIRS = ['src/system/components', 'src/editor/component-editor'];
107
-
108
- // Property suffixes come from the editor's own kind table, so the checker and
109
- // the picker can never disagree about what a name means. Read as text rather
110
- // than imported: this module must load without the compiled engine (CI runs the
111
- // suite before the plugin is built).
112
- const ALIAS_KINDS = 'src/editor/core/components/aliasKinds.ts';
113
-
114
- function readKnownSuffixes(root) {
115
- for (const base of [root, PKG_ROOT]) {
116
- const path = join(base, ALIAS_KINDS);
117
- if (!existsSync(path)) continue;
118
- const src = readFileSync(path, 'utf8');
119
- const block = src.match(/KIND_RULES[^=]*=\s*\[([\s\S]*?)\n\];/);
120
- if (!block) continue;
121
- const out = new Set();
122
- for (const m of block[1].matchAll(/suffix:\s*\[([\s\S]*?)\]/g)) {
123
- for (const n of m[1].matchAll(/'-([a-z0-9-]+)'/g)) out.add(n[1]);
124
- }
125
- if (out.size > 0) return [...out];
126
- }
127
- return [];
128
- }
129
-
130
- /** True when `id` is one of the package's own components. */
131
- function isBuiltIn(id) {
132
- return builtInIds(process.cwd(), PKG_ROOT).has(id);
133
- }
134
-
135
- // Per-side padding names (`--card-body-padding-top`) are written by the padding
136
- // selector, never declared by hand, and belong with their parent.
137
- const SIDE_SUFFIXES = ['-top', '-right', '-bottom', '-left'];
138
-
139
- // State tokens that must come *before* the property, never after.
140
- const STATE_TOKENS = ['hover', 'disabled', 'selected', 'focus', 'active', 'focused'];
141
-
142
- // Disabled is terminal: a disabled component cannot be hovered, focused, or
143
- // selected, so a token naming both describes a state that never paints.
144
- const TERMINAL_CONFLICTS = ['hover', 'focus', 'focused', 'selected', 'on', 'active', 'checked'];
145
-
146
- // Deep imports into the package internals are not a supported API.
147
- const DEEP_IMPORT_PATTERNS = [
148
- /^@motion-proto\/live-tokens\/src\//,
149
- /node_modules\/@motion-proto\/live-tokens/,
150
- ];
51
+ export const COMPONENT_RULES = assembleRules(
52
+ [
53
+ 'invalid-id',
54
+ 'missing-file',
55
+ 'missing-root-block',
56
+ 'no-tokens',
57
+ 'missing-description',
58
+ 'unread-token',
59
+ 'state-after-property',
60
+ 'disabled-is-terminal',
61
+ 'unknown-suffix',
62
+ 'phantom-editor-token',
63
+ 'color-literal',
64
+ 'missing-component-const',
65
+ 'missing-all-tokens',
66
+ 'deep-import',
67
+ 'missing-registration',
68
+ 'unknown-token-ref',
69
+ 'default-not-token',
70
+ 'phantom-link',
71
+ 'dimension-literal',
72
+ 'config-token',
73
+ 'contract-registry',
74
+ 'contract-behavior',
75
+ 'contract-render',
76
+ 'contract-alias',
77
+ 'contract-persist',
78
+ 'contract-theme',
79
+ 'contract-states',
80
+ 'contract-interaction',
81
+ 'contract-listed',
82
+ 'contract-sketch',
83
+ 'contract-missing',
84
+ 'tests-not-installed',
85
+ 'tests-setup',
86
+ 'tests-incomplete',
87
+ ],
88
+ componentStructure.componentRules,
89
+ tokenRules.componentRules,
90
+ importsAndRoutes.componentRules,
91
+ testRuns.componentRules,
92
+ );
151
93
 
152
94
  function capitalize(id) {
153
95
  return id.charAt(0).toUpperCase() + id.slice(1);
154
96
  }
155
97
 
98
+ const pick = (entry) => ({ Id: entry.Id, runtimePath: entry.runtimePath, editorPath: entry.editorPath });
99
+
156
100
  /**
157
- * Where `id`'s runtime and editor files live, resolved the same way
158
- * `checkComponent` does: the real on-disk filename rather than a capitalised
159
- * guess, since `cornerbadge` ships as `CornerBadge.svelte`. Exported so a
160
- * caller outside the lint (the `--tests` runner, attributing a contract
161
- * finding to a consumer artifact) doesn't re-derive it differently.
101
+ * Where `id`'s runtime and editor files live, resolved from `root`'s own
102
+ * inventory first, the real on-disk filename rather than a capitalised guess,
103
+ * since `cornerbadge` ships as `CornerBadge.svelte`. Exported so a caller
104
+ * outside the lint (the `--tests` runner, attributing a contract finding to a
105
+ * consumer artifact) doesn't re-derive it differently.
162
106
  */
163
107
  export function resolveComponentPaths(id, root = process.cwd()) {
108
+ const local = componentInventory(root).get(id);
109
+ if (local?.runtimeExists) return pick(local);
110
+
164
111
  // A consumer names a shipped id and owns no copy of its files. Falling back
165
112
  // to the package keeps the lint reading the same source the contract run
166
113
  // resolves, instead of reporting the component's own files missing. In this
167
- // repo the consumer paths always exist, so the fallback never fires here.
168
- const roots = builtInIds(root).has(id) ? [root, PKG_ROOT] : [root];
169
- for (const base of roots) {
170
- const dir = join(base, 'src/system/components');
171
- const Id =
172
- (existsSync(dir) ? readdirSync(dir) : [])
173
- .find((f) => f.toLowerCase() === `${id}.svelte`)
174
- ?.replace('.svelte', '') ?? capitalize(id);
175
- const runtimePath = join(dir, `${Id}.svelte`);
176
- const editorPath =
177
- EDITOR_DIRS.map((d) => join(base, d, `${Id}Editor.svelte`)).find(existsSync) ??
178
- join(base, EDITOR_DIRS[0], `${Id}Editor.svelte`);
179
- if (base === roots.at(-1) || (existsSync(runtimePath) && existsSync(editorPath))) {
180
- return { Id, runtimePath, editorPath };
181
- }
182
- }
183
- }
184
-
185
- function extractImports(source) {
186
- const out = [];
187
- const re = /import\s+(?:[^'"]*\s+from\s+)?['"]([^'"]+)['"]/g;
188
- let m;
189
- while ((m = re.exec(source)) !== null) {
190
- out.push(m[1]);
191
- }
192
- return out;
193
- }
194
-
195
- /**
196
- * `--<id>-*` tokens declared in the given blocks.
197
- *
198
- * The prefix may also be the hyphenated word form of the id: CornerBadge is
199
- * registered as `cornerbadge` but names its tokens `--corner-badge-*`, and the
200
- * whole system (config, theme, editor) follows that. Segments never end on a
201
- * hyphen, so a trailing `-` cannot be mistaken for a token name.
202
- */
203
- function extractTokensForId(blocks, id, kebab) {
204
- const tokens = new Set();
205
- // Comments name tokens too (`the --card-hover-* tokens`); they are prose.
206
- blocks = blocks.map((b) => b.replace(/\/\*[\s\S]*?\*\//g, ' '));
207
- const prefixes = kebab && kebab !== id ? [id, kebab] : [id];
208
- for (const prefix of prefixes) {
209
- const re = new RegExp(`--${prefix}(?:-[a-z0-9]+)+`, 'g');
210
- for (const block of blocks) {
211
- for (const t of block.match(re) ?? []) tokens.add(t);
212
- }
213
- }
214
- return [...tokens];
215
- }
216
-
217
- /** `CornerBadge` -> `corner-badge`; the other accepted token prefix. */
218
- function kebabOf(name) {
219
- return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
220
- }
221
-
222
- function tokenSuffix(token, known) {
223
- const bare = SIDE_SUFFIXES.find((x) => token.endsWith(x)) ? token.slice(0, token.lastIndexOf('-')) : token;
224
- for (const suffix of known) {
225
- if (bare.endsWith(`-${suffix}`)) return suffix;
226
- }
227
- return null;
228
- }
229
-
230
- function detectStateAfterProperty(token, known) {
231
- // e.g. --comp-part-surface-hover (wrong) vs --comp-part-hover-surface (right)
232
- for (const state of STATE_TOKENS) {
233
- if (token.endsWith(`-${state}`)) {
234
- const head = token.slice(0, -(state.length + 1));
235
- if (tokenSuffix(head, known)) return state;
236
- }
237
- }
238
- return null;
239
- }
240
-
241
- // True if `source` calls `fnName(` at least once with a single argument (no
242
- // top-level comma before the matching close paren). Brackets/braces are balanced
243
- // so commas inside nested objects/arrays/calls don't count.
244
- function hasBareCall(source, fnName) {
245
- const needle = fnName + '(';
246
- let idx = 0;
247
- while ((idx = source.indexOf(needle, idx)) !== -1) {
248
- let i = idx + needle.length;
249
- let depth = 1;
250
- let topComma = false;
251
- for (; i < source.length && depth > 0; i++) {
252
- const c = source[i];
253
- if (c === '(' || c === '[' || c === '{') depth++;
254
- else if (c === ')' || c === ']' || c === '}') depth--;
255
- else if (c === ',' && depth === 1) topComma = true;
256
- }
257
- if (!topComma) return true;
258
- idx = i;
259
- }
260
- return false;
261
- }
262
-
263
- function findFilesRecursive(dir, exts) {
264
- if (!existsSync(dir)) return [];
265
- const out = [];
266
- for (const ent of readdirSync(dir, { withFileTypes: true })) {
267
- if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue;
268
- const full = join(dir, ent.name);
269
- if (ent.isDirectory()) {
270
- out.push(...findFilesRecursive(full, exts));
271
- } else if (exts.includes(extname(ent.name))) {
272
- out.push(full);
273
- }
274
- }
275
- return out;
276
- }
277
-
278
- /**
279
- * Every token the editor names in a row: `variable: '--x'`, the type-group
280
- * `colorVariable` / `familyVariable` / ... keys, and the arrow form intrinsics
281
- * use. A `${...}` hole stands for a variant segment. Per-side padding names are
282
- * written by the padding selector rather than declared, so they resolve to
283
- * their parent.
284
- */
285
- function editorTokenRefs(editor) {
286
- const literals = new Set();
287
- const patterns = [];
288
- for (const m of editor.matchAll(/\b(?:variable|[a-zA-Z]+Variable)\s*:\s*(?:\([^)]*\)\s*=>\s*)?[`'"](--(?:\$\{[^}]*\}|[^`'"])+)[`'"]/g)) {
289
- const name = stripSide(m[1]);
290
- if (name.includes('${')) {
291
- patterns.push([name, new RegExp(`^${name.replace(/[.*+?^()|[\]\\]/g, '\\$&').replace(/\$\{[^}]*\}/g, '[a-z0-9-]+')}$`)]);
292
- } else {
293
- literals.add(name);
294
- }
295
- }
296
- return { literals, patterns };
297
- }
298
-
299
- function stripSide(token) {
300
- const side = SIDE_SUFFIXES.find((x) => token.endsWith(x));
301
- return side ? token.slice(0, -side.length) : token;
302
- }
303
-
304
- /**
305
- * Token patterns the editor declares in `intrinsics` — the only tokens allowed a
306
- * bare keyword instead of a theme token.
307
- *
308
- * Matched on each spec's `variable`, not its `key`: the two need not agree
309
- * (Image's `zoom` key declares `--image-zoom-enabled`), and `variable` is what
310
- * actually names the token. A `${...}` hole stands for a variant segment.
311
- */
312
- function intrinsicMatchers(editor) {
313
- const block = editor.match(/export\s+const\s+intrinsics[^=]*=\s*\[([\s\S]*?)\];/);
314
- if (!block) return [];
315
- const out = [];
316
- for (const m of block[1].matchAll(/\bvariable\s*:[^`'"]*[`'"]([^`'"]+)[`'"]/g)) {
317
- const pattern = m[1]
318
- .replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === '$' ? '$' : `\\${c}`))
319
- .replace(/\$\\\{[^}]*\\\}/g, '[a-z0-9-]+')
320
- .replace(/\$\{[^}]*\}/g, '[a-z0-9-]+');
321
- out.push(new RegExp(`^${pattern}$`));
322
- }
323
- return out;
324
- }
325
-
326
- /**
327
- * The semantic half of the contract: a component token is a *property name*, and
328
- * its default is the theme token that property reads. So every default must
329
- * resolve to a real token — otherwise the component stops repainting when the
330
- * theme changes, which is the whole point of declaring it.
331
- */
332
- function checkDefaultsAreSemantic({ blocks, runtime, editor, root, runtimePath, record, vocabulary }) {
333
- const vocab = vocabulary ?? loadVocabulary({ root });
334
- const matchers = intrinsicMatchers(editor);
335
- const rel = relative(root, runtimePath);
336
-
337
- const own = new Set();
338
- for (const block of blocks) {
339
- for (const m of block.matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/gm)) own.add(m[1]);
114
+ // repo the consumer paths always exist, so this fallback never fires here.
115
+ if (root !== PKG_ROOT && builtInIds(root, PKG_ROOT).has(id)) {
116
+ const shipped = componentInventory(PKG_ROOT).get(id);
117
+ if (shipped?.runtimeExists) return pick(shipped);
340
118
  }
341
119
 
342
- for (const block of blocks) {
343
- for (const m of block.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
344
- const [decl, name, raw] = m;
345
- const value = raw.trim();
346
- const at = runtime.indexOf(decl);
120
+ // A registered id with no runtime anywhere: report where it would live.
121
+ if (local) return pick(local);
347
122
 
348
- const painted = stripVarFallbacks(value);
349
- const refs = [...painted.matchAll(/var\(\s*(--[a-z0-9-]+)/g)].map((x) => x[1]);
350
- for (const ref of refs) {
351
- if (own.has(ref) || vocab.knows(ref)) continue;
352
- record(
353
- 'unknown-token-ref',
354
- STATE_TOKENS.includes(ref.replace(/^--/, '').split('-')[0])
355
- ? `${rel}: ${name} reads ${ref}, but a state is a segment of a property name. Read the token the state should paint`
356
- : isContractToken(ref)
357
- ? `${rel}: ${name} reads ${ref}, which has the shape of a design token but no longer exists. Check tokens.css for a rename`
358
- : `${rel}: ${name} reads ${ref}, which is not a design token or a semantic property`,
359
- at,
360
- );
361
- }
362
-
363
- if (hasColorLiteral(painted)) {
364
- record(
365
- 'color-literal',
366
- `${rel}: ${name}: ${value} is a colour literal; defaults must reference design tokens (e.g. var(--surface-primary))`,
367
- at,
368
- );
369
- continue;
370
- }
371
-
372
- if (refs.length === 0 && !matchers.some((re) => re.test(name))) {
373
- record(
374
- 'default-not-token',
375
- `${rel}: ${name}: ${value} has no design token behind it. Back it with a token, or declare it in the editor's \`intrinsics\` when it is a structural keyword`,
376
- at,
377
- );
378
- }
379
-
380
- if (hasDimensionLiteral(painted)) {
381
- record(
382
- 'dimension-literal',
383
- `${rel}: ${name}: ${value} pins a raw dimension; use a --space-*, --radius-*, or --border-width-* token`,
384
- at,
385
- );
386
- }
387
- }
388
- }
123
+ const Id = capitalize(id);
124
+ const editorPath =
125
+ EDITOR_DIRS.map((d) => join(root, d, `${Id}Editor.svelte`)).find(existsSync) ??
126
+ join(root, EDITOR_DIRS[0], `${Id}Editor.svelte`);
127
+ return { Id, runtimePath: join(root, 'src/system/components', `${Id}.svelte`), editorPath };
389
128
  }
390
129
 
391
130
  export function checkComponent(id, root = process.cwd(), { vocabulary } = {}) {
@@ -395,227 +134,82 @@ export function checkComponent(id, root = process.cwd(), { vocabulary } = {}) {
395
134
  let file = '';
396
135
  let source = '';
397
136
 
398
- const record = (rule, message, index = -1) => {
137
+ const record = (rule, message, index = -1, extra = {}) => {
399
138
  findings.push({
400
139
  rule,
401
140
  file,
402
141
  line: index >= 0 && source ? lineOf(source, index) : 1,
403
142
  message,
143
+ ...extra,
404
144
  });
405
- (COMPONENT_RULES[rule] === 'warn' ? warnings : errors).push(message);
145
+ (COMPONENT_RULES[rule]?.severity === 'warn' ? warnings : errors).push(message);
146
+ };
147
+ // For a finding about a file other than the runtime (config-token against
148
+ // the saved alias config, deep-import against the editor or the registration
149
+ // file): the line is already resolved against that file's own text, never
150
+ // `source` (the runtime).
151
+ const recordAt = (rule, message, atFile, line = 1, extra = {}) => {
152
+ findings.push({ rule, file: atFile, line, message, ...extra });
153
+ (COMPONENT_RULES[rule]?.severity === 'warn' ? warnings : errors).push(message);
406
154
  };
407
155
  const done = () => ({ errors, warnings, findings });
408
156
 
409
- if (!/^[a-z][a-z0-9]*$/.test(id)) {
410
- record('invalid-id', `id "${id}" is invalid; must be lowercase letters/digits, no dashes`);
411
- return done();
412
- }
157
+ if (!componentStructure.checkId(id, record)) return done();
413
158
 
414
159
  const { Id, runtimePath, editorPath } = resolveComponentPaths(id, root);
415
-
416
160
  file = relative(root, runtimePath);
417
- if (!existsSync(runtimePath)) {
418
- record('missing-file', `runtime missing: ${relative(root, runtimePath)}`);
419
- }
420
- if (!existsSync(editorPath)) {
421
- record('missing-file', `editor missing: ${relative(root, editorPath)}`);
422
- }
423
- if (errors.length) return done();
161
+ const { editorMissing, runtimeMissing } = componentStructure.checkFiles({ root, runtimePath, editorPath }, record);
162
+ if (runtimeMissing) return done();
163
+ // A missing editor still gets its own finding, but the rules that need only
164
+ // the runtime (token shape, semantics, config, registration) still run —
165
+ // an editor-less component is exactly the case those rules exist to catch.
424
166
 
425
167
  const runtime = readFileSync(runtimePath, 'utf8');
426
- const editor = readFileSync(editorPath, 'utf8');
168
+ const editor = editorMissing ? '' : readFileSync(editorPath, 'utf8');
427
169
  source = runtime;
428
-
429
- // Runtime: :global(:root) block present.
430
170
  const blocks = extractGlobalRootBlocks(runtime);
431
- if (blocks.length === 0) {
432
- record('missing-root-block', `${relative(root, runtimePath)}: missing :global(:root) declaration block`);
433
- }
434
-
435
- // Runtime: at least one --<id>-* token.
436
- const tokens = extractTokensForId(blocks, id, kebabOf(Id));
437
- if (blocks.length > 0 && tokens.length === 0) {
438
- record('no-tokens', `${relative(root, runtimePath)}: no --${id}-* tokens declared in :global(:root)`);
439
- }
440
-
441
- // A token the editor declares as an intrinsic carries a structural keyword,
442
- // not a themeable value, so a property-suffix rule is the wrong test for it.
443
- const intrinsic = intrinsicMatchers(editor);
444
- const known = readKnownSuffixes(root);
445
-
446
- // Runtime: state-after-property anti-pattern. Report this first; if it fires
447
- // for a token, skip the unknown-suffix error for the same token (the state-
448
- // suffix wouldn't be in the suffix list anyway, so it's the same root cause).
449
- const stateAfterTokens = new Set();
450
- for (const token of tokens) {
451
- if (intrinsic.some((re) => re.test(token))) continue;
452
- const trailingState = detectStateAfterProperty(token, known);
453
- if (trailingState) {
454
- stateAfterTokens.add(token);
455
- record(
456
- 'state-after-property',
457
- `${relative(root, runtimePath)}: ${token} has '${trailingState}' after the property; ` +
458
- `state must come before property (e.g. -${trailingState}-surface)`,
459
- runtime.indexOf(token),
460
- );
461
- }
462
- }
463
-
464
- for (const token of tokens) {
465
- const segments = token.slice(2).split('-');
466
- if (!segments.includes('disabled')) continue;
467
- const conflict = TERMINAL_CONFLICTS.find((s) => segments.includes(s));
468
- if (conflict) {
469
- record(
470
- 'disabled-is-terminal',
471
- `${relative(root, runtimePath)}: ${token} combines 'disabled' with '${conflict}'; disabled is terminal, so that state never paints. Drop the token.`,
472
- runtime.indexOf(token),
473
- );
474
- }
475
- }
476
-
477
- // Runtime: every token ends in a known suffix.
478
- for (const token of tokens) {
479
- if (stateAfterTokens.has(token) || intrinsic.some((re) => re.test(token))) continue;
480
- if (!tokenSuffix(token, known)) {
481
- record(
482
- 'unknown-suffix',
483
- `${relative(root, runtimePath)}: ${token} doesn't end in a known suffix`,
484
- runtime.indexOf(token),
485
- );
486
- }
487
- }
488
-
489
- checkDefaultsAreSemantic({ blocks, runtime, editor, root, runtimePath, record, vocabulary });
490
-
491
- // Editor: declares `const component = '<id>'` (module block).
492
- const componentDecl = new RegExp(`\\bconst\\s+component\\s*=\\s*['"]${id}['"]`);
493
- if (!componentDecl.test(editor)) {
494
- record(
495
- 'missing-component-const',
496
- `${relative(root, editorPath)}: missing 'const component = "${id}"' in <script module>`,
497
- );
498
- }
499
-
500
- // Editor: exports allTokens.
501
- if (!/\bexport\s+const\s+allTokens\b/.test(editor)) {
502
- record('missing-all-tokens', `${relative(root, editorPath)}: missing 'export const allTokens'`);
503
- }
504
-
505
- // Editor: every token a row names is one the runtime declares. A row that
506
- // names nothing renders a control that edits nothing.
507
- const declared = new Set();
508
- for (const block of blocks) {
509
- for (const n of declaredCustomProperties(block.replace(/\/\*[\s\S]*?\*\//g, ' '))) declared.add(n);
510
- }
511
- const refs = editorTokenRefs(editor);
512
- for (const name of refs.literals) {
513
- if (!declared.has(name)) {
514
- record(
515
- 'phantom-editor-token',
516
- `${relative(root, editorPath)}: names ${name}, which ${relative(root, runtimePath)} never declares in :global(:root)`,
517
- );
518
- }
519
- }
520
- for (const [name, re] of refs.patterns) {
521
- if (![...declared].some((d) => re.test(d))) {
522
- record(
523
- 'phantom-editor-token',
524
- `${relative(root, editorPath)}: names ${name}, which matches nothing ${relative(root, runtimePath)} declares in :global(:root)`,
525
- );
526
- }
527
- }
528
-
529
- // Editor: phantom-link guard. The font type-group helpers fall back to bare
530
- // `font-family`/`font-size`/… keys when called with a single argument (no
531
- // derivation). Across more than one slot that silently links every slot's fonts
532
- // into one tree. Passing `{ component, variants }` (a second arg) opts into
533
- // distinct, structural keys and suppresses the check, so this only fires on the
534
- // silent inference path. (The color helper no longer infers — a bare call there
535
- // emits solo, un-grouped colors, which can't phantom-link.)
536
- const colorPatterns = new Set();
537
- for (const m of editor.matchAll(/colorVariable\s*:\s*[`'"]([^`'"]+)[`'"]/g)) {
538
- colorPatterns.add(m[1].replace(/\$\{[^}]*\}/g, '*'));
539
- }
540
- const slots = colorPatterns.size;
541
- const fontBare =
542
- hasBareCall(editor, 'buildTypeGroupFontTokens') || hasBareCall(editor, 'buildTypeGroupTokens');
543
- if (slots > 1 && fontBare) {
544
- record(
545
- 'phantom-link',
546
- `${relative(root, editorPath)}: a type-group font helper is called across ${slots} slots without a derivation; ` +
547
- `its bare font-family/font-size/… keys would phantom-link every slot's fonts. Pass { component, variants } to buildTypeGroupTokens/buildTypeGroupFontTokens.`,
548
- );
549
- }
550
-
551
- // Imports across runtime + editor: reject deep imports into the package.
552
- for (const [path, source] of [[runtimePath, runtime], [editorPath, editor]]) {
553
- for (const imp of extractImports(source)) {
554
- for (const pattern of DEEP_IMPORT_PATTERNS) {
555
- if (pattern.test(imp)) {
556
- record('deep-import', `${relative(root, path)}: deep import not supported: ${imp}`);
557
- }
558
- }
559
- }
560
- }
561
-
562
- // Registration: either a direct registerComponent({ id }) call or the id
563
- // passed through bootLiveTokens({ components: [{ id }] }) — the standard
564
- // scaffold boot. Accept both, somewhere under src/.
565
- const srcFiles = findFilesRecursive(join(root, 'src'), ['.ts', '.js', '.svelte', '.mjs']);
566
- const idLiteral = `id\\s*:\\s*['"]${id}['"]`;
567
- const directPattern = new RegExp(`registerComponent\\s*\\(\\s*\\{[^}]*${idLiteral}`, 's');
568
- const bootPattern = new RegExp(`bootLiveTokens\\s*\\([\\s\\S]*?components\\s*:\\s*\\[[\\s\\S]*?${idLiteral}`, 's');
569
- let registrationFile = null;
570
- for (const file of srcFiles) {
571
- try {
572
- const src = readFileSync(file, 'utf8');
573
- if (directPattern.test(src) || bootPattern.test(src)) {
574
- registrationFile = file;
575
- break;
576
- }
577
- } catch {
578
- // ignore unreadable files
579
- }
580
- }
581
- // A first-party component is registered by membership in the package's own
582
- // `builtInRegistry`, not by a `registerComponent` call, so look there too
583
- // before calling it unregistered.
584
- if (!registrationFile && isBuiltIn(id)) return done();
171
+ const context = {
172
+ id,
173
+ Id,
174
+ root,
175
+ runtimePath,
176
+ editorPath,
177
+ runtime,
178
+ editor,
179
+ blocks,
180
+ vocab: vocabulary ?? loadVocabulary({ root }),
181
+ // A token the editor declares as an intrinsic carries a structural keyword,
182
+ // not a themeable value, so a property-suffix rule is the wrong test for it.
183
+ intrinsic: intrinsicMatchers(editor),
184
+ declared: declaredTokens(blocks),
185
+ };
585
186
 
586
- if (!registrationFile) {
587
- record(
588
- 'missing-registration',
589
- `no registration for '${id}' under src/. Expected registerComponent({ id: '${id}', ... }) or bootLiveTokens({ components: [{ id: '${id}', ... }] })`,
590
- );
591
- } else {
592
- // Check the registration file's imports too.
593
- const regSource = readFileSync(registrationFile, 'utf8');
594
- for (const imp of extractImports(regSource)) {
595
- for (const pattern of DEEP_IMPORT_PATTERNS) {
596
- if (pattern.test(imp)) {
597
- record('deep-import', `${relative(root, registrationFile)}: deep import not supported: ${imp}`);
598
- }
599
- }
600
- }
187
+ componentStructure.checkRuntime(context, record);
188
+ tokenRules.checkDefaultsAreSemantic(context, record);
189
+ tokenRules.checkConfigTokens(context, recordAt);
190
+ tokenRules.checkUnreadTokens(context, record);
191
+ // The editor rules need real editor content; a missing editor already has
192
+ // its own `missing-file` finding, and running them against an empty string
193
+ // would only restate it under different rules.
194
+ if (!editorMissing) componentStructure.checkEditor(context, record);
195
+ importsAndRoutes.checkComponentImports(root, [[runtimePath, runtime], [editorPath, editor]], recordAt);
196
+
197
+ const registrationFile = componentStructure.checkRegistration(context, record);
198
+ if (registrationFile) {
199
+ importsAndRoutes.checkComponentImports(root, [[registrationFile, readFileSync(registrationFile, 'utf8')]], recordAt);
601
200
  }
602
-
603
201
  return done();
604
202
  }
605
203
 
606
204
  /**
607
205
  * Every component authored in `root`: a runtime file under
608
- * src/system/components with an editor beside it or in the package's editor
609
- * directory. What `check-component` runs over when no id is named.
206
+ * `src/system/components` or a configured `componentDirs` entry, editor or
207
+ * not — a runtime with no editor is still in the batch, so `checkComponent`
208
+ * can report its own `missing-file`. What `check-component` runs over when no
209
+ * id is named.
610
210
  */
611
211
  export function discoverComponents(root = process.cwd()) {
612
- const dir = join(root, 'src/system/components');
613
- if (!existsSync(dir)) return [];
614
- return readdirSync(dir)
615
- .filter((f) => f.endsWith('.svelte') && !f.endsWith('Editor.svelte'))
616
- .map((f) => f.replace('.svelte', ''))
617
- .filter((Id) => EDITOR_DIRS.some((d) => existsSync(join(root, d, `${Id}Editor.svelte`))))
618
- .map((Id) => Id.toLowerCase());
212
+ return [...componentInventory(root).values()].filter((e) => e.runtimeExists).map((e) => e.id);
619
213
  }
620
214
 
621
215
  export function formatReport(id, result) {
@@ -655,7 +249,14 @@ export function checkComponentDefaults(runtimePath, { root = process.cwd(), voca
655
249
  const editorPath = EDITOR_DIRS.map((d) => join(root, d, `${name}Editor.svelte`)).find(existsSync);
656
250
  const editor = editorPath ? readFileSync(editorPath, 'utf8') : '';
657
251
 
658
- const blocks = extractGlobalRootBlocks(runtime);
659
- checkDefaultsAreSemantic({ blocks, runtime, editor, root, runtimePath, record, vocabulary });
252
+ const context = {
253
+ blocks: extractGlobalRootBlocks(runtime),
254
+ runtime,
255
+ root,
256
+ runtimePath,
257
+ vocab: vocabulary ?? loadVocabulary({ root }),
258
+ intrinsic: intrinsicMatchers(editor),
259
+ };
260
+ tokenRules.checkDefaultsAreSemantic(context, record);
660
261
  return findings;
661
262
  }