@motion-proto/live-tokens 0.77.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 (177) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +60 -29
  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 +9 -6
  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 +284 -0
  13. package/README.md +9 -15
  14. package/bin/check-component.mjs +152 -551
  15. package/bin/check-page.mjs +56 -420
  16. package/bin/cli.mjs +83 -15
  17. package/bin/contractRunner.mjs +380 -120
  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/pageRoutes.mjs +309 -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/rules/componentStructure.mjs +344 -0
  30. package/bin/rules/componentUse.mjs +313 -0
  31. package/bin/rules/importsAndRoutes.mjs +136 -0
  32. package/bin/rules/testRuns.mjs +122 -0
  33. package/bin/rules/tokens.mjs +363 -0
  34. package/bin/setup-claude.mjs +1 -2
  35. package/dist-plugin/{chunk-PDNL4NC5.js → chunk-D4WRIKEZ.js} +7 -2
  36. package/dist-plugin/{chunk-SWXRVZKT.js → chunk-REBHE3ZM.js} +414 -1
  37. package/dist-plugin/index.cjs +432 -15
  38. package/dist-plugin/index.js +8 -9
  39. package/dist-plugin/migrateData/index.cjs +422 -4
  40. package/dist-plugin/migrateData/index.js +2 -2
  41. package/dist-plugin/setColors/index.cjs +414 -1
  42. package/dist-plugin/setColors/index.d.cts +1 -1
  43. package/dist-plugin/setColors/index.d.ts +1 -1
  44. package/dist-plugin/setColors/index.js +1 -1
  45. package/dist-plugin/setGeometry/index.cjs +423 -13
  46. package/dist-plugin/setGeometry/index.d.cts +3 -3
  47. package/dist-plugin/setGeometry/index.d.ts +3 -3
  48. package/dist-plugin/setGeometry/index.js +10 -13
  49. package/dist-plugin/setType/index.d.cts +1 -1
  50. package/dist-plugin/setType/index.d.ts +1 -1
  51. package/dist-plugin/{themeTypes-BxRtuN5V.d.cts → themeTypes-B8_Idrp4.d.cts} +2 -2
  52. package/dist-plugin/{themeTypes-BxRtuN5V.d.ts → themeTypes-B8_Idrp4.d.ts} +2 -2
  53. package/package.json +6 -4
  54. package/src/editor/component-editor/CalloutEditor.svelte +2 -2
  55. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +14 -15
  56. package/src/editor/component-editor/CornerBadgeEditor.svelte +14 -14
  57. package/src/editor/component-editor/DialogEditor.svelte +5 -5
  58. package/src/editor/component-editor/InlineEditActionsEditor.svelte +2 -2
  59. package/src/editor/component-editor/RadioButtonEditor.svelte +21 -21
  60. package/src/editor/component-editor/SectionDividerEditor.svelte +7 -7
  61. package/src/editor/component-editor/SegmentedControlEditor.svelte +5 -5
  62. package/src/editor/component-editor/SideNavigationEditor.svelte +25 -25
  63. package/src/editor/component-editor/TabBarEditor.svelte +6 -6
  64. package/src/editor/component-editor/TableEditor.svelte +6 -6
  65. package/src/editor/component-editor/ToggleEditor.svelte +2 -2
  66. package/src/editor/component-editor/index.ts +3 -0
  67. package/src/editor/component-editor/registry.ts +57 -1
  68. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +14 -14
  69. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +3 -0
  70. package/src/editor/component-editor/scaffolding/types.ts +15 -0
  71. package/src/editor/core/components/adjustAliases.ts +4 -4
  72. package/src/editor/core/components/aliasKinds.ts +20 -19
  73. package/src/editor/core/sketch/sketchLayer.ts +3 -3
  74. package/src/editor/core/themes/migrateComponentConfig.ts +14 -6
  75. package/src/editor/core/themes/migrations/2026-09-13-badge-brand.ts +42 -0
  76. package/src/editor/core/themes/migrations/2026-09-13-collapsiblesection-open.ts +33 -0
  77. package/src/editor/core/themes/migrations/2026-09-13-cornerbadge-prefix.ts +70 -0
  78. package/src/editor/core/themes/migrations/2026-09-13-hairline.ts +92 -0
  79. package/src/editor/core/themes/migrations/2026-09-13-indicator.ts +54 -0
  80. package/src/editor/core/themes/migrations/2026-09-13-sectiondivider-surface.ts +36 -0
  81. package/src/editor/core/themes/migrations/2026-09-13-selected-state.ts +124 -0
  82. package/src/editor/core/themes/migrations/2026-09-13-toggle-label.ts +31 -0
  83. package/src/editor/core/themes/migrations/index.ts +16 -0
  84. package/src/editor/core/themes/themeService.ts +3 -3
  85. package/src/editor/core/themes/themeTypes.ts +13 -15
  86. package/src/editor/docs/Docs.svelte +1 -1
  87. package/src/editor/docs/content/light-and-dark.md +3 -3
  88. package/src/editor/docs/content.generated.ts +1 -1
  89. package/src/editor/index.ts +1 -0
  90. package/src/editor/overlay/ColumnsOverlay.svelte +1 -1
  91. package/src/editor/overlay/LiveEditorOverlay.svelte +16 -0
  92. package/src/editor/overlay/LiveTokensRouter.svelte +1 -0
  93. package/src/editor/skill-atlas/SkillAtlas.svelte +4 -4
  94. package/src/editor/skill-atlas/TreeNodeCard.svelte +4 -4
  95. package/src/editor/skill-atlas/skillSources.generated.ts +11 -14
  96. package/src/editor/skill-atlas/skillTrees.ts +1 -3
  97. package/src/editor/skill-atlas/trees/check-compliance.ts +258 -93
  98. package/src/editor/skill-atlas/trees/create-component.ts +73 -62
  99. package/src/editor/skill-atlas/trees/create-page.ts +54 -33
  100. package/src/editor/skill-atlas/trees/pick-component.ts +3 -3
  101. package/src/editor/skill-atlas/trees/set-geometry.ts +1 -1
  102. package/src/editor/ui/UIPaletteSelector.svelte +11 -15
  103. package/src/editor/ui/variantScales.ts +8 -8
  104. package/src/live-tokens/data/themes/autumn.json +188 -188
  105. package/src/live-tokens/data/themes/halloween.json +188 -188
  106. package/src/live-tokens/data/themes/midnight-study.json +245 -245
  107. package/src/live-tokens/data/themes/ocean.json +188 -188
  108. package/src/live-tokens/data/themes/royal-velvet.json +188 -188
  109. package/src/live-tokens/data/themes/sketchy.json +188 -188
  110. package/src/live-tokens/data/themes/spring-meadow.json +188 -188
  111. package/src/live-tokens/data/themes/sunset.json +188 -188
  112. package/src/system/components/Badge.svelte +27 -23
  113. package/src/system/components/Button.svelte +13 -7
  114. package/src/system/components/Callout.svelte +16 -11
  115. package/src/system/components/Card.svelte +22 -15
  116. package/src/system/components/CodeSnippet.svelte +10 -6
  117. package/src/system/components/CollapsibleSection.svelte +68 -63
  118. package/src/system/components/CornerBadge.svelte +78 -72
  119. package/src/system/components/Dialog.svelte +21 -18
  120. package/src/system/components/IconButton.svelte +13 -9
  121. package/src/system/components/Image.svelte +14 -8
  122. package/src/system/components/ImageLightbox.svelte +10 -6
  123. package/src/system/components/InlineEditActions.svelte +17 -14
  124. package/src/system/components/Input.svelte +13 -7
  125. package/src/system/components/MenuSelect.svelte +13 -7
  126. package/src/system/components/Notification.svelte +17 -11
  127. package/src/system/components/Panel.svelte +13 -6
  128. package/src/system/components/ProgressBar.svelte +10 -5
  129. package/src/system/components/RadioButton.svelte +33 -30
  130. package/src/system/components/SectionDivider.svelte +28 -21
  131. package/src/system/components/SegmentedControl.svelte +27 -23
  132. package/src/system/components/SideNavigation.svelte +175 -171
  133. package/src/system/components/Slider.svelte +11 -7
  134. package/src/system/components/TabBar.svelte +53 -49
  135. package/src/system/components/Table.svelte +20 -15
  136. package/src/system/components/Toggle.svelte +14 -10
  137. package/src/system/components/Tooltip.svelte +10 -6
  138. package/src/system/styles/CONVENTIONS.md +3 -4
  139. package/src/testing-js/chunk-3UKGXCDL.js +48 -0
  140. package/src/testing-js/chunk-3UKGXCDL.js.map +1 -0
  141. package/src/testing-js/{chunk-ZMSX6CXR.js → chunk-NB3NZRBM.js} +13 -2
  142. package/src/testing-js/chunk-NB3NZRBM.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-7TI7Z6Y6.js → chunk-U7OJE5DU.js} +19 -19
  146. package/src/testing-js/chunk-U7OJE5DU.js.map +1 -0
  147. package/src/testing-js/{chunk-L73N4NSO.js → chunk-WIZ6W7UT.js} +2 -2
  148. package/src/testing-js/{chunk-4JQX6WWL.js → chunk-ZMZQZ33J.js} +474 -166
  149. package/src/testing-js/chunk-ZMZQZ33J.js.map +1 -0
  150. package/src/testing-js/component-alias.contract.js +2 -2
  151. package/src/testing-js/component-behavior.contract.js +155 -0
  152. package/src/testing-js/component-behavior.contract.js.map +1 -0
  153. package/src/testing-js/component-editor.contract.js +7 -5
  154. package/src/testing-js/component-editor.contract.js.map +1 -1
  155. package/src/testing-js/component-render.contract.js +15 -11
  156. package/src/testing-js/component-render.contract.js.map +1 -1
  157. package/src/testing-js/index.d.ts +50 -4
  158. package/src/testing-js/index.js +32 -8
  159. package/src/testing-js/index.js.map +1 -1
  160. package/src/testing-js/page-compliance.contract.js +867 -0
  161. package/src/testing-js/page-compliance.contract.js.map +1 -0
  162. package/src/testing-js/registry.contract.js +5 -3
  163. package/src/testing-js/registry.contract.js.map +1 -1
  164. package/src/testing-js/{vitest-BE6uGF31.d.ts → vitest-BMLIbDs2.d.ts} +29 -3
  165. package/src/testing-js/vitest.d.ts +1 -1
  166. package/src/testing-js/vitest.js +3 -2
  167. package/template/README.md +16 -9
  168. package/template/package.json +2 -2
  169. package/template/src/pages/Home.svelte +13 -0
  170. package/.claude/skills/live-tokens-create-component/SKILL copy.md +0 -196
  171. package/.claude/skills/live-tokens-fix-findings/SKILL.md +0 -98
  172. package/src/editor/skill-atlas/trees/fix-findings.ts +0 -469
  173. package/src/testing-js/chunk-4JQX6WWL.js.map +0 -1
  174. package/src/testing-js/chunk-7TI7Z6Y6.js.map +0 -1
  175. package/src/testing-js/chunk-FAFOAWYL.js.map +0 -1
  176. package/src/testing-js/chunk-ZMSX6CXR.js.map +0 -1
  177. /package/src/testing-js/{chunk-L73N4NSO.js.map → chunk-WIZ6W7UT.js.map} +0 -0
