@motion-proto/live-tokens 0.68.1 → 0.70.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 (95) hide show
  1. package/.claude/skills/live-tokens-build-page/SKILL.md +16 -5
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +55 -19
  3. package/.claude/skills/live-tokens-create-component/references/token-naming.md +30 -1
  4. package/.claude/skills/live-tokens-fix-findings/SKILL.md +133 -0
  5. package/.claude/skills/live-tokens-pick-component/SKILL.md +10 -1
  6. package/CHANGELOG.md +236 -0
  7. package/README.md +13 -4
  8. package/bin/check-component.mjs +367 -63
  9. package/bin/check-page.mjs +409 -0
  10. package/bin/cli.mjs +107 -9
  11. package/bin/lib/catalogue.mjs +123 -0
  12. package/bin/lib/cssValues.mjs +50 -0
  13. package/bin/lib/findings.mjs +106 -0
  14. package/bin/lib/tokenVocabulary.mjs +240 -0
  15. package/dist-plugin/adjust/index.cjs +174 -23
  16. package/dist-plugin/adjust/index.js +68 -23
  17. package/dist-plugin/{chunk-2UX6EVVA.js → chunk-2YNERPXY.js} +1 -1
  18. package/dist-plugin/{chunk-NE6N66EE.js → chunk-GPIBU44G.js} +107 -1
  19. package/dist-plugin/{chunk-ZHPX7ZYQ.js → chunk-RFVYPNRO.js} +39 -1
  20. package/dist-plugin/generateColorsAndType/index.cjs +107 -1
  21. package/dist-plugin/generateColorsAndType/index.js +1 -1
  22. package/dist-plugin/index.cjs +146 -2
  23. package/dist-plugin/index.js +3 -3
  24. package/dist-plugin/migrateData/index.cjs +107 -1
  25. package/dist-plugin/migrateData/index.js +2 -2
  26. package/dist-plugin/tokensCssMigrations/index.cjs +39 -1
  27. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  28. package/package.json +3 -2
  29. package/src/app/site.css +4 -4
  30. package/src/editor/component-editor/ButtonEditor.svelte +71 -4
  31. package/src/editor/component-editor/CardEditor.svelte +6 -6
  32. package/src/editor/component-editor/DialogEditor.svelte +3 -3
  33. package/src/editor/component-editor/IconButtonEditor.svelte +68 -3
  34. package/src/editor/component-editor/ImageEditor.svelte +8 -8
  35. package/src/editor/component-editor/ImageLightboxEditor.svelte +12 -1
  36. package/src/editor/component-editor/MenuSelectEditor.svelte +63 -3
  37. package/src/editor/component-editor/SegmentedControlEditor.svelte +63 -3
  38. package/src/editor/component-editor/SideNavigationEditor.svelte +67 -3
  39. package/src/editor/component-editor/SliderEditor.svelte +186 -0
  40. package/src/editor/component-editor/TabBarEditor.svelte +63 -3
  41. package/src/editor/component-editor/registry.ts +10 -0
  42. package/src/editor/core/components/aliasKinds.ts +51 -28
  43. package/src/editor/core/sketch/sketchLayer.ts +16 -0
  44. package/src/editor/core/store/editorPersistence.ts +44 -1
  45. package/src/editor/core/store/editorRenderer.ts +2 -2
  46. package/src/editor/core/store/editorStore.ts +18 -18
  47. package/src/editor/core/store/editorTypes.ts +7 -6
  48. package/src/editor/core/themes/migrations/2026-09-01-gate-suffix-enabled.ts +31 -0
  49. package/src/editor/core/themes/migrations/2026-09-01-scrim-rename.ts +52 -0
  50. package/src/editor/core/themes/migrations/2026-09-01-tabbar-active-tint.ts +27 -0
  51. package/src/editor/core/themes/migrations/2026-09-01-tint-rename.ts +42 -0
  52. package/src/editor/core/themes/migrations/index.ts +16 -0
  53. package/src/editor/core/themes/slices/domainVars.ts +2 -2
  54. package/src/editor/core/themes/slices/washes.ts +107 -0
  55. package/src/editor/docs/content/editing-tokens.md +5 -3
  56. package/src/editor/docs/content.generated.ts +1 -1
  57. package/src/editor/index.ts +1 -1
  58. package/src/editor/pages/EditorShell.svelte +1 -1
  59. package/src/editor/ui/SurfacesTab.svelte +3 -3
  60. package/src/editor/ui/UITokenSelector.svelte +1 -0
  61. package/src/editor/ui/VariablesTab.svelte +2 -2
  62. package/src/editor/ui/sections/{OverlaysSection.svelte → WashesSection.svelte} +44 -43
  63. package/src/live-tokens/data/colors-and-type/autumn.json +6 -6
  64. package/src/live-tokens/data/colors-and-type/default.json +6 -6
  65. package/src/live-tokens/data/colors-and-type/halloween.json +6 -6
  66. package/src/live-tokens/data/colors-and-type/midnight-study.json +6 -6
  67. package/src/live-tokens/data/colors-and-type/ocean.json +6 -6
  68. package/src/live-tokens/data/colors-and-type/royal-velvet.json +6 -6
  69. package/src/live-tokens/data/colors-and-type/sketchy.json +6 -6
  70. package/src/live-tokens/data/colors-and-type/spring-meadow.json +6 -6
  71. package/src/live-tokens/data/colors-and-type/sunset.json +6 -6
  72. package/src/live-tokens/data/themes/autumn.json +81 -13
  73. package/src/live-tokens/data/themes/halloween.json +81 -13
  74. package/src/live-tokens/data/themes/midnight-study.json +81 -13
  75. package/src/live-tokens/data/themes/ocean.json +81 -13
  76. package/src/live-tokens/data/themes/royal-velvet.json +81 -13
  77. package/src/live-tokens/data/themes/sketchy.json +81 -13
  78. package/src/live-tokens/data/themes/spring-meadow.json +81 -13
  79. package/src/live-tokens/data/themes/sunset.json +81 -13
  80. package/src/live-tokens/data/tokens.generated.css +6 -6
  81. package/src/system/components/Button.svelte +28 -10
  82. package/src/system/components/Card.svelte +6 -6
  83. package/src/system/components/Dialog.svelte +3 -3
  84. package/src/system/components/IconButton.svelte +23 -7
  85. package/src/system/components/Image.svelte +6 -6
  86. package/src/system/components/MenuSelect.svelte +17 -1
  87. package/src/system/components/SegmentedControl.svelte +20 -2
  88. package/src/system/components/SideNavigation.svelte +22 -2
  89. package/src/system/components/Slider.svelte +348 -0
  90. package/src/system/components/TabBar.svelte +20 -2
  91. package/src/system/styles/CONVENTIONS.md +2 -2
  92. package/src/system/styles/tokens.css +12 -4
  93. package/template/package.json +3 -2
  94. package/template/src/pages/Home.svelte +1 -1
  95. package/src/editor/core/themes/slices/overlays.ts +0 -101
