@motion-proto/live-tokens 0.77.0 → 0.78.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 (37) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +2 -0
  2. package/.claude/skills/live-tokens-create-page/SKILL.md +5 -4
  3. package/.claude/skills/live-tokens-fix-findings/SKILL.md +7 -0
  4. package/CHANGELOG.md +53 -0
  5. package/bin/check-page.mjs +131 -9
  6. package/bin/cli.mjs +25 -3
  7. package/bin/contractRunner.mjs +343 -29
  8. package/bin/lib/pageRoutes.mjs +309 -0
  9. package/package.json +5 -3
  10. package/src/editor/overlay/ColumnsOverlay.svelte +1 -1
  11. package/src/editor/overlay/LiveEditorOverlay.svelte +1 -0
  12. package/src/editor/overlay/LiveTokensRouter.svelte +1 -0
  13. package/src/editor/skill-atlas/skillSources.generated.ts +3 -3
  14. package/src/editor/skill-atlas/trees/check-compliance.ts +18 -18
  15. package/src/editor/skill-atlas/trees/create-page.ts +26 -26
  16. package/src/editor/skill-atlas/trees/fix-findings.ts +46 -11
  17. package/src/testing-js/{chunk-7TI7Z6Y6.js → chunk-GNIUPIU2.js} +2 -2
  18. package/src/testing-js/{chunk-ZMSX6CXR.js → chunk-NB3NZRBM.js} +13 -2
  19. package/src/testing-js/chunk-NB3NZRBM.js.map +1 -0
  20. package/src/testing-js/{chunk-L73N4NSO.js → chunk-WIZ6W7UT.js} +2 -2
  21. package/src/testing-js/component-alias.contract.js +2 -2
  22. package/src/testing-js/component-editor.contract.js +3 -3
  23. package/src/testing-js/component-render.contract.js +3 -3
  24. package/src/testing-js/index.d.ts +8 -2
  25. package/src/testing-js/index.js +19 -1
  26. package/src/testing-js/index.js.map +1 -1
  27. package/src/testing-js/page-compliance.contract.js +829 -0
  28. package/src/testing-js/page-compliance.contract.js.map +1 -0
  29. package/src/testing-js/{vitest-BE6uGF31.d.ts → vitest-C-wNWcoA.d.ts} +21 -1
  30. package/src/testing-js/vitest.d.ts +1 -1
  31. package/src/testing-js/vitest.js +1 -1
  32. package/template/README.md +16 -9
  33. package/template/package.json +1 -1
  34. package/template/src/pages/Home.svelte +13 -0
  35. package/src/testing-js/chunk-ZMSX6CXR.js.map +0 -1
  36. /package/src/testing-js/{chunk-7TI7Z6Y6.js.map → chunk-GNIUPIU2.js.map} +0 -0
  37. /package/src/testing-js/{chunk-L73N4NSO.js.map → chunk-WIZ6W7UT.js.map} +0 -0
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.77.0",
3
+ "version": "0.78.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -148,10 +148,11 @@
148
148
  "check": "svelte-check --tsconfig ./tsconfig.json",
149
149
  "test": "vitest run",
150
150
  "test:watch": "vitest",
151
- "test:e2e": "npm run test:e2e:contract && npm run test:e2e:contract-defects && npm run test:e2e:stateful",
151
+ "test:e2e": "npm run test:e2e:contract && npm run test:e2e:contract-defects && npm run test:e2e:page-defects && npm run test:e2e:stateful",
152
152
  "test:e2e:contract": "playwright test --project=contract --workers=${PLAYWRIGHT_WORKERS:-4}",
153
153
  "test:e2e:stateful": "playwright test tests/e2e/live-editing.spec.ts tests/e2e/theme-workflow.spec.ts --workers=1",
154
154
  "test:e2e:contract-defects": "playwright test --project=contract-defects --workers=${PLAYWRIGHT_WORKERS:-4}",
155
+ "test:e2e:page-defects": "playwright test --project=page-defects --workers=${PLAYWRIGHT_WORKERS:-4}",
155
156
  "test:e2e:components": "playwright test --project=contract src/testing/component-render.contract.ts",
156
157
  "test:e2e:ui": "playwright test --ui",
157
158
  "test:registry-contract": "vitest run --config vitest.contract.config.ts",
@@ -183,7 +184,8 @@
183
184
  "check:smoke-install": "bash scripts/smoke-install.sh",
184
185
  "check:smoke-create": "bash scripts/smoke-create.sh",
185
186
  "check:smoke-component-tests": "bash scripts/smoke-component-tests.sh",
186
- "prepublishOnly": "npm run check:no-style-imports && npm run check:no-tooling-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:pages && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:token-contract && npm run check:preset-themes && npm run check:skills && npm run check:cli-strings && npm run check:skill-sources && npm run check:skill-atlas && npm run check:smoke-install && npm run check:smoke-create && npm run check:smoke-component-tests"
187
+ "check:smoke-page-tests": "bash scripts/smoke-page-tests.sh",
188
+ "prepublishOnly": "npm run check:no-style-imports && npm run check:no-tooling-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:pages && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:token-contract && npm run check:preset-themes && npm run check:skills && npm run check:cli-strings && npm run check:skill-sources && npm run check:skill-atlas && npm run check:smoke-install && npm run check:smoke-create && npm run check:smoke-component-tests && npm run check:smoke-page-tests"
187
189
  },
188
190
  "peerDependencies": {
189
191
  "@playwright/test": "^1.55",
@@ -38,7 +38,7 @@
38
38
  </script>
39
39
 
40
40
  {#if enabled && $columnsVisible}
41
- <div class="columns-overlay" aria-hidden="true">
41
+ <div class="columns-overlay" data-live-tokens-chrome aria-hidden="true">
42
42
  <div class="columns-overlay__inner">
43
43
  {#each Array(count) as _, i}
44
44
  <div class="columns-overlay__col">
@@ -552,6 +552,7 @@
552
552
  <!-- svelte-ignore a11y_no_static_element_interactions -->
553
553
  <div
554
554
  class="lt-overlay {dockClass}"
555
+ data-live-tokens-chrome
555
556
  style={panelStyle}
556
557
  class:shown={open}
557
558
  class:hidden={!open}
@@ -266,6 +266,7 @@
266
266
  <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions, a11y_no_static_element_interactions -->
267
267
  <div
268
268
  class="lt-app"
269
+ data-live-tokens-page
269
270
  class:is-editor={isEditor}
270
271
  class:is-component-editor={isComponentEditor}
271
272
  class:is-colors={isColors}