@@ -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
+ }
@@ -0,0 +1,309 @@
1
+ // Page targets for `check-page --tests`: a page source file paired with the
2
+ // route that renders it.
3
+ //
4
+ // The route table is the consumer's own `pages` object, found by the file that
5
+ // imports `LiveTokensRouter`, and read statically the way `missing-source`
6
+ // already reads it. A route that object cannot express — one served by
7
+ // `resolve()` — is mapped by hand under `pageRoutes` in the testing settings.
8
+ // Parameter values are never guessed and the app entry point is never imported
9
+ // into Node.
10
+
11
+ import { existsSync, readFileSync, statSync } from 'node:fs';
12
+ import { join, relative, resolve } from 'node:path';
13
+ import { isExcluded } from './findings.mjs';
14
+ import { walk } from './tokenVocabulary.mjs';
15
+
16
+ /**
17
+ * Directories that hold the system rather than pages built on it. Mirrors
18
+ * `NOT_PAGES` in `check-page.mjs`; duplicated so the static checker can import
19
+ * this module without an import cycle, and pinned to it by
20
+ * `pageRoutes.test.ts`.
21
+ */
22
+ export const SYSTEM_DIRS = ['src/system', 'src/editor', 'src/lib', 'src/live-tokens'];
23
+
24
+ const SETTINGS_FILES = [
25
+ 'live-tokens.testing.ts',
26
+ 'live-tokens.testing.mts',
27
+ 'live-tokens.testing.js',
28
+ 'live-tokens.testing.mjs',
29
+ ];
30
+
31
+ export function settingsFilePath(root) {
32
+ for (const name of SETTINGS_FILES) {
33
+ const path = join(root, name);
34
+ if (existsSync(path)) return path;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** Block comments first: a `//` inside one must not seed a second, overlapping
40
+ * strip. Same reasoning as `contractRunner.mjs`'s own scraper. */
41
+ function stripComments(text) {
42
+ return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
43
+ }
44
+
45
+ function skipString(text, start) {
46
+ const quote = text[start];
47
+ for (let i = start + 1; i < text.length; i++) {
48
+ if (text[i] === '\\') { i++; continue; }
49
+ if (text[i] === quote) return i;
50
+ }
51
+ return text.length;
52
+ }
53
+
54
+ /** The text between the braces of the object literal opening at `open`. */
55
+ function objectBody(text, open) {
56
+ let depth = 0;
57
+ for (let i = open; i < text.length; i++) {
58
+ const ch = text[i];
59
+ if (ch === "'" || ch === '"' || ch === '`') { i = skipString(text, i); continue; }
60
+ if (ch === '{') depth++;
61
+ else if (ch === '}') {
62
+ depth--;
63
+ if (depth === 0) return text.slice(open + 1, i);
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** The text between the brackets of the array literal opening at `open`.
70
+ * Mirrors `objectBody`, bracket-keyed rather than brace-keyed. */
71
+ function arrayBody(text, open) {
72
+ let depth = 0;
73
+ for (let i = open; i < text.length; i++) {
74
+ const ch = text[i];
75
+ if (ch === "'" || ch === '"' || ch === '`') { i = skipString(text, i); continue; }
76
+ if (ch === '[') depth++;
77
+ else if (ch === ']') {
78
+ depth--;
79
+ if (depth === 0) return text.slice(open + 1, i);
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** Top-level comma-separated chunks of an object or array body, respecting
86
+ * strings and nested brackets. Shared by `objectEntries` (each chunk is
87
+ * `key: value`) and `settingsPageViewports` (each chunk is a `{ width,
88
+ * height }` literal). */
89
+ function splitTopLevel(body) {
90
+ const chunks = [];
91
+ let depth = 0;
92
+ let start = 0;
93
+ for (let i = 0; i < body.length; i++) {
94
+ const ch = body[i];
95
+ if (ch === "'" || ch === '"' || ch === '`') { i = skipString(body, i); continue; }
96
+ if (ch === '{' || ch === '[' || ch === '(') depth++;
97
+ else if (ch === '}' || ch === ']' || ch === ')') depth--;
98
+ else if (ch === ',' && depth === 0) {
99
+ chunks.push(body.slice(start, i));
100
+ start = i + 1;
101
+ }
102
+ }
103
+ chunks.push(body.slice(start));
104
+ return chunks;
105
+ }
106
+
107
+ /** Top-level `key: value` pairs of an object body, values kept as source text. */
108
+ function objectEntries(body) {
109
+ const entries = [];
110
+ for (const chunk of splitTopLevel(body)) {
111
+ const match = /^\s*(?:'([^']*)'|"([^"]*)"|([A-Za-z_$][\w$]*))\s*:([\s\S]*)$/.exec(chunk);
112
+ if (!match) continue;
113
+ entries.push({ key: match[1] ?? match[2] ?? match[3], value: match[4].trim() });
114
+ }
115
+ return entries;
116
+ }
117
+
118
+ function namedObject(text, field) {
119
+ const at = new RegExp(`\\b${field}\\s*[:=]\\s*\\{`).exec(text);
120
+ if (!at) return null;
121
+ return objectBody(text, at.index + at[0].length - 1);
122
+ }
123
+
124
+ const ROUTER_IMPORT = /import\s*\{[^}]*\bLiveTokensRouter\b[^}]*\}\s*from\s*['"]/;
125
+
126
+ /** Every file under `src/` that mounts the router, sorted so a project with
127
+ * more than one entry point resolves the same way on every run. */
128
+ export function routerFiles(root) {
129
+ const src = join(root, 'src');
130
+ if (!existsSync(src)) return [];
131
+ return walk(src, ['.svelte'])
132
+ .filter((file) => ROUTER_IMPORT.test(stripComments(readFileSync(file, 'utf8'))))
133
+ .sort();
134
+ }
135
+
136
+ /** Route path -> page source, from the `pages` object of every router file.
137
+ * The first file to claim a route keeps it. */
138
+ export function routeTable(root) {
139
+ const table = new Map();
140
+ for (const file of routerFiles(root)) {
141
+ const body = namedObject(stripComments(readFileSync(file, 'utf8')), 'pages');
142
+ if (body === null) continue;
143
+ for (const { key, value } of objectEntries(body)) {
144
+ const source = /\bsource\s*:\s*['"]([^'"]+)['"]/.exec(value);
145
+ if (!source || table.has(key)) continue;
146
+ table.set(key, source[1]);
147
+ }
148
+ }
149
+ return table;
150
+ }
151
+
152
+ /**
153
+ * `pageRoutes` from the testing settings: page source -> concrete URL. Read
154
+ * from the file's source text, like `contractRunner.mjs`'s `dataDir` scrape,
155
+ * because importing the settings file would fail on its own extensionless
156
+ * imports. A `pageRoutes` key present in live code that this cannot read as
157
+ * a plain string map throws rather than falling through to a silent default:
158
+ * an unread mapping reads as a page with no route.
159
+ */
160
+ export function settingsPageRoutes(root) {
161
+ const settings = settingsFilePath(root);
162
+ if (!settings) return new Map();
163
+ const live = stripComments(readFileSync(settings, 'utf8'));
164
+ const body = namedObject(live, 'pageRoutes');
165
+ if (body === null) {
166
+ if (/\bpageRoutes\s*:/.test(live)) {
167
+ throw new Error(
168
+ `"pageRoutes" in ${settings} is not a plain object literal, so --tests cannot read it statically. `
169
+ + 'Write it as literal page-path to URL string pairs, or remove the key.',
170
+ );
171
+ }
172
+ return new Map();
173
+ }
174
+ const routes = new Map();
175
+ for (const { key, value } of objectEntries(body)) {
176
+ const url = /^['"]([^'"]+)['"]$/.exec(value);
177
+ if (!url) {
178
+ throw new Error(
179
+ `"pageRoutes['${key}']" in ${settings} is not a plain string literal, so --tests cannot read it statically.`,
180
+ );
181
+ }
182
+ routes.set(key, url[1]);
183
+ }
184
+ return routes;
185
+ }
186
+
187
+ /** The contract project's own desktop size, and the phone the text styles
188
+ * carry media overrides for (decision 4). Mirrors `DEFAULT_PAGE_VIEWPORTS`
189
+ * in `src/testing/config.ts`; duplicated for the reason `settingsPageRoutes`
190
+ * above reads its own settings statically rather than importing that module. */
191
+ export const DEFAULT_PAGE_VIEWPORTS = [
192
+ { width: 1280, height: 900 },
193
+ { width: 390, height: 844 },
194
+ ];
195
+
196
+ /**
197
+ * `pageViewports` from the testing settings, read the same way
198
+ * `settingsPageRoutes` reads `pageRoutes`. Falls back to
199
+ * `DEFAULT_PAGE_VIEWPORTS` when the key is absent, so a caller building the
200
+ * set of viewports a page run is expected to cover sees the same list the
201
+ * generated Playwright config resolves at runtime (`src/testing/
202
+ * playwright.ts`'s own `settings.pageViewports`), rather than a shipped
203
+ * default that a project replaced. A `pageViewports` present in live code
204
+ * that this cannot read as a plain array of `{ width, height }` literals
205
+ * throws rather than falling through to a silent default.
206
+ */
207
+ export function settingsPageViewports(root) {
208
+ const settings = settingsFilePath(root);
209
+ if (!settings) return DEFAULT_PAGE_VIEWPORTS;
210
+ const live = stripComments(readFileSync(settings, 'utf8'));
211
+ const at = /\bpageViewports\s*:\s*\[/.exec(live);
212
+ if (!at) {
213
+ if (/\bpageViewports\s*:/.test(live)) {
214
+ throw new Error(
215
+ `"pageViewports" in ${settings} is not a plain array literal, so --tests cannot read it statically. `
216
+ + 'Write it as literal { width, height } entries, or remove the key.',
217
+ );
218
+ }
219
+ return DEFAULT_PAGE_VIEWPORTS;
220
+ }
221
+ const body = arrayBody(live, at.index + at[0].length - 1) ?? '';
222
+ const items = splitTopLevel(body).map((item) => item.trim()).filter(Boolean);
223
+ if (items.length === 0) return DEFAULT_PAGE_VIEWPORTS;
224
+ return items.map((item) => {
225
+ const width = /\bwidth\s*:\s*(\d+)/.exec(item);
226
+ const height = /\bheight\s*:\s*(\d+)/.exec(item);
227
+ if (!width || !height) {
228
+ throw new Error(
229
+ `"pageViewports" in ${settings} has an entry that is not a plain { width, height } literal, so --tests cannot read it statically.`,
230
+ );
231
+ }
232
+ return { width: Number(width[1]), height: Number(height[1]) };
233
+ });
234
+ }
235
+
236
+ function isSystemPath(rel) {
237
+ return SYSTEM_DIRS.some((dir) => rel === dir || rel.startsWith(`${dir}/`));
238
+ }
239
+
240
+ function toRelative(root, target) {
241
+ return relative(root, resolve(root, target)).split('\\').join('/');
242
+ }
243
+
244
+ const NO_ROUTE =
245
+ 'no route renders this page. Give its entry in the pages object a `source` field, '
246
+ + 'or map the file to a concrete URL under `pageRoutes` in live-tokens.testing.ts.';
247
+
248
+ /**
249
+ * The pages `--tests` opens, each with the route that renders it.
250
+ *
251
+ * With no paths, every mapped page outside the system directories is a target.
252
+ * With paths, each one is looked up in the same map; a file no route names
253
+ * comes back with `route: null` and the `reason` the caller reports as a
254
+ * `tests-setup` finding.
255
+ */
256
+ export function resolvePageTargets(paths = [], root = process.cwd()) {
257
+ const mapped = new Map();
258
+ for (const [route, source] of routeTable(root)) {
259
+ const rel = toRelative(root, source);
260
+ if (!mapped.has(rel)) mapped.set(rel, route);
261
+ }
262
+ // An explicit mapping wins: it exists because the route table could not
263
+ // express the route in the first place.
264
+ for (const [source, url] of settingsPageRoutes(root)) {
265
+ mapped.set(toRelative(root, source), url);
266
+ }
267
+
268
+ const pages = [...mapped.entries()]
269
+ .filter(([source]) => !isSystemPath(source))
270
+ .sort(([a], [b]) => a.localeCompare(b))
271
+ .map(([source, route]) => ({ source, route }));
272
+
273
+ if (paths.length === 0) return pages;
274
+
275
+ const targets = [];
276
+ for (const path of paths) {
277
+ const full = resolve(root, path);
278
+ if (existsSync(full) && statSync(full).isDirectory()) {
279
+ const prefix = `${toRelative(root, path)}/`;
280
+ targets.push(...pages.filter((page) => page.source.startsWith(prefix)));
281
+ continue;
282
+ }
283
+ const rel = toRelative(root, path);
284
+ const route = mapped.get(rel);
285
+ targets.push(route === undefined ? { source: rel, route: null, reason: NO_ROUTE } : { source: rel, route });
286
+ }
287
+ return targets;
288
+ }
289
+
290
+ /**
291
+ * `resolvePageTargets`, filtered by `checks.exclude` the same way
292
+ * `checkPages` (`bin/check-page.mjs`) filters the static half of the same
293
+ * command: an explicit *file* argument always checks, `isExcluded`'s own
294
+ * contract, but a directory `checkPages` walks — an explicit directory
295
+ * argument, or the no-paths-given discovery — drops excluded files. The two
296
+ * halves of `check-page --tests` have to agree on which pages a directory or
297
+ * omitted target reaches, or `--tests` fails a page the static half
298
+ * deliberately skips.
299
+ */
300
+ export function resolvePageTestTargets(paths = [], root = process.cwd()) {
301
+ const allTargets = resolvePageTargets(paths, root);
302
+ const explicitFiles = new Set(
303
+ paths
304
+ .map((p) => resolve(root, p))
305
+ .filter((full) => existsSync(full) && !statSync(full).isDirectory())
306
+ .map((full) => toRelative(root, full)),
307
+ );
308
+ return allTargets.filter((t) => explicitFiles.has(t.source) || !isExcluded(t.source, root));
309
+ }