package/README.md CHANGED
@@ -11,7 +11,7 @@ The editor is dev-only. Production builds get plain CSS variables and the compon
11
11
  ## Features
12
12
 
13
13
  - **Live token editing.** Colors, typography, spacing, radii, shadows, motion, palettes, and gradients. Every input writes a CSS variable, so the page repaints with no reload and no build step.
14
- - **Live component editing.** 25 shipped Svelte components (Button, IconButton, Input, Card, Dialog, Badge, Callout, Table, Tooltip, Toggle, TabBar, SegmentedControl, RadioButton, MenuSelect, ProgressBar, CornerBadge, SectionDivider, CollapsibleSection, Notification, Image, ImageLightbox, CodeSnippet, SideNavigation, Panel, InlineEditActions) declare their design-token aliases in a `:global(:root)` block. Rewire an alias from the component's editor and it updates everywhere that component is used, on your real pages.
14
+ - **Live component editing.** 26 shipped Svelte components (Button, IconButton, Input, Card, Dialog, Badge, Callout, Table, Tooltip, Toggle, TabBar, SegmentedControl, RadioButton, MenuSelect, ProgressBar, CornerBadge, SectionDivider, CollapsibleSection, Notification, Image, ImageLightbox, CodeSnippet, SideNavigation, Panel, InlineEditActions) declare their design-token aliases in a `:global(:root)` block. Rewire an alias from the component's editor and it updates everywhere that component is used, on your real pages.
15
15
  - **Four dev-only routes.** `/live-tokens/editor` for tokens, `/live-tokens/colors` for palettes, `/live-tokens/components` for per-component aliases, `/live-tokens/docs` for the user guide.
16
16
  - **Editor overlay.** Pins to the top right of every dev page and opens the editor in a side panel or floating window, so you edit on the page you are styling. Its "Page Source" button opens the current page's `.svelte` file in VS Code.
17
17
  - **Themes.** A theme is a whole look in one file: colors and type plus a config for every component, stored by value. Loading one changes a single pointer file, and nothing your site ships changes until you Adopt. Export a theme and import it into another project to restore the look in one step.
@@ -323,7 +323,10 @@ npx @motion-proto/live-tokens <command>
323
323
  |---|---|
324
324
  | `create <dir> [--force]` | Scaffold a new Svelte + Vite app wired up with live-tokens. |
325
325
  | `setup-claude [--force]` | Install the bundled Claude Code skills into `./.claude/skills/`. |
326
- | `check-component <id>` | Validate a component's runtime, editor, and registration against the authoring contract. |
326
+ | `components [id] [--json]` | List every component the project has, shipped and its own, with the props each takes; with an id, its props, variants, tokens, and defaults. |
327
+ | `tokens [--family <name>] [--json]` | List every theme token the project's `tokens.css` declares, by family, with its value. |
328
+ | `check-component [id]` | Validate a component's runtime, editor, and registration against the authoring contract; with no id, every component authored under `src/system/components`. |
329
+ | `check-page [paths...]` | Validate pages against the build-page contract: catalogue components and their props, theme tokens over literals, route wiring. |
327
330
  | `generate-theme <brief.json> [--no-activate] [--dry-run] [--carry-from <name>]` | Build a full theme from a 10-seed OKLCH brief, enforce AA contrast, write `themes/<slug>.json`, and open it. |
328
331
  | `adjust <ops.json> [--dry-run]` | Move radius, padding, gap, and border-width aliases along their token scales. |
