@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,152 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PKG_ROOT, declaredCustomProperties, extractGlobalRootBlocks } from './tokenVocabulary.mjs';
4
+
5
+ // Property suffixes come from the editor's own kind table, so the checker and
6
+ // the picker can never disagree about what a name means. Read as text rather
7
+ // than imported: this module must load without the compiled engine (CI runs the
8
+ // suite before the plugin is built).
9
+ const ALIAS_KINDS = 'src/editor/core/components/aliasKinds.ts';
10
+
11
+ /** Each kind with the suffixes that name it, in the file's own order, since
12
+ * the first match wins there too. */
13
+ export function readKindRules(root) {
14
+ for (const base of [root, PKG_ROOT]) {
15
+ const path = join(base, ALIAS_KINDS);
16
+ if (!existsSync(path)) continue;
17
+ const src = readFileSync(path, 'utf8');
18
+ const block = src.match(/KIND_RULES[^=]*=\s*\[([\s\S]*?)\n\];/);
19
+ if (!block) continue;
20
+ const out = [];
21
+ for (const m of block[1].matchAll(/\{\s*kind:\s*'([a-z-]+)',\s*suffix:\s*\[([\s\S]*?)\]/g)) {
22
+ out.push({ kind: m[1], suffixes: [...m[2].matchAll(/'-([a-z0-9-]+)'/g)].map((n) => n[1]) });
23
+ }
24
+ if (out.length > 0) return out;
25
+ }
26
+ return [];
27
+ }
28
+
29
+ export function readKnownSuffixes(root) {
30
+ return [...new Set(readKindRules(root).flatMap((r) => r.suffixes))];
31
+ }
32
+
33
+ /**
34
+ * The token scale a property draws its value from, by way of the editor's own
35
+ * kind for it. A kind with no scale behind it — a length, a duration, a font
36
+ * axis — has none, so a literal there has no candidate to offer.
37
+ */
38
+ const KIND_SCALE = {
39
+ 'text-color': 'text',
40
+ surface: 'surface',
41
+ border: 'border',
42
+ radius: 'radius',
43
+ padding: 'space',
44
+ gap: 'space',
45
+ 'hairline-inset': 'space',
46
+ 'border-width': 'border-width',
47
+ 'hairline-width': 'border-width',
48
+ 'indicator-width': 'border-width',
49
+ shadow: 'shadow',
50
+ };
51
+
52
+ export const COLOR_SCALES = ['text', 'surface', 'border'];
53
+ export const GEOMETRY_SCALES = ['space', 'radius', 'border-width', 'shadow'];
54
+
55
+ export function tokenScale(token, kindRules, wanted) {
56
+ const bare = SIDE_SUFFIXES.find((x) => token.endsWith(x)) ? token.slice(0, token.lastIndexOf('-')) : token;
57
+ const rule = kindRules.find((r) => r.suffixes.some((s) => bare.endsWith(`-${s}`)));
58
+ const scale = rule ? (KIND_SCALE[rule.kind] ?? null) : null;
59
+ return wanted.includes(scale) ? scale : null;
60
+ }
61
+
62
+ // Per-side padding names (`--card-body-padding-top`) are written by the padding
63
+ // selector, never declared by hand, and belong with their parent.
64
+ export const SIDE_SUFFIXES = ['-top', '-right', '-bottom', '-left'];
65
+
66
+ // State tokens that must come *before* the property, never after.
67
+ export const STATE_TOKENS = ['hover', 'disabled', 'selected', 'focus', 'active', 'focused'];
68
+
69
+ export function tokenSuffix(token, known) {
70
+ const bare = SIDE_SUFFIXES.find((x) => token.endsWith(x)) ? token.slice(0, token.lastIndexOf('-')) : token;
71
+ for (const suffix of known) {
72
+ if (bare.endsWith(`-${suffix}`)) return suffix;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Token patterns the editor declares in `intrinsics` — the only tokens allowed a
79
+ * bare keyword instead of a theme token.
80
+ *
81
+ * Matched on each spec's `variable`, not its `key`: the two need not agree
82
+ * (Image's `zoom` key declares `--image-zoom-enabled`), and `variable` is what
83
+ * actually names the token. A `${...}` hole stands for a variant segment.
84
+ */
85
+ export function intrinsicMatchers(editor) {
86
+ const block = editor.match(/export\s+const\s+intrinsics[^=]*=\s*\[([\s\S]*?)\];/);
87
+ if (!block) return [];
88
+ const out = [];
89
+ for (const m of block[1].matchAll(/\bvariable\s*:[^`'"]*[`'"]([^`'"]+)[`'"]/g)) {
90
+ const pattern = m[1]
91
+ .replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === '$' ? '$' : `\\${c}`))
92
+ .replace(/\$\\\{[^}]*\\\}/g, '[a-z0-9-]+')
93
+ .replace(/\$\{[^}]*\}/g, '[a-z0-9-]+');
94
+ out.push(new RegExp(`^${pattern}$`));
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Every custom property the `:global(:root)` blocks declare, comments ignored. */
100
+ export function declaredTokens(blocks) {
101
+ const declared = new Set();
102
+ for (const block of blocks) {
103
+ for (const n of declaredCustomProperties(block.replace(/\/\*[\s\S]*?\*\//g, ' '))) declared.add(n);
104
+ }
105
+ return declared;
106
+ }
107
+
108
+ /**
109
+ * Properties a component declares that nothing in its own file reads. A read is
110
+ * the name appearing outside the `:global(:root)` block: in a `var()`, in a
111
+ * `style:` directive, or as the string a padding mixin takes. SCSS interpolation
112
+ * (`--badge-#{$v}-surface`) reads every property the pattern covers. A per-side
113
+ * padding is read through its parent.
114
+ */
115
+ export function unreadTokens(source, tokens) {
116
+ let body = source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/<!--[\s\S]*?-->/g, ' ');
117
+ for (const block of extractGlobalRootBlocks(body)) body = body.replace(block, ' ');
118
+ const patterns = [...body.matchAll(/--[a-z0-9-]*(?:#\{[^}]*\}[a-z0-9-]*)+/g)].map(
119
+ (m) => new RegExp(`^${m[0].replace(/[.*+?^()|[\]\\]/g, '\\$&').replace(/#\{[^}]*\}/g, '[a-z0-9-]+')}$`),
120
+ );
121
+ const isRead = (name) => body.includes(name) || patterns.some((re) => re.test(name));
122
+ return [...tokens].filter((name) => {
123
+ const side = SIDE_SUFFIXES.find((s) => name.endsWith(s));
124
+ return !isRead(name) && !(side && isRead(name.slice(0, -side.length)));
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Every token the editor names in a row: `variable: '--x'`, the type-group
130
+ * `colorVariable` / `familyVariable` / ... keys, and the arrow form intrinsics
131
+ * use. A `${...}` hole stands for a variant segment. Per-side padding names are
132
+ * written by the padding selector rather than declared, so they resolve to
133
+ * their parent.
134
+ */
135
+ export function editorTokenRefs(editor) {
136
+ const literals = new Set();
137
+ const patterns = [];
138
+ for (const m of editor.matchAll(/\b(?:variable|[a-zA-Z]+Variable)\s*:\s*(?:\([^)]*\)\s*=>\s*)?[`'"](--(?:\$\{[^}]*\}|[^`'"])+)[`'"]/g)) {
139
+ const name = stripSide(m[1]);
140
+ if (name.includes('${')) {
141
+ patterns.push([name, new RegExp(`^${name.replace(/[.*+?^()|[\]\\]/g, '\\$&').replace(/\$\{[^}]*\}/g, '[a-z0-9-]+')}$`)]);
142
+ } else {
143
+ literals.add(name);
144
+ }
145
+ }
146
+ return { literals, patterns };
147
+ }
148
+
149
+ function stripSide(token) {
150
+ const side = SIDE_SUFFIXES.find((x) => token.endsWith(x));
151
+ return side ? token.slice(0, -side.length) : token;
152
+ }
@@ -48,3 +48,12 @@ export function stripVarFallbacks(value) {
48
48
  export function blankStrings(css) {
49
49
  return css.replace(/(["'])(?:\\.|(?!\1)[^\\])*\1/g, (m) => m[0] + ' '.repeat(m.length - 2) + m[0]);
50
50
  }
51
+
52
+ /** The token scale a CSS property draws its colour from. The role inside that
53
+ * scale stays the user's choice. */
54
+ export function colorScaleOfProperty(prop) {
55
+ if (prop === 'color') return 'text';
56
+ if (/^background/.test(prop)) return 'surface';
57
+ if (/^(border|outline)(-|$)/.test(prop)) return 'border';
58
+ return null;
59
+ }
@@ -0,0 +1,126 @@
1
+ // Resolves the project's live-tokens data directory and its optional
2
+ // `live-tokens.testing.ts` settings file, statically — no engine import, no
3
+ // dynamic import of the consumer's own config. Shared by `contractRunner.mjs`
4
+ // (which isolates a copy of this directory before spawning the test tools)
5
+ // and the `config-token` rule in `bin/rules/tokens.mjs` (which reads
6
+ // `component-configs/<id>/default.json` under it directly), so the two agree
7
+ // on which tree a project's saved assignments live in.
8
+
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join, resolve } from 'node:path';
11
+
12
+ export function settingsFilePath(root) {
13
+ for (const name of ['live-tokens.testing.ts', 'live-tokens.testing.mts', 'live-tokens.testing.js', 'live-tokens.testing.mjs']) {
14
+ const path = join(root, name);
15
+ if (existsSync(path)) return path;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ /**
21
+ * A generated config's own extensionless relative imports (Vite/Playwright's
22
+ * loaders resolve those, matching how this repo's own `vite.config.ts` and
23
+ * `live-tokens.testing.ts` are written) only get that treatment for a
24
+ * *static* import. A dynamic `import()` of the same path, called after a Vite
25
+ * config finishes loading, runs through plain Node resolution instead and
26
+ * fails on the same files: verified against `vitest.contract.config.ts` with
27
+ * both a `file://` URL and a plain absolute path as the dynamic specifier,
28
+ * identical `ERR_MODULE_NOT_FOUND` both times. So neither `settings.viteConfig`
29
+ * nor `settings.dataDir` (the resolved values `resolveTestingConfig` computes)
30
+ * has a reader here on purpose — reading either would mean importing the
31
+ * settings file dynamically first, which reintroduces exactly this failure.
32
+ * Regexing the settings file's *source text* for a field, imprecise as that
33
+ * is, is what stays inside the static-import constraint: a wrong guess still
34
+ * fails loudly (a bad `viteConfig` throws on its own static import in the
35
+ * generated file; a bad `dataDir` throws "no data directory at ..." below),
36
+ * never as a silent pass. A settings-level `dataDir` this cannot see at all
37
+ * is worse than one resolved this imprecisely, since a project that names its
38
+ * data directory only in `live-tokens.testing.ts` would otherwise have its
39
+ * contracts checked against whatever happens to sit at the default path.
40
+ */
41
+ function stripComments(text) {
42
+ // Block comments first: a `//` inside one (`/* // note */`) must not seed a
43
+ // second, overlapping strip.
44
+ return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
45
+ }
46
+
47
+ /** Measured across eleven settings-file shapes: a commented-out `dataDir:`
48
+ * above the real one won the regex, because the regex only sees text
49
+ * position, never comment syntax, and a comment naming the field with
50
+ * nothing else present invented a setting out of prose. Comments are
51
+ * stripped before the field ever gets a chance to match. A field present in
52
+ * live code as a template literal, a computed value, or an import throws
53
+ * instead of falling through to a default silently: the two guesses this
54
+ * feeds, `viteConfig` and `dataDir`, can each resolve to a real path that
55
+ * simply names the wrong tree, which then reads as a clean run. */
56
+ export function scrapeSettingsField(settingsPath, fieldName) {
57
+ if (!settingsPath) return null;
58
+ let text;
59
+ try {
60
+ text = readFileSync(settingsPath, 'utf8');
61
+ } catch {
62
+ return null;
63
+ }
64
+ const live = stripComments(text);
65
+ const literal = new RegExp(`\\b${fieldName}\\s*:\\s*['"]([^'"]+)['"]`).exec(live);
66
+ if (literal) return literal[1];
67
+ if (new RegExp(`\\b${fieldName}\\s*:`).test(live)) {
68
+ throw new Error(
69
+ `"${fieldName}" in ${settingsPath} is set to something other than a plain string literal, ` +
70
+ `so it cannot be read statically. Use a literal string, or remove the key to fall back to the default.`,
71
+ );
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * `dataDir` as the plugin resolves it: a `dataDir` field scraped from the
78
+ * settings file (see above), else `live-tokens.config.json`'s own key, else
79
+ * the default. Mirrors `resolveTestingConfig`'s own `configuredDataDir`
80
+ * fallback (`src/testing/config.ts`) rather than importing it: that module
81
+ * compiles into `src/testing-js`, which does not exist until `build:testing`
82
+ * runs, and importing the *source* `.ts` module to reach it hits the same
83
+ * extensionless-import failure documented above — measured with
84
+ * `src/testing-js` moved aside, `resolveTestingEntry`'s `.ts` fallback threw
85
+ * exactly that trying to load `vitest.ts`. Duplicating this small a resolver
86
+ * has precedent in this module already, in `settingsFilePath`.
87
+ */
88
+ export function resolveSourceDataDir(root, settingsPath) {
89
+ const scraped = scrapeSettingsField(settingsPath, 'dataDir');
90
+ if (scraped) return resolve(root, scraped);
91
+ try {
92
+ const parsed = JSON.parse(readFileSync(join(root, 'live-tokens.config.json'), 'utf8'));
93
+ if (parsed && typeof parsed === 'object' && typeof parsed.dataDir === 'string') {
94
+ return resolve(root, parsed.dataDir);
95
+ }
96
+ } catch {
97
+ // Missing or unparseable reads as absent, matching the plugin's own resolver.
98
+ }
99
+ return resolve(root, 'src/live-tokens/data');
100
+ }
101
+
102
+ /**
103
+ * `component-configs/<id>/default.json` under the source data directory, as
104
+ * `{ path, text, data }`, or null when there is none to read. A non-literal
105
+ * `dataDir` in `live-tokens.testing.ts` (a template literal, a computed value)
106
+ * is only resolvable at `--tests` time, when the settings module itself runs;
107
+ * `resolveSourceDataDir` throws rather than guess. A plain-Node rule has no
108
+ * settings module to run, so it reads that as no config instead of taking the
109
+ * whole static lint down with it. Unparseable JSON reads as no config too.
110
+ */
111
+ export function readComponentConfig(root, id) {
112
+ let dataDir;
113
+ try {
114
+ dataDir = resolveSourceDataDir(root, settingsFilePath(root));
115
+ } catch {
116
+ return null;
117
+ }
118
+ const path = join(dataDir, 'component-configs', id, 'default.json');
119
+ if (!existsSync(path)) return null;
120
+ const text = readFileSync(path, 'utf8');
121
+ try {
122
+ return { path, text, data: JSON.parse(text) };
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
@@ -20,6 +20,15 @@ export function lineOf(text, index) {
20
20
  return line;
21
21
  }
22
22
 
23
+ /** The line a `"<key>":` sits on. The quote on both sides of the key makes
24
+ * this exact by construction — `"--card-default-body"` cannot match inside
25
+ * `"--card-default-body-padding"`, unlike a bare substring search. */
26
+ export function findJsonKeyLine(text, key) {
27
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
+ const m = new RegExp(`"${escaped}"\\s*:`).exec(text);
29
+ return m ? lineOf(text, m.index) : 1;
30
+ }
31
+
23
32
  export function readChecksConfig(root) {
24
33
  const path = join(root, 'live-tokens.config.json');
25
34
  if (!existsSync(path)) return {};
@@ -52,7 +61,7 @@ export function isExcluded(relPath, root) {
52
61
  * Unrecognised flags are returned in `rest` for the caller to handle.
53
62
  */
54
63
  export function parseCheckFlags(argv) {
55
- const opts = { off: [], warn: [], error: [], strict: false, json: false, tests: false, rest: [] };
64
+ const opts = { off: [], warn: [], error: [], strict: false, json: false, tests: false, noFix: false, rest: [] };
56
65
  for (const arg of argv) {
57
66
  const m = arg.match(/^--(off|warn|error)=(.+)$/);
58
67
  if (m) {
@@ -60,17 +69,43 @@ export function parseCheckFlags(argv) {
60
69
  } else if (arg === '--strict') opts.strict = true;
61
70
  else if (arg === '--json') opts.json = true;
62
71
  else if (arg === '--tests') opts.tests = true;
72
+ else if (arg === '--no-fix') opts.noFix = true;
63
73
  else opts.rest.push(arg);
64
74
  }
65
75
  return opts;
66
76
  }
67
77
 
78
+ /**
79
+ * One checker's rule table, keyed in `order`, from the modules under
80
+ * `bin/rules/` that define its rules. An id on one side and not the other
81
+ * throws when the checker loads.
82
+ */
83
+ export function assembleRules(order, ...tables) {
84
+ const defined = Object.assign({}, ...tables);
85
+ const stray = [...order.filter((id) => !defined[id]), ...Object.keys(defined).filter((id) => !order.includes(id))];
86
+ if (stray.length > 0) throw new Error(`rule order and rule modules disagree on: ${stray.join(', ')}`);
87
+ return Object.fromEntries(order.map((id) => [id, defined[id]]));
88
+ }
89
+
90
+ /**
91
+ * The config entry that records a deliberate decision to keep a finding.
92
+ *
93
+ * A page or CSS file drops out of discovery, which is narrower than turning a
94
+ * rule off across the project. Everything else steps its rule down one notch,
95
+ * since a rule already resolved to `warn` is not silenced by `warn`.
96
+ */
97
+ function exceptionFor(finding, severity, exclude) {
98
+ return exclude
99
+ ? { checks: { exclude: [finding.file] } }
100
+ : { checks: { rules: { [finding.rule]: severity === 'error' ? 'warn' : 'off' } } };
101
+ }
102
+
68
103
  /** The same last-wins resolution `applySeverity` applies per finding, exposed
69
104
  * standalone so coverage (which has no findings to attach a severity to, but
70
105
  * still has to honor `--off`) can ask the same question. */
71
106
  export function resolveRuleSeverity(ruleId, rules, opts = {}, config = {}) {
72
107
  const configured = config.rules ?? {};
73
- let severity = rules[ruleId] ?? 'error';
108
+ let severity = rules[ruleId]?.severity ?? 'error';
74
109
  if (SEVERITIES.includes(configured[ruleId])) severity = configured[ruleId];
75
110
  if (opts.off?.includes(ruleId)) severity = 'off';
76
111
  if (opts.warn?.includes(ruleId)) severity = 'warn';
@@ -80,16 +115,28 @@ export function resolveRuleSeverity(ruleId, rules, opts = {}, config = {}) {
80
115
  }
81
116
 
82
117
  /**
83
- * Resolve each finding's severity and drop the ones turned off.
84
- * `rules` maps rule id to its default severity.
118
+ * Resolve each finding's severity and drop the ones turned off, then attach
119
+ * what a repair needs: how to make it (`guidance`), how far code can take it
120
+ * (`repair`), and the config entry that records a decision to keep it
121
+ * (`exception`).
122
+ *
123
+ * `rules` maps rule id to `{ severity, repair, guidance }`. A rule's `repair`
124
+ * is the ceiling: a finding that arrives carrying its own has already lowered
125
+ * it, because its context is more ambiguous than the rule's.
85
126
  */
86
- export function applySeverity(findings, rules, opts = {}, config = {}, fixes = {}) {
127
+ export function applySeverity(findings, rules, opts = {}, config = {}, { exclude = false } = {}) {
87
128
  return findings
88
- .map((f) => ({
89
- ...f,
90
- severity: resolveRuleSeverity(f.rule, rules, opts, config),
91
- ...(fixes[f.rule] ? { fix: fixes[f.rule] } : {}),
92
- }))
129
+ .map((f) => {
130
+ const severity = resolveRuleSeverity(f.rule, rules, opts, config);
131
+ const rule = rules[f.rule];
132
+ return {
133
+ ...f,
134
+ severity,
135
+ ...(rule?.guidance ? { guidance: rule.guidance } : {}),
136
+ repair: f.repair ?? rule?.repair ?? 'authored',
137
+ exception: exceptionFor(f, severity, exclude),
138
+ };
139
+ })
93
140
  .filter((f) => f.severity !== 'off');
94
141
  }
95
142
 
@@ -144,11 +191,55 @@ export function formatFindings(findings, { label, checked = 0 } = {}) {
144
191
  return lines.join('\n');
145
192
  }
146
193
 
147
- export function toJson(findings, { label, checked = 0, coverage } = {}) {
194
+ export function toJson(findings, { label, checked = 0, coverage, fix } = {}) {
148
195
  const { errors, warnings } = countBySeverity(findings);
149
196
  return JSON.stringify(
150
- { check: label, checked, errors, warnings, findings, ...(coverage ? { coverage } : {}) },
197
+ { check: label, checked, errors, warnings, ...(fix ? { fix } : {}), findings, ...(coverage ? { coverage } : {}) },
151
198
  null,
152
199
  2,
153
200
  );
154
201
  }
202
+
203
+ /** The unresolved property names a `contract-alias` finding lists, read off the
204
+ * first line: a Playwright or Vitest stack follows on the lines after it.
205
+ * `assertAliasesResolve` is the only obligation that lists them; every other
206
+ * `contract-alias` message (an empty shipped config, say) misses and is left
207
+ * alone. */
208
+ const UNRESOLVED_ALIASES = /aliases resolve to nothing at the root: (.+)/;
209
+
210
+ /** `component-configs/<id>/default.json`, the file both rules name. */
211
+ const CONFIG_FILE = /component-configs[/\\]([^/\\]+)[/\\]default\.json$/;
212
+
213
+ /**
214
+ * One broken alias, one finding.
215
+ *
216
+ * A `default.json` alias naming something the vocabulary lacks is a
217
+ * `config-token` finding from plain Node, and the same string makes the
218
+ * property resolve to nothing at the root, which the browser reports as
219
+ * `contract-alias`. Under `--tests` both run, so the merge drops the browser's
220
+ * copy when every name it lists already carries a `config-token` finding for
221
+ * the same component. A `contract-alias` naming a token the vocabulary knows,
222
+ * declared, spelled correctly, and still unresolved, is a different defect and
223
+ * stays.
224
+ */
225
+ export function dedupeAliasFindings(findings) {
226
+ const broken = new Map();
227
+ for (const f of findings) {
228
+ if (f.rule !== 'config-token') continue;
229
+ const id = CONFIG_FILE.exec(f.file ?? '')?.[1];
230
+ const property = f.details?.property;
231
+ if (!id || !property) continue;
232
+ if (!broken.has(id)) broken.set(id, new Set());
233
+ broken.get(id).add(property);
234
+ }
235
+ if (broken.size === 0) return findings;
236
+ return findings.filter((f) => {
237
+ if (f.rule !== 'contract-alias') return true;
238
+ const id = CONFIG_FILE.exec(f.file ?? '')?.[1];
239
+ const known = id ? broken.get(id) : undefined;
240
+ if (!known) return true;
241
+ const listed = UNRESOLVED_ALIASES.exec(String(f.message ?? '').split('\n')[0])?.[1];
242
+ if (!listed) return true;
243
+ return !listed.split(',').map((name) => name.trim()).filter(Boolean).every((name) => known.has(name));
244
+ });
245
+ }
@@ -0,0 +1,64 @@
1
+ // Applies the patches an `auto` finding already computed in its own
2
+ // `details.patch`. This module owns none of the per-rule repair logic — that
3
+ // lives beside each rule, in bin/rules/, pageSource.mjs, geometry.mjs, and
4
+ // catalogue.mjs, since only the rule that found the fault knows its shape.
5
+ // This module only knows how to write one down safely.
6
+
7
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ /** The character offset where 1-based `line` begins. The inverse of
11
+ * `lineOf` in findings.mjs. */
12
+ function offsetOfLine(text, line) {
13
+ let n = 1;
14
+ for (let i = 0; i < text.length; i++) {
15
+ if (n === line) return i;
16
+ if (text[i] === '\n') n++;
17
+ }
18
+ return text.length;
19
+ }
20
+
21
+ /**
22
+ * Writes every `auto` finding's patch, one file at a time.
23
+ *
24
+ * A patch applies at the first occurrence of `from` at or after the finding's
25
+ * own line: the file has moved since the finding was computed, or another
26
+ * patch in the same file shifted text, so a patch whose `from` cannot be
27
+ * found there is not applied and comes back in `skipped` instead of guessing
28
+ * at a different occurrence. Findings within one file apply in line order;
29
+ * none of the four fixers inserts or removes a newline, so an earlier
30
+ * patch's line never moves a later one's.
31
+ */
32
+ export function applyFixes(findings, root) {
33
+ const byFile = new Map();
34
+ for (const f of findings) {
35
+ if (f.repair !== 'auto' || !f.details?.patch) continue;
36
+ if (!byFile.has(f.file)) byFile.set(f.file, []);
37
+ byFile.get(f.file).push(f);
38
+ }
39
+
40
+ const applied = [];
41
+ const skipped = [];
42
+ for (const [file, list] of byFile) {
43
+ const path = join(root, file);
44
+ if (!existsSync(path)) {
45
+ skipped.push(...list);
46
+ continue;
47
+ }
48
+ let text = readFileSync(path, 'utf8');
49
+ let changed = false;
50
+ for (const finding of list.sort((a, b) => a.line - b.line)) {
51
+ const { from, to } = finding.details.patch;
52
+ const at = text.indexOf(from, offsetOfLine(text, finding.line));
53
+ if (at === -1) {
54
+ skipped.push(finding);
55
+ continue;
56
+ }
57
+ text = text.slice(0, at) + to + text.slice(at + from.length);
58
+ changed = true;
59
+ applied.push(finding);
60
+ }
61
+ if (changed) writeFileSync(path, text);
62
+ }
63
+ return { applied, skipped };
64
+ }
@@ -0,0 +1,92 @@
1
+ // Which token a raw dimension should have been, computed rather than guessed.
2
+ //
3
+ // `dimension-literal` fires on a declaration the checkers already flagged; this
4
+ // module answers the follow-up a repair needs: what the literal measures, which
5
+ // step of its scale sits nearest, and whether that step is unique. A tie is the
6
+ // one case a person has to settle, so it is reported rather than resolved.
7
+
8
+ import { stripVarFallbacks } from './cssValues.mjs';
9
+
10
+ const REM_PX = 16;
11
+
12
+ // The same literals `hasDimensionLiteral` flags: px and rem, no leading sign.
13
+ const LITERAL = /(?<![\w.-])(\d*\.?\d+)(px|rem)\b/g;
14
+
15
+ const round = (n) => Math.round(n * 1000) / 1000;
16
+
17
+ /**
18
+ * A token's value in pixels, or null when it is not a single length. A shadow
19
+ * step (`1px 1px 2px hsla(...)`) and `--space-full: 100%` both measure nothing
20
+ * a literal can be matched against, so neither becomes a candidate.
21
+ */
22
+ function pixelsOf(value) {
23
+ const m = /^\s*(\d*\.?\d+)(px|rem)?\s*$/.exec(value);
24
+ if (!m) return null;
25
+ const n = parseFloat(m[1]);
26
+ if (!m[2]) return n === 0 ? 0 : null;
27
+ return m[2] === 'rem' ? n * REM_PX : n;
28
+ }
29
+
30
+ /** The token scale a CSS property draws its lengths from, for check-page. */
31
+ export function geometryScaleOfProperty(prop) {
32
+ if (/-radius$/.test(prop)) return 'radius';
33
+ if (/shadow$/.test(prop)) return 'shadow';
34
+ if (/^(border|outline)(-|$)/.test(prop)) return 'border-width';
35
+ if (/^(padding|margin|gap|row-gap|column-gap|inset|top|right|bottom|left)(-|$)/.test(prop)) return 'space';
36
+ return null;
37
+ }
38
+
39
+ /** Each literal in `text` (fallback-stripped, the same text `from` names)
40
+ * replaced by its one candidate's token. Only called once every literal is
41
+ * unique, so `candidates[0]` is never a guess. A zero-valued match has no
42
+ * entry in `literals` (the census skips it too), so it must be recognised
43
+ * here and left untouched rather than consuming the next entry.
44
+ */
45
+ function rewriteLiterals(text, literals) {
46
+ let i = 0;
47
+ return text.replace(LITERAL, (match, num, unit) => {
48
+ const px = round(parseFloat(num) * (unit === 'rem' ? REM_PX : 1));
49
+ if (px === 0) return match;
50
+ return `var(${literals[i++].candidates[0].token})`;
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Every literal in a declaration, each with the nearest step of `scale`.
56
+ *
57
+ * A term inside `calc()` or a shorthand is measured on its own, so
58
+ * `padding: 8px 16px` reports two literals and `calc(100% - 20px)` reports the
59
+ * 20px. `auto` holds only when every one of them lands on a single step and
60
+ * the declaration carries no var() fallback: a tie, a scale with no
61
+ * comparable steps, or a fallback beside the literal leaves the choice open.
62
+ */
63
+ export function resolveGeometryLiteral(value, scale, tokens = []) {
64
+ const steps = [];
65
+ for (const token of tokens) {
66
+ const px = pixelsOf(token.value ?? '');
67
+ if (px !== null) steps.push({ token: token.name, px });
68
+ }
69
+
70
+ const stripped = stripVarFallbacks(value);
71
+ const literals = [];
72
+ for (const m of stripped.matchAll(LITERAL)) {
73
+ const px = round(parseFloat(m[1]) * (m[2] === 'rem' ? REM_PX : 1));
74
+ if (px === 0) continue;
75
+ const nearest = steps.reduce((best, s) => Math.min(best, Math.abs(s.px - px)), Infinity);
76
+ const candidates = steps
77
+ .filter((s) => Math.abs(Math.abs(s.px - px) - nearest) < 1e-6)
78
+ .map((s) => ({ token: s.token, px: s.px, shift: round(s.px - px) }));
79
+ literals.push({ value: m[0], px, candidates });
80
+ }
81
+
82
+ // A var() fallback beside a flagged literal makes `stripped` text the source
83
+ // never contains, so no patch could find its site; the choice stays open.
84
+ const auto =
85
+ stripped === value && literals.length > 0 && literals.every((l) => l.candidates.length === 1);
86
+ return {
87
+ scale,
88
+ literals,
89
+ auto,
90
+ patch: auto ? { from: stripped, to: rewriteLiterals(stripped, literals) } : null,
91
+ };
92
+ }