329
332
  | `set-fonts <brief.json> [--dry-run] [--no-verify]` | Bind Google Fonts families to the theme's font stacks, verified against the API. |
@@ -333,7 +336,7 @@ Once installed in a project, the same commands are available as `npx live-tokens
333
336
 
334
337
  ## Claude Code skills
335
338
 
336
- The package bundles six Claude Code skills. They encode the conventions this README cannot carry in full: which component fits a need, how a page is wired, what a valid theme looks like in OKLCH, how two typefaces sit together, and how geometry moves along the token scales. Each triggers from an ordinary request, so there are no slash commands to learn.
339
+ The package bundles seven Claude Code skills. They encode the conventions this README cannot carry in full: which component fits a need, how a page is wired, what a valid theme looks like in OKLCH, how two typefaces sit together, how geometry moves along the token scales, and how an existing page or component is brought back into line with all of that. Each triggers from an ordinary request, so there are no slash commands to learn.
337
340
 
338
341
  ### Install
339
342
 
@@ -397,13 +400,19 @@ Ask for something the catalogue lacks: "author a Rating component", "make my Chi
397
400
 
398
401
  The skill covers the recipe: the runtime `.svelte` file with its `:global(:root)` token block, the editor `.svelte` file exporting `allTokens` and its variant groups, the `registerComponent()` call, and the catalogue entry that keeps `live-tokens-pick-component` current. It carries the naming scheme, the token suffix vocabulary, the state model (component states such as selected and disabled are separate from interaction states such as hover), and the public-imports rule, and points at the shipped `Toggle` in `node_modules` as the worked example. Linked siblings, intrinsics, and the fixed-overlay portal rule sit in reference files the skill reads only when a component needs them.
399
402
 
403
+ ### `live-tokens-fix-findings`
404
+
405
+ Ask for the existing code to catch up: "make check:design pass", "fix the design-system warnings", "replace the hex and pixel values with tokens", "why is check-page failing on the pricing page?".
406
+
407
+ The two checkers report a stable rule id per finding. The skill runs them with `--json`, groups the findings by rule, and carries one fix recipe per rule: a colour literal becomes the token for its role rather than the nearest hue, a spacing literal moves to the nearest `--space-*` step with the shift named, a raw `font-size` becomes a whole text style, a prop the component does not declare is mapped or dropped, and `site.css` moves out of `main.ts` into each page. It re-runs after every rule and stops at exit 0, then reports what changed, what it left and why, and what `--strict` would add. It never silences a rule to pass and never adds a token to `tokens.css` to match a value a page happened to use.
408
+
400
409
  Verify the result:
401
410
 
402
411
  ```bash
403
412
  npx @motion-proto/live-tokens check-component <id>
404
413
  ```
405
414
 
406
- The validator checks the file layout, the `:global(:root)` block, the token-suffix vocabulary, the state-before-property rule, the no-raw-color-defaults rule, the public-imports rule, and the `registerComponent({ id })` call. Exit code 0 means the static contract is met. Use it after Claude generates a component, and as a pre-commit guard on hand-authored ones.
415
+ The validator checks the file layout, the `:global(:root)` block, the token-suffix vocabulary, the state-before-property rule, the public-imports rule, the `registerComponent({ id })` call, and that every default resolves to a theme token rather than a literal. Exit code 0 means the static contract is met. A project scaffolded by `create` runs it, with `check-page`, as `npm run check:design` before every `vite build`, so a component or page that opts out of the theme cannot ship by accident.
407
416
 
408
417
  ## From edit to production
409
418
 
@@ -10,26 +10,92 @@
10
10
  // - token names match --<id>-<part>[-<state>][-<element>]-<property>
11
11
  // with the property being one of the recognised suffixes,
12
12
  // and state coming before property (never after)
13
- // - :global(:root) defaults reference theme tokens (no raw colour literals)
13
+ // - :global(:root) defaults are semantic: every one resolves to a real theme
14
+ // token, so the component repaints when the theme changes. A value with no
15
+ // token behind it must be a declared intrinsic (a structural keyword the
16
+ // editor exports in `intrinsics`), never a literal.
14
17
  //
15
- // Returns { errors: string[], warnings: string[] }.
18
+ // 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.
16
21
 
17
22
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
18
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, declaredCustomProperties, extractGlobalRootBlocks, isContractToken, loadVocabulary } from './lib/tokenVocabulary.mjs';
19
27
 
20
- // Property suffixes the editor picker recognises (KIND_PATTERNS in the editor).
21
- // Keep in sync with the skill's suffix vocabulary.
22
- const KNOWN_SUFFIXES = [
23
- 'surface', 'border', 'text', 'icon', 'label', 'fill',
24
- 'radius', 'border-width', 'font-family', 'font-weight',
25
- 'font-size', 'line-height', 'letter-spacing', 'padding',
26
- 'thickness', 'width', 'color', 'size', 'gap', 'opacity',
27
- 'shadow', 'blur', 'divider',
28
- ];
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
+ };
47
+
48
+ // Shipped components keep their editor beside the other editors; a
49
+ // consumer-authored one sits next to its runtime. Probe both.
50
+ const EDITOR_DIRS = ['src/system/components', 'src/editor/component-editor'];
51
+
52
+ // Property suffixes come from the editor's own kind table, so the checker and
53
+ // the picker can never disagree about what a name means. Read as text rather
54
+ // than imported: this module must load without the compiled engine (CI runs the
55
+ // suite before the plugin is built).
56
+ const ALIAS_KINDS = 'src/editor/core/components/aliasKinds.ts';
57
+
58
+ function readKnownSuffixes(root) {
59
+ for (const base of [root, PKG_ROOT]) {
60
+ const path = join(base, ALIAS_KINDS);
61
+ if (!existsSync(path)) continue;
62
+ const src = readFileSync(path, 'utf8');
63
+ const block = src.match(/KIND_RULES[^=]*=\s*\[([\s\S]*?)\n\];/);
64
+ if (!block) continue;
65
+ const out = new Set();
66
+ for (const m of block[1].matchAll(/suffix:\s*\[([\s\S]*?)\]/g)) {
67
+ for (const n of m[1].matchAll(/'-([a-z0-9-]+)'/g)) out.add(n[1]);
68
+ }
69
+ if (out.size > 0) return [...out];
70
+ }
71
+ return [];
72
+ }
73
+
74
+ const BUILT_IN_REGISTRY = 'src/editor/component-editor/registry.ts';
75
+
76
+ /** True when `id` is one of the package's own components. */
77
+ function isBuiltIn(id) {
78
+ for (const base of [PKG_ROOT, process.cwd()]) {
79
+ const path = join(base, BUILT_IN_REGISTRY);
80
+ if (!existsSync(path)) continue;
81
+ const src = readFileSync(path, 'utf8');
82
+ const block = src.match(/builtInRegistry[^=]*=\s*Object\.freeze\(\{([\s\S]*?)\n\}\);/);
83
+ if (block && new RegExp(`\\bid:\\s*'${id}'`).test(block[1])) return true;
84
+ }
85
+ return false;
86
+ }
87
+
88
+ // Per-side padding names (`--card-body-padding-top`) are written by the padding
89
+ // selector, never declared by hand, and belong with their parent.
90
+ const SIDE_SUFFIXES = ['-top', '-right', '-bottom', '-left'];
29
91
 
30
92
  // State tokens that must come *before* the property, never after.
31
93
  const STATE_TOKENS = ['hover', 'disabled', 'selected', 'focus', 'active', 'focused'];
32
94
 
95
+ // Disabled is terminal: a disabled component cannot be hovered, focused, or
96
+ // selected, so a token naming both describes a state that never paints.
97
+ const TERMINAL_CONFLICTS = ['hover', 'focus', 'focused', 'selected', 'on', 'active', 'checked'];
98
+
33
99
  // Deep imports into the package internals are not a supported API.
34
100
  const DEEP_IMPORT_PATTERNS = [
35
101
  /^@motion-proto\/live-tokens\/src\//,
@@ -50,39 +116,47 @@ function extractImports(source) {
50
116
  return out;
51
117
  }
52
118
 
53
- function extractGlobalRootBlocks(source) {
54
- const blocks = [];
55
- const re = /:global\(:root\)\s*\{([^}]*)\}/g;
56
- let m;
57
- while ((m = re.exec(source)) !== null) {
58
- blocks.push(m[1]);
59
- }
60
- return blocks;
61
- }
62
-
63
- function extractTokensForId(blocks, id) {
119
+ /**
120
+ * `--<id>-*` tokens declared in the given blocks.
121
+ *
122
+ * The prefix may also be the hyphenated word form of the id: CornerBadge is
123
+ * registered as `cornerbadge` but names its tokens `--corner-badge-*`, and the
124
+ * whole system (config, theme, editor) follows that. Segments never end on a
125
+ * hyphen, so a trailing `-` cannot be mistaken for a token name.
126
+ */
127
+ function extractTokensForId(blocks, id, kebab) {
64
128
  const tokens = new Set();
65
- const re = new RegExp(`--${id}-[a-z0-9-]+`, 'g');
66
- for (const block of blocks) {
67
- const matches = block.match(re) ?? [];
68
- for (const t of matches) tokens.add(t);
129
+ // Comments name tokens too (`the --card-hover-* tokens`); they are prose.
130
+ blocks = blocks.map((b) => b.replace(/\/\*[\s\S]*?\*\//g, ' '));
131
+ const prefixes = kebab && kebab !== id ? [id, kebab] : [id];
132
+ for (const prefix of prefixes) {
133
+ const re = new RegExp(`--${prefix}(?:-[a-z0-9]+)+`, 'g');
134
+ for (const block of blocks) {
135
+ for (const t of block.match(re) ?? []) tokens.add(t);
136
+ }
69
137
  }
70
138
  return [...tokens];
71
139
  }
72
140
 
73
- function tokenSuffix(token) {
74
- for (const suffix of KNOWN_SUFFIXES) {
75
- if (token.endsWith(`-${suffix}`)) return suffix;
141
+ /** `CornerBadge` -> `corner-badge`; the other accepted token prefix. */
142
+ function kebabOf(name) {
143
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
144
+ }
145
+
146
+ function tokenSuffix(token, known) {
147
+ const bare = SIDE_SUFFIXES.find((x) => token.endsWith(x)) ? token.slice(0, token.lastIndexOf('-')) : token;
148
+ for (const suffix of known) {
149
+ if (bare.endsWith(`-${suffix}`)) return suffix;
76
150
  }
77
151
  return null;
78
152
  }
79
153
 
80
- function detectStateAfterProperty(token) {
154
+ function detectStateAfterProperty(token, known) {
81
155
  // e.g. --comp-part-surface-hover (wrong) vs --comp-part-hover-surface (right)
82
156
  for (const state of STATE_TOKENS) {
83
157
  if (token.endsWith(`-${state}`)) {
84
158
  const head = token.slice(0, -(state.length + 1));
85
- if (tokenSuffix(head)) return state;
159
+ if (tokenSuffix(head, known)) return state;
86
160
  }
87
161
  }
88
162
  return null;
@@ -125,85 +199,266 @@ function findFilesRecursive(dir, exts) {
125
199
  return out;
126
200
  }
127
201
 
128
- export function checkComponent(id, root = process.cwd()) {
202
+ /**
203
+ * Every token the editor names in a row: `variable: '--x'`, the type-group
204
+ * `colorVariable` / `familyVariable` / ... keys, and the arrow form intrinsics
205
+ * use. A `${...}` hole stands for a variant segment. Per-side padding names are
206
+ * written by the padding selector rather than declared, so they resolve to
207
+ * their parent.
208
+ */
209
+ function editorTokenRefs(editor) {
210
+ const literals = new Set();
211
+ const patterns = [];
212
+ for (const m of editor.matchAll(/\b(?:variable|[a-zA-Z]+Variable)\s*:\s*(?:\([^)]*\)\s*=>\s*)?[`'"](--(?:\$\{[^}]*\}|[^`'"])+)[`'"]/g)) {
213
+ const name = stripSide(m[1]);
214
+ if (name.includes('${')) {
215
+ patterns.push([name, new RegExp(`^${name.replace(/[.*+?^()|[\]\\]/g, '\\$&').replace(/\$\{[^}]*\}/g, '[a-z0-9-]+')}$`)]);
216
+ } else {
217
+ literals.add(name);
218
+ }
219
+ }
220
+ return { literals, patterns };
221
+ }
222
+
223
+ function stripSide(token) {
224
+ const side = SIDE_SUFFIXES.find((x) => token.endsWith(x));
225
+ return side ? token.slice(0, -side.length) : token;
226
+ }
227
+
228
+ /**
229
+ * Token patterns the editor declares in `intrinsics` — the only tokens allowed a
230
+ * bare keyword instead of a theme token.
231
+ *
232
+ * Matched on each spec's `variable`, not its `key`: the two need not agree
233
+ * (Image's `zoom` key declares `--image-zoom-enabled`), and `variable` is what
234
+ * actually names the token. A `${...}` hole stands for a variant segment.
235
+ */
236
+ function intrinsicMatchers(editor) {
237
+ const block = editor.match(/export\s+const\s+intrinsics[^=]*=\s*\[([\s\S]*?)\];/);
238
+ if (!block) return [];
239
+ const out = [];
240
+ for (const m of block[1].matchAll(/\bvariable\s*:[^`'"]*[`'"]([^`'"]+)[`'"]/g)) {
241
+ const pattern = m[1]
242
+ .replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === '$' ? '$' : `\\${c}`))
243
+ .replace(/\$\\\{[^}]*\\\}/g, '[a-z0-9-]+')
244
+ .replace(/\$\{[^}]*\}/g, '[a-z0-9-]+');
245
+ out.push(new RegExp(`^${pattern}$`));
246
+ }
247
+ return out;
248
+ }
249
+
250
+ /**
251
+ * The semantic half of the contract: a component token is a *property name*, and
252
+ * its default is the theme token that property reads. So every default must
253
+ * resolve to a real token — otherwise the component stops repainting when the
254
+ * theme changes, which is the whole point of declaring it.
255
+ */
256
+ function checkDefaultsAreSemantic({ blocks, runtime, editor, root, runtimePath, record, vocabulary }) {
257
+ const vocab = vocabulary ?? loadVocabulary({ root });
258
+ const matchers = intrinsicMatchers(editor);
259
+ const rel = relative(root, runtimePath);
260
+
261
+ const own = new Set();
262
+ for (const block of blocks) {
263
+ for (const m of block.matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/gm)) own.add(m[1]);
264
+ }
265
+
266
+ for (const block of blocks) {
267
+ for (const m of block.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
268
+ const [decl, name, raw] = m;
269
+ const value = raw.trim();
270
+ const at = runtime.indexOf(decl);
271
+
272
+ const painted = stripVarFallbacks(value);
273
+ const refs = [...painted.matchAll(/var\(\s*(--[a-z0-9-]+)/g)].map((x) => x[1]);
274
+ for (const ref of refs) {
275
+ if (own.has(ref) || vocab.knows(ref)) continue;
276
+ record(
277
+ 'unknown-token-ref',
278
+ STATE_TOKENS.includes(ref.replace(/^--/, '').split('-')[0])
279
+ ? `${rel}: ${name} reads ${ref}, but a state is a segment of a property name, not a token of its own; read the token the state should paint`
280
+ : isContractToken(ref)
281
+ ? `${rel}: ${name} reads ${ref}, which looks like a theme token but no longer exists; check tokens.css for a rename`
282
+ : `${rel}: ${name} reads ${ref}, which is not a theme token or a component token`,
283
+ at,
284
+ );
285
+ }
286
+
287
+ if (hasColorLiteral(painted)) {
288
+ record(
289
+ 'color-literal',
290
+ `${rel}: ${name}: ${value} is a colour literal; defaults must reference theme tokens (e.g. var(--surface-primary))`,
291
+ at,
292
+ );
293
+ continue;
294
+ }
295
+
296
+ if (refs.length === 0 && !matchers.some((re) => re.test(name))) {
297
+ record(
298
+ 'default-not-token',
299
+ `${rel}: ${name}: ${value} has no theme token behind it; back it with a token, or declare it in the editor's \`intrinsics\` if it is a structural keyword`,
300
+ at,
301
+ );
302
+ }
303
+
304
+ if (hasDimensionLiteral(painted)) {
305
+ record(
306
+ 'dimension-literal',
307
+ `${rel}: ${name}: ${value} pins a raw dimension; use a --space-*, --radius-*, or --border-width-* token`,
308
+ at,
309
+ );
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ export function checkComponent(id, root = process.cwd(), { vocabulary } = {}) {
129
316
  const errors = [];
130
317
  const warnings = [];
318
+ const findings = [];
319
+ let file = '';
320
+ let source = '';
321
+
322
+ const record = (rule, message, index = -1) => {
323
+ findings.push({
324
+ rule,
325
+ file,
326
+ line: index >= 0 && source ? lineOf(source, index) : 1,
327
+ message,
328
+ });
329
+ (COMPONENT_RULES[rule] === 'warn' ? warnings : errors).push(message);
330
+ };
331
+ const done = () => ({ errors, warnings, findings });
131
332
 
132
333
  if (!/^[a-z][a-z0-9]*$/.test(id)) {
133
- errors.push(`id "${id}" is invalid; must be lowercase letters/digits, no dashes`);
134
- return { errors, warnings };
334
+ record('invalid-id', `id "${id}" is invalid; must be lowercase letters/digits, no dashes`);
335
+ return done();
135
336
  }
136
337
 
137
- const Id = capitalize(id);
138
- const runtimePath = join(root, 'src/system/components', `${Id}.svelte`);
139
- const editorPath = join(root, 'src/system/components', `${Id}Editor.svelte`);
338
+ // Resolve the real filename rather than capitalising the id: `cornerbadge`
339
+ // ships as `CornerBadge.svelte`, and the casing is what gives the kebab form
340
+ // of its token prefix.
341
+ const dir = join(root, 'src/system/components');
342
+ const Id =
343
+ (existsSync(dir) ? readdirSync(dir) : [])
344
+ .find((f) => f.toLowerCase() === `${id}.svelte`)
345
+ ?.replace('.svelte', '') ?? capitalize(id);
346
+ const runtimePath = join(dir, `${Id}.svelte`);
347
+ const editorPath =
348
+ EDITOR_DIRS.map((d) => join(root, d, `${Id}Editor.svelte`)).find(existsSync) ??
349
+ join(root, EDITOR_DIRS[0], `${Id}Editor.svelte`);
140
350
 
351
+ file = relative(root, runtimePath);
141
352
  if (!existsSync(runtimePath)) {
142
- errors.push(`runtime missing: ${relative(root, runtimePath)}`);
353
+ record('missing-file', `runtime missing: ${relative(root, runtimePath)}`);
143
354
  }
144
355
  if (!existsSync(editorPath)) {
145
- errors.push(`editor missing: ${relative(root, editorPath)}`);
356
+ record('missing-file', `editor missing: ${relative(root, editorPath)}`);
146
357
  }
147
- if (errors.length) return { errors, warnings };
358
+ if (errors.length) return done();
148
359
 
149
360
  const runtime = readFileSync(runtimePath, 'utf8');
150
361
  const editor = readFileSync(editorPath, 'utf8');
362
+ source = runtime;
151
363
 
152
364
  // Runtime: :global(:root) block present.
153
365
  const blocks = extractGlobalRootBlocks(runtime);
154
366
  if (blocks.length === 0) {
155
- errors.push(`${relative(root, runtimePath)}: missing :global(:root) declaration block`);
367
+ record('missing-root-block', `${relative(root, runtimePath)}: missing :global(:root) declaration block`);
156
368
  }
157
369
 
158
370
  // Runtime: at least one --<id>-* token.
159
- const tokens = extractTokensForId(blocks, id);
371
+ const tokens = extractTokensForId(blocks, id, kebabOf(Id));
160
372
  if (blocks.length > 0 && tokens.length === 0) {
161
- errors.push(`${relative(root, runtimePath)}: no --${id}-* tokens declared in :global(:root)`);
373
+ record('no-tokens', `${relative(root, runtimePath)}: no --${id}-* tokens declared in :global(:root)`);
162
374
  }
163
375
 
376
+ // A token the editor declares as an intrinsic carries a structural keyword,
377
+ // not a themeable value, so a property-suffix rule is the wrong test for it.
378
+ const intrinsic = intrinsicMatchers(editor);
379
+ const known = readKnownSuffixes(root);
380
+
164
381
  // Runtime: state-after-property anti-pattern. Report this first; if it fires
165
382
  // for a token, skip the unknown-suffix error for the same token (the state-
166
383
  // suffix wouldn't be in the suffix list anyway, so it's the same root cause).
167
384
  const stateAfterTokens = new Set();
168
385
  for (const token of tokens) {
169
- const trailingState = detectStateAfterProperty(token);
386
+ if (intrinsic.some((re) => re.test(token))) continue;
387
+ const trailingState = detectStateAfterProperty(token, known);
170
388
  if (trailingState) {
171
389
  stateAfterTokens.add(token);
172
- errors.push(
390
+ record(
391
+ 'state-after-property',
173
392
  `${relative(root, runtimePath)}: ${token} has '${trailingState}' after the property; ` +
174
393
  `state must come before property (e.g. -${trailingState}-surface, not -surface-${trailingState})`,
394
+ runtime.indexOf(token),
175
395
  );
176
396
  }
177
397
  }
178
398
 
179
- // Runtime: every token ends in a known suffix.
180
399
  for (const token of tokens) {
181
- if (stateAfterTokens.has(token)) continue;
182
- if (!tokenSuffix(token)) {
183
- errors.push(`${relative(root, runtimePath)}: ${token} doesn't end in a known suffix`);
400
+ const segments = token.slice(2).split('-');
401
+ if (!segments.includes('disabled')) continue;
402
+ const conflict = TERMINAL_CONFLICTS.find((s) => segments.includes(s));
403
+ if (conflict) {
404
+ record(
405
+ 'disabled-is-terminal',
406
+ `${relative(root, runtimePath)}: ${token} combines 'disabled' with '${conflict}'; disabled is terminal, so that state never paints. Drop the token.`,
407
+ runtime.indexOf(token),
408
+ );
184
409
  }
185
410
  }
186
411
 
187
- // Runtime: defaults inside :global(:root) reference theme tokens, not raw colours.
188
- for (const block of blocks) {
189
- const rawColours = block.match(/:\s*#[0-9a-fA-F]{3,8}\b/g) ?? [];
190
- if (rawColours.length > 0) {
191
- errors.push(
192
- `${relative(root, runtimePath)}: :global(:root) contains ${rawColours.length} raw colour literal(s); ` +
193
- `defaults must reference theme tokens (e.g. var(--surface-primary))`,
412
+ // Runtime: every token ends in a known suffix.
413
+ for (const token of tokens) {
414
+ if (stateAfterTokens.has(token) || intrinsic.some((re) => re.test(token))) continue;
415
+ if (!tokenSuffix(token, known)) {
416
+ record(
417
+ 'unknown-suffix',
418
+ `${relative(root, runtimePath)}: ${token} doesn't end in a known suffix`,
419
+ runtime.indexOf(token),
194
420
  );
195
421
  }
196
422
  }
197
423
 
424
+ checkDefaultsAreSemantic({ blocks, runtime, editor, root, runtimePath, record, vocabulary });
425
+
198
426
  // Editor: declares `const component = '<id>'` (module block).
199
427
  const componentDecl = new RegExp(`\\bconst\\s+component\\s*=\\s*['"]${id}['"]`);
200
428
  if (!componentDecl.test(editor)) {
201
- errors.push(`${relative(root, editorPath)}: missing 'const component = "${id}"' in <script module>`);
429
+ record(
430
+ 'missing-component-const',
431
+ `${relative(root, editorPath)}: missing 'const component = "${id}"' in <script module>`,
432
+ );
202
433
  }
203
434
 
204
435
  // Editor: exports allTokens.
205
436
  if (!/\bexport\s+const\s+allTokens\b/.test(editor)) {
206
- errors.push(`${relative(root, editorPath)}: missing 'export const allTokens'`);
437
+ record('missing-all-tokens', `${relative(root, editorPath)}: missing 'export const allTokens'`);
438
+ }
439
+
440
+ // Editor: every token a row names is one the runtime declares. A row that
441
+ // names nothing renders a control that edits nothing.
442
+ const declared = new Set();
443
+ for (const block of blocks) {
444
+ for (const n of declaredCustomProperties(block.replace(/\/\*[\s\S]*?\*\//g, ' '))) declared.add(n);
445
+ }
446
+ const refs = editorTokenRefs(editor);
447
+ for (const name of refs.literals) {
448
+ if (!declared.has(name)) {
449
+ record(
450
+ 'phantom-editor-token',
451
+ `${relative(root, editorPath)}: names ${name}, which ${relative(root, runtimePath)} never declares in :global(:root)`,
452
+ );
453
+ }
454
+ }
455
+ for (const [name, re] of refs.patterns) {
456
+ if (![...declared].some((d) => re.test(d))) {
457
+ record(
458
+ 'phantom-editor-token',
459
+ `${relative(root, editorPath)}: names ${name}, which matches nothing ${relative(root, runtimePath)} declares in :global(:root)`,
460
+ );
461
+ }
207
462
  }
208
463
 
209
464
  // Editor: phantom-link guard. The font type-group helpers fall back to bare
@@ -221,7 +476,8 @@ export function checkComponent(id, root = process.cwd()) {
221
476
  const fontBare =
222
477
  hasBareCall(editor, 'buildTypeGroupFontTokens') || hasBareCall(editor, 'buildTypeGroupTokens');
223
478
  if (slots > 1 && fontBare) {
224
- warnings.push(
479
+ record(
480
+ 'phantom-link',
225
481
  `${relative(root, editorPath)}: a type-group font helper is called across ${slots} slots without a derivation; ` +
226
482
  `its bare font-family/font-size/… keys would phantom-link every slot's fonts. Pass { component, variants } to buildTypeGroupTokens/buildTypeGroupFontTokens.`,
227
483
  );
@@ -232,7 +488,7 @@ export function checkComponent(id, root = process.cwd()) {
232
488
  for (const imp of extractImports(source)) {
233
489
  for (const pattern of DEEP_IMPORT_PATTERNS) {
234
490
  if (pattern.test(imp)) {
235
- errors.push(`${relative(root, path)}: deep import not supported: ${imp}`);
491
+ record('deep-import', `${relative(root, path)}: deep import not supported: ${imp}`);
236
492
  }
237
493
  }
238
494
  }
@@ -257,21 +513,44 @@ export function checkComponent(id, root = process.cwd()) {
257
513
  // ignore unreadable files
258
514
  }
259
515
  }
516
+ // A first-party component is registered by membership in the package's own
517
+ // `builtInRegistry`, not by a `registerComponent` call, so look there too
518
+ // before calling it unregistered.
519
+ if (!registrationFile && isBuiltIn(id)) return done();
520
+
260
521
  if (!registrationFile) {
261
- errors.push(`no registration for '${id}' under src/ — expected registerComponent({ id: '${id}', ... }) or bootLiveTokens({ components: [{ id: '${id}', ... }] })`);
522
+ record(
523
+ 'missing-registration',
524
+ `no registration for '${id}' under src/ — expected registerComponent({ id: '${id}', ... }) or bootLiveTokens({ components: [{ id: '${id}', ... }] })`,
525
+ );
262
526
  } else {
263
527
  // Check the registration file's imports too.
264
528
  const regSource = readFileSync(registrationFile, 'utf8');
265
529
  for (const imp of extractImports(regSource)) {
266
530
  for (const pattern of DEEP_IMPORT_PATTERNS) {
267
531
  if (pattern.test(imp)) {
268
- errors.push(`${relative(root, registrationFile)}: deep import not supported: ${imp}`);
532
+ record('deep-import', `${relative(root, registrationFile)}: deep import not supported: ${imp}`);
269
533
  }
270
534
  }
271
535
  }
272
536
  }
273
537
 
274
- return { errors, warnings };
538
+ return done();
539
+ }
540
+
541
+ /**
542
+ * Every component authored in `root`: a runtime file under
543
+ * src/system/components with an editor beside it or in the package's editor
544
+ * directory. What `check-component` runs over when no id is named.
545
+ */
546
+ export function discoverComponents(root = process.cwd()) {
547
+ const dir = join(root, 'src/system/components');
548
+ if (!existsSync(dir)) return [];
549
+ return readdirSync(dir)
550
+ .filter((f) => f.endsWith('.svelte') && !f.endsWith('Editor.svelte'))
551
+ .map((f) => f.replace('.svelte', ''))
552
+ .filter((Id) => EDITOR_DIRS.some((d) => existsSync(join(root, d, `${Id}Editor.svelte`))))
553
+ .map((Id) => Id.toLowerCase());
275
554
  }
276
555
 
277
556
  export function formatReport(id, result) {
@@ -290,3 +569,28 @@ export function formatReport(id, result) {
290
569
  }
291
570
  return lines.join('\n');
292
571
  }
572
+
573
+ /**
574
+ * The half of the contract that holds for every component, shipped or authored:
575
+ * each `:global(:root)` default is a semantic property backed by a real token.
576
+ *
577
+ * Takes a runtime file rather than an id, so it works on the shipped naming
578
+ * (`SectionDivider.svelte`) that an id round-trip would flatten, and it skips
579
+ * the consumer-only rules — registration and file layout — that shipped
580
+ * components satisfy through the package's own registry instead.
581
+ */
582
+ export function checkComponentDefaults(runtimePath, { root = process.cwd(), vocabulary } = {}) {
583
+ const findings = [];
584
+ const runtime = readFileSync(runtimePath, 'utf8');
585
+ const rel = relative(root, runtimePath);
586
+ const record = (rule, message, index = -1) =>
587
+ findings.push({ rule, file: rel, line: index >= 0 ? lineOf(runtime, index) : 1, message });
588
+
589
+ const name = runtimePath.slice(runtimePath.lastIndexOf('/') + 1).replace('.svelte', '');
590
+ const editorPath = EDITOR_DIRS.map((d) => join(root, d, `${name}Editor.svelte`)).find(existsSync);
591
+ const editor = editorPath ? readFileSync(editorPath, 'utf8') : '';
592
+
593
+ const blocks = extractGlobalRootBlocks(runtime);
594
+ checkDefaultsAreSemantic({ blocks, runtime, editor, root, runtimePath, record, vocabulary });
595
+ return findings;
596
+ }