@motion-proto/live-tokens 0.68.1 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/live-tokens-build-page/SKILL.md +16 -5
- package/.claude/skills/live-tokens-create-component/SKILL.md +55 -19
- package/.claude/skills/live-tokens-create-component/references/token-naming.md +30 -1
- package/.claude/skills/live-tokens-fix-findings/SKILL.md +133 -0
- package/.claude/skills/live-tokens-pick-component/SKILL.md +10 -1
- package/CHANGELOG.md +236 -0
- package/README.md +13 -4
- package/bin/check-component.mjs +367 -63
- package/bin/check-page.mjs +409 -0
- package/bin/cli.mjs +107 -9
- package/bin/lib/catalogue.mjs +123 -0
- package/bin/lib/cssValues.mjs +50 -0
- package/bin/lib/findings.mjs +106 -0
- package/bin/lib/tokenVocabulary.mjs +240 -0
- package/dist-plugin/adjust/index.cjs +174 -23
- package/dist-plugin/adjust/index.js +68 -23
- package/dist-plugin/{chunk-2UX6EVVA.js → chunk-2YNERPXY.js} +1 -1
- package/dist-plugin/{chunk-NE6N66EE.js → chunk-GPIBU44G.js} +107 -1
- package/dist-plugin/{chunk-ZHPX7ZYQ.js → chunk-RFVYPNRO.js} +39 -1
- package/dist-plugin/generateColorsAndType/index.cjs +107 -1
- package/dist-plugin/generateColorsAndType/index.js +1 -1
- package/dist-plugin/index.cjs +146 -2
- package/dist-plugin/index.js +3 -3
- package/dist-plugin/migrateData/index.cjs +107 -1
- package/dist-plugin/migrateData/index.js +2 -2
- package/dist-plugin/tokensCssMigrations/index.cjs +39 -1
- package/dist-plugin/tokensCssMigrations/index.js +1 -1
- package/package.json +3 -2
- package/src/app/site.css +4 -4
- package/src/editor/component-editor/ButtonEditor.svelte +71 -4
- package/src/editor/component-editor/CardEditor.svelte +6 -6
- package/src/editor/component-editor/DialogEditor.svelte +3 -3
- package/src/editor/component-editor/IconButtonEditor.svelte +68 -3
- package/src/editor/component-editor/ImageEditor.svelte +8 -8
- package/src/editor/component-editor/ImageLightboxEditor.svelte +12 -1
- package/src/editor/component-editor/MenuSelectEditor.svelte +63 -3
- package/src/editor/component-editor/SegmentedControlEditor.svelte +63 -3
- package/src/editor/component-editor/SideNavigationEditor.svelte +67 -3
- package/src/editor/component-editor/SliderEditor.svelte +186 -0
- package/src/editor/component-editor/TabBarEditor.svelte +63 -3
- package/src/editor/component-editor/registry.ts +10 -0
- package/src/editor/core/components/aliasKinds.ts +51 -28
- package/src/editor/core/sketch/sketchLayer.ts +16 -0
- package/src/editor/core/store/editorPersistence.ts +44 -1
- package/src/editor/core/store/editorRenderer.ts +2 -2
- package/src/editor/core/store/editorStore.ts +18 -18
- package/src/editor/core/store/editorTypes.ts +7 -6
- package/src/editor/core/themes/migrations/2026-09-01-gate-suffix-enabled.ts +31 -0
- package/src/editor/core/themes/migrations/2026-09-01-scrim-rename.ts +52 -0
- package/src/editor/core/themes/migrations/2026-09-01-tabbar-active-tint.ts +27 -0
- package/src/editor/core/themes/migrations/2026-09-01-tint-rename.ts +42 -0
- package/src/editor/core/themes/migrations/index.ts +16 -0
- package/src/editor/core/themes/slices/domainVars.ts +2 -2
- package/src/editor/core/themes/slices/washes.ts +107 -0
- package/src/editor/docs/content/editing-tokens.md +5 -3
- package/src/editor/docs/content.generated.ts +1 -1
- package/src/editor/index.ts +1 -1
- package/src/editor/pages/EditorShell.svelte +1 -1
- package/src/editor/ui/SurfacesTab.svelte +3 -3
- package/src/editor/ui/UITokenSelector.svelte +1 -0
- package/src/editor/ui/VariablesTab.svelte +2 -2
- package/src/editor/ui/sections/{OverlaysSection.svelte → WashesSection.svelte} +44 -43
- package/src/live-tokens/data/colors-and-type/autumn.json +6 -6
- package/src/live-tokens/data/colors-and-type/default.json +6 -6
- package/src/live-tokens/data/colors-and-type/halloween.json +6 -6
- package/src/live-tokens/data/colors-and-type/midnight-study.json +6 -6
- package/src/live-tokens/data/colors-and-type/ocean.json +6 -6
- package/src/live-tokens/data/colors-and-type/royal-velvet.json +6 -6
- package/src/live-tokens/data/colors-and-type/sketchy.json +6 -6
- package/src/live-tokens/data/colors-and-type/spring-meadow.json +6 -6
- package/src/live-tokens/data/colors-and-type/sunset.json +6 -6
- package/src/live-tokens/data/themes/autumn.json +81 -13
- package/src/live-tokens/data/themes/halloween.json +81 -13
- package/src/live-tokens/data/themes/midnight-study.json +81 -13
- package/src/live-tokens/data/themes/ocean.json +81 -13
- package/src/live-tokens/data/themes/royal-velvet.json +81 -13
- package/src/live-tokens/data/themes/sketchy.json +81 -13
- package/src/live-tokens/data/themes/spring-meadow.json +81 -13
- package/src/live-tokens/data/themes/sunset.json +81 -13
- package/src/live-tokens/data/tokens.generated.css +6 -6
- package/src/system/components/Button.svelte +28 -10
- package/src/system/components/Card.svelte +6 -6
- package/src/system/components/Dialog.svelte +3 -3
- package/src/system/components/IconButton.svelte +23 -7
- package/src/system/components/Image.svelte +6 -6
- package/src/system/components/MenuSelect.svelte +17 -1
- package/src/system/components/SegmentedControl.svelte +20 -2
- package/src/system/components/SideNavigation.svelte +22 -2
- package/src/system/components/Slider.svelte +348 -0
- package/src/system/components/TabBar.svelte +20 -2
- package/src/system/styles/CONVENTIONS.md +2 -2
- package/src/system/styles/tokens.css +12 -4
- package/template/package.json +3 -2
- package/template/src/pages/Home.svelte +1 -1
- package/src/editor/core/themes/slices/overlays.ts +0 -101
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Static validator for a live-tokens page.
|
|
2
|
+
//
|
|
3
|
+
// Asserts that a page satisfies the contract described in the
|
|
4
|
+
// live-tokens-build-page skill: it is assembled from catalogue components, and
|
|
5
|
+
// every value in its CSS is a theme token rather than a literal. The rules and
|
|
6
|
+
// their default severities are in PAGE_RULES; each is overridable per project
|
|
7
|
+
// (live-tokens.config.json) or per run (--off/--warn/--error/--strict), because
|
|
8
|
+
// the line between "wrong" and "deliberate" moves with the project.
|
|
9
|
+
//
|
|
10
|
+
// Returns { findings, checked } — findings carry a stable `rule` id so a skill
|
|
11
|
+
// can parse --json output, fix, and re-run until the exit code is 0.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
14
|
+
import { join, relative, resolve, basename } from 'node:path';
|
|
15
|
+
import { lineOf } from './lib/findings.mjs';
|
|
16
|
+
import { blankStrings, hasColorLiteral, hasDimensionLiteral, stripVarFallbacks } from './lib/cssValues.mjs';
|
|
17
|
+
import { isContractToken, loadVocabulary, walk } from './lib/tokenVocabulary.mjs';
|
|
18
|
+
import { resolveTokensCssPath } from './migrate.mjs';
|
|
19
|
+
|
|
20
|
+
export const PAGE_RULES = {
|
|
21
|
+
'unknown-component': 'error',
|
|
22
|
+
'unknown-prop': 'error',
|
|
23
|
+
'unknown-prop-value': 'error',
|
|
24
|
+
'deep-import': 'error',
|
|
25
|
+
'unknown-token': 'error',
|
|
26
|
+
'color-literal': 'error',
|
|
27
|
+
'reserved-route': 'error',
|
|
28
|
+
'site-css-in-main': 'error',
|
|
29
|
+
'dimension-literal': 'warn',
|
|
30
|
+
'hardcoded-columns': 'warn',
|
|
31
|
+
'raw-text-axis': 'warn',
|
|
32
|
+
'missing-source': 'warn',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Directories that hold the system, not pages built on it.
|
|
36
|
+
const NOT_PAGES = ['src/system', 'src/editor', 'src/lib', 'src/live-tokens'];
|
|
37
|
+
|
|
38
|
+
const COMPONENT_IMPORT =
|
|
39
|
+
/(?:@motion-proto\/live-tokens\/components|[./][^'"]*\/system\/components)\/([A-Za-z0-9]+)\.svelte$/;
|
|
40
|
+
|
|
41
|
+
const DEEP_IMPORT_PATTERNS = [
|
|
42
|
+
/^@motion-proto\/live-tokens\/src\//,
|
|
43
|
+
/node_modules\/@motion-proto\/live-tokens/,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const TEXT_AXES = ['font-size', 'font-family', 'font-weight', 'line-height', 'letter-spacing'];
|
|
47
|
+
|
|
48
|
+
// The geometry the theme owns: spacing, stroke, radius, and shadow all have a
|
|
49
|
+
// token scale, and `adjust` moves them. Sizing (a hero's height, a column's
|
|
50
|
+
// minimum width, a max content width) is layout, has no scale, and stays
|
|
51
|
+
// literal.
|
|
52
|
+
const THEMED_GEOMETRY = /^(padding|margin|gap|row-gap|column-gap|border|outline|inset|top|right|bottom|left|box-shadow|text-shadow)(-|$)|-radius$/;
|
|
53
|
+
|
|
54
|
+
// A local two-up or three-up is a layout. From four columns on, a hardcoded
|
|
55
|
+
// count reads as a claim about the page grid, which `--columns-count` owns.
|
|
56
|
+
const PAGE_GRID_COLUMNS = 4;
|
|
57
|
+
|
|
58
|
+
/** Blank out comments, url() payloads, and string contents so none of them can match a rule. */
|
|
59
|
+
function neutralise(css) {
|
|
60
|
+
return blankStrings(
|
|
61
|
+
css
|
|
62
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
|
|
63
|
+
.replace(/url\((?:[^()]|\([^()]*\))*\)/g, (m) => ' '.repeat(m.length)),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `<style>` blocks with their absolute offset in the file; whole file for .css. */
|
|
68
|
+
function styleRegions(text, file) {
|
|
69
|
+
if (file.endsWith('.css')) return [{ text, offset: 0 }];
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const m of text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
|
|
72
|
+
out.push({ text: m[1], offset: m.index + m[0].indexOf(m[1]) });
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Inline styles in markup, as declaration lists the value rules can read: a
|
|
79
|
+
* `style="..."` attribute verbatim, and a `style:prop="value"` directive
|
|
80
|
+
* rewritten as `prop: value;`. A `{...}` expression is dynamic and skipped.
|
|
81
|
+
*/
|
|
82
|
+
function inlineStyleRegions(code) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const m of code.matchAll(/\sstyle=(["'])([^"']*)\1/g)) {
|
|
85
|
+
out.push({ text: `${m[2]};`, offset: m.index + m[0].indexOf(m[2]) });
|
|
86
|
+
}
|
|
87
|
+
for (const m of code.matchAll(/\sstyle:([a-z-]+)=(["'])([^"']*)\2/g)) {
|
|
88
|
+
out.push({ text: `${m[1]}: ${m[3]};`, offset: m.index + 1 });
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Everything outside `<style>`: script and markup. */
|
|
94
|
+
function codeRegion(text, file) {
|
|
95
|
+
if (file.endsWith('.css')) return null;
|
|
96
|
+
return text.replace(/<style[^>]*>[\s\S]*?<\/style>/g, (m) => ' '.repeat(m.length));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Declarations in a stylesheet, with at-rule preludes excluded. A breakpoint in
|
|
101
|
+
* `@media (max-width: 768px)` is structural geometry, not a themeable value.
|
|
102
|
+
* A property name is read from its start, so `--heading-2xl` is one custom
|
|
103
|
+
* property and never the property `xl`.
|
|
104
|
+
*/
|
|
105
|
+
function declarations(css) {
|
|
106
|
+
const body = css.replace(/@[a-z-]+[^;{]*(?=\{)/gi, (m) => ' '.repeat(m.length));
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const m of body.matchAll(/(?<![\w-])((?:--)?[a-z][\w-]*)\s*:\s*([^;{}]+)[;}]/gi)) {
|
|
109
|
+
out.push({ prop: m[1].toLowerCase(), value: m[2].trim(), index: m.index });
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The attributes of one component tag starting at `start` (the `<`), read with
|
|
116
|
+
* `{}` depth and quotes tracked so an expression holding `>` does not end the
|
|
117
|
+
* tag early. Returns null when the tag spreads an object, which makes its prop
|
|
118
|
+
* set unknowable.
|
|
119
|
+
*/
|
|
120
|
+
function tagAttributes(code, start) {
|
|
121
|
+
let i = code.indexOf(' ', start);
|
|
122
|
+
const tagEnd = (() => {
|
|
123
|
+
let depth = 0;
|
|
124
|
+
let quote = null;
|
|
125
|
+
for (let j = start; j < code.length; j++) {
|
|
126
|
+
const c = code[j];
|
|
127
|
+
if (quote) {
|
|
128
|
+
if (c === quote) quote = null;
|
|
129
|
+
} else if (c === '"' || c === "'") quote = c;
|
|
130
|
+
else if (c === '{') depth++;
|
|
131
|
+
else if (c === '}') depth--;
|
|
132
|
+
else if (c === '>' && depth === 0) return j;
|
|
133
|
+
}
|
|
134
|
+
return code.length;
|
|
135
|
+
})();
|
|
136
|
+
if (i === -1 || i > tagEnd) return { attrs: [], end: tagEnd };
|
|
137
|
+
const attrs = [];
|
|
138
|
+
while (i < tagEnd) {
|
|
139
|
+
const c = code[i];
|
|
140
|
+
if (/\s/.test(c) || c === '/') {
|
|
141
|
+
i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (c === '{') {
|
|
145
|
+
let depth = 0;
|
|
146
|
+
let j = i;
|
|
147
|
+
for (; j < tagEnd; j++) {
|
|
148
|
+
if (code[j] === '{') depth++;
|
|
149
|
+
else if (code[j] === '}' && --depth === 0) break;
|
|
150
|
+
}
|
|
151
|
+
const inner = code.slice(i + 1, j).trim();
|
|
152
|
+
if (inner.startsWith('...')) return null;
|
|
153
|
+
if (/^\w+$/.test(inner)) attrs.push({ name: inner, value: null, index: i });
|
|
154
|
+
i = j + 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const name = code.slice(i).match(/^[^\s=/>]+/)?.[0];
|
|
158
|
+
if (!name) break;
|
|
159
|
+
const at = i;
|
|
160
|
+
i += name.length;
|
|
161
|
+
let value = null;
|
|
162
|
+
if (code[i] === '=') {
|
|
163
|
+
i++;
|
|
164
|
+
const q = code[i];
|
|
165
|
+
if (q === '"' || q === "'") {
|
|
166
|
+
const close = code.indexOf(q, i + 1);
|
|
167
|
+
value = code.slice(i + 1, close === -1 ? tagEnd : close);
|
|
168
|
+
i = close === -1 ? tagEnd : close + 1;
|
|
169
|
+
} else if (q === '{') {
|
|
170
|
+
let depth = 0;
|
|
171
|
+
for (; i < tagEnd; i++) {
|
|
172
|
+
if (code[i] === '{') depth++;
|
|
173
|
+
else if (code[i] === '}' && --depth === 0) break;
|
|
174
|
+
}
|
|
175
|
+
i++;
|
|
176
|
+
} else {
|
|
177
|
+
const bare = code.slice(i).match(/^[^\s>]+/)?.[0] ?? '';
|
|
178
|
+
value = bare;
|
|
179
|
+
i += bare.length;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
attrs.push({ name, value, index: at });
|
|
183
|
+
}
|
|
184
|
+
return { attrs, end: tagEnd };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Props a page passes that the component does not declare, or values outside a prop's union. */
|
|
188
|
+
function checkComponentUsage(code, imports, add) {
|
|
189
|
+
for (const [local, entry] of imports) {
|
|
190
|
+
const props = entry.props;
|
|
191
|
+
if (!props) continue;
|
|
192
|
+
const re = new RegExp(`<${local}(?=[\\s/>])`, 'g');
|
|
193
|
+
for (const m of code.matchAll(re)) {
|
|
194
|
+
const tag = tagAttributes(code, m.index);
|
|
195
|
+
if (!tag) continue;
|
|
196
|
+
for (const { name, value, index } of tag.attrs) {
|
|
197
|
+
if (name.includes(':') || name.startsWith('@') || name === 'children') continue;
|
|
198
|
+
if (!props.props.has(name)) {
|
|
199
|
+
add('unknown-prop', index, `${entry.name} has no prop '${name}'; it accepts ${[...props.props].join(', ')}`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const allowed = props.enums.get(name);
|
|
203
|
+
if (allowed && value !== null && !allowed.has(value)) {
|
|
204
|
+
add('unknown-prop-value', index, `${entry.name} ${name}="${value}" is not one of ${[...allowed].join(', ')}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The object literal enclosing `index`, found by balancing braces outward. */
|
|
212
|
+
function enclosingObject(text, index) {
|
|
213
|
+
let depth = 0;
|
|
214
|
+
let start = -1;
|
|
215
|
+
for (let i = index; i >= 0; i--) {
|
|
216
|
+
const c = text[i];
|
|
217
|
+
if (c === '}') depth++;
|
|
218
|
+
else if (c === '{') {
|
|
219
|
+
if (depth === 0) {
|
|
220
|
+
start = i;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
depth--;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (start === -1) return null;
|
|
227
|
+
depth = 0;
|
|
228
|
+
for (let i = start; i < text.length; i++) {
|
|
229
|
+
const c = text[i];
|
|
230
|
+
if (c === '{') depth++;
|
|
231
|
+
else if (c === '}') {
|
|
232
|
+
depth--;
|
|
233
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function checkFile(file, text, vocab, root) {
|
|
240
|
+
const rel = relative(root, file);
|
|
241
|
+
const findings = [];
|
|
242
|
+
const add = (rule, index, message) =>
|
|
243
|
+
findings.push({ rule, file: rel, line: lineOf(text, index), message });
|
|
244
|
+
|
|
245
|
+
const code = codeRegion(text, file);
|
|
246
|
+
if (code !== null) {
|
|
247
|
+
const imports = new Map();
|
|
248
|
+
for (const m of code.matchAll(/import\s+(?:([^'"]*?)\s+from\s+)?['"]([^'"]+)['"]/g)) {
|
|
249
|
+
const spec = m[2];
|
|
250
|
+
for (const pattern of DEEP_IMPORT_PATTERNS) {
|
|
251
|
+
if (pattern.test(spec)) {
|
|
252
|
+
add('deep-import', m.index, `deep import into package internals: ${spec}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const comp = spec.match(COMPONENT_IMPORT);
|
|
256
|
+
if (!comp) continue;
|
|
257
|
+
const entry = vocab.components.get(comp[1].toLowerCase());
|
|
258
|
+
if (!entry) {
|
|
259
|
+
add(
|
|
260
|
+
'unknown-component',
|
|
261
|
+
m.index,
|
|
262
|
+
`'${comp[1]}' is not in the component catalogue; author it with live-tokens-create-component or pick a shipped one`,
|
|
263
|
+
);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const local = m[1]?.trim().match(/^(\w+)$/)?.[1];
|
|
267
|
+
if (local) imports.set(local, entry);
|
|
268
|
+
}
|
|
269
|
+
checkComponentUsage(code, imports, add);
|
|
270
|
+
|
|
271
|
+
for (const m of code.matchAll(/['"](\/live-tokens[^'"]*)['"]\s*:/g)) {
|
|
272
|
+
add('reserved-route', m.index, `route '${m[1]}' is inside the reserved /live-tokens/* namespace`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const m of code.matchAll(/\blazy\s*:/g)) {
|
|
276
|
+
const entry = enclosingObject(code, m.index);
|
|
277
|
+
if (entry && !/\bsource\s*:/.test(entry)) {
|
|
278
|
+
add('missing-source', m.index, `route entry has no 'source', so Page Source cannot open it`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (/^main\.(ts|js)$/.test(basename(file))) {
|
|
283
|
+
for (const m of code.matchAll(/import\s+['"]([^'"]*site\.css)['"]/g)) {
|
|
284
|
+
add(
|
|
285
|
+
'site-css-in-main',
|
|
286
|
+
m.index,
|
|
287
|
+
`site.css imported from main; import it from each page's <script> so it cannot leak into editor routes`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A page may also mint a custom property outside its <style> block — a
|
|
294
|
+
// `style:--x={...}` directive or an el.style.setProperty call — and those are
|
|
295
|
+
// just as declared as one written in CSS.
|
|
296
|
+
const declaredHere = new Set();
|
|
297
|
+
const regions = styleRegions(text, file);
|
|
298
|
+
for (const region of regions) {
|
|
299
|
+
for (const m of neutralise(region.text).matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/gim)) {
|
|
300
|
+
declaredHere.add(m[1]);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const m of text.matchAll(/(?:style:|setProperty\(\s*['"`]|['"`])(--[a-z0-9-]+)/g)) {
|
|
304
|
+
declaredHere.add(m[1]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const region of [...regions, ...(code === null ? [] : inlineStyleRegions(code))]) {
|
|
308
|
+
const css = neutralise(region.text);
|
|
309
|
+
const at = (i) => region.offset + i;
|
|
310
|
+
|
|
311
|
+
for (const m of css.matchAll(/var\(\s*(--[a-z0-9-]+)/g)) {
|
|
312
|
+
const name = m[1];
|
|
313
|
+
if (declaredHere.has(name) || vocab.knows(name)) continue;
|
|
314
|
+
add(
|
|
315
|
+
'unknown-token',
|
|
316
|
+
at(m.index),
|
|
317
|
+
isContractToken(name)
|
|
318
|
+
? `${name} looks like a theme token but no longer exists; check tokens.css for a rename`
|
|
319
|
+
: `${name} is not a theme token, a component token, or declared in this file`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const decl of declarations(css)) {
|
|
324
|
+
const { prop, value, index } = decl;
|
|
325
|
+
if (prop.startsWith('--')) continue;
|
|
326
|
+
|
|
327
|
+
// A `var()` fallback only renders when the token is missing, so a literal
|
|
328
|
+
// inside one is not the page's value.
|
|
329
|
+
const painted = stripVarFallbacks(value);
|
|
330
|
+
if (!TEXT_AXES.includes(prop) && hasColorLiteral(painted)) {
|
|
331
|
+
add('color-literal', at(index), `${prop}: ${value}. Use a theme token, not a colour literal.`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Only absolute type values are a finding. `em`, `%`, and a unitless
|
|
336
|
+
// line-height are relative to the inherited type, so they ride whatever
|
|
337
|
+
// the theme sets rather than overriding it.
|
|
338
|
+
if (
|
|
339
|
+
(TEXT_AXES.includes(prop) || prop === 'font') &&
|
|
340
|
+
!value.includes('var(') &&
|
|
341
|
+
!/^(inherit|initial|unset|normal)$/.test(value) &&
|
|
342
|
+
/\d(px|rem|pt)\b|^[a-z"']/i.test(value)
|
|
343
|
+
) {
|
|
344
|
+
add(
|
|
345
|
+
'raw-text-axis',
|
|
346
|
+
at(index),
|
|
347
|
+
`${prop}: ${value}. Set type from a text style bundle (--heading-*, --body-*, --editorial-*).`,
|
|
348
|
+
);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (THEMED_GEOMETRY.test(prop) && hasDimensionLiteral(painted)) {
|
|
353
|
+
add(
|
|
354
|
+
'dimension-literal',
|
|
355
|
+
at(index),
|
|
356
|
+
`${prop}: ${value}. Use a --space-*, --radius-*, --border-width-*, or --shadow-* token.`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const columns = value.match(/\brepeat\(\s*(\d+)\s*,\s*1fr\s*\)/);
|
|
361
|
+
if (columns && Number(columns[1]) >= PAGE_GRID_COLUMNS) {
|
|
362
|
+
add(
|
|
363
|
+
'hardcoded-columns',
|
|
364
|
+
at(index),
|
|
365
|
+
`${prop}: ${value}. Use repeat(var(--columns-count), 1fr) so the page grid stays in step.`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return findings;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Files that define the vocabulary rather than consume it.
|
|
375
|
+
const TOKEN_SOURCES = ['tokens.generated.css', 'fonts.css'];
|
|
376
|
+
|
|
377
|
+
/** Pages to check when the caller names none: every .svelte/.css under src/ that is not system code. */
|
|
378
|
+
export function discoverPages(root) {
|
|
379
|
+
const src = join(root, 'src');
|
|
380
|
+
if (!existsSync(src)) return [];
|
|
381
|
+
const tokensCss = resolveTokensCssPath(null, null, root);
|
|
382
|
+
return walk(src, ['.svelte', '.css', '.ts', '.js']).filter((f) => {
|
|
383
|
+
const rel = relative(root, f);
|
|
384
|
+
if (NOT_PAGES.some((d) => rel.startsWith(`${d}/`))) return false;
|
|
385
|
+
if (f === tokensCss || TOKEN_SOURCES.includes(basename(f))) return false;
|
|
386
|
+
if (/\.(test|spec)\.[tj]s$/.test(rel)) return false;
|
|
387
|
+
if (rel.endsWith('.ts') || rel.endsWith('.js')) return /main\.(ts|js)$/.test(rel);
|
|
388
|
+
return true;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function checkPages(targets, { root = process.cwd(), vocabulary } = {}) {
|
|
393
|
+
const vocab = vocabulary ?? loadVocabulary({ root });
|
|
394
|
+
const files = [];
|
|
395
|
+
for (const t of targets) {
|
|
396
|
+
const full = resolve(root, t);
|
|
397
|
+
if (!existsSync(full)) continue;
|
|
398
|
+
if (statSync(full).isDirectory()) {
|
|
399
|
+
files.push(...walk(full, ['.svelte', '.css']));
|
|
400
|
+
} else {
|
|
401
|
+
files.push(full);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const findings = [];
|
|
405
|
+
for (const file of files) {
|
|
406
|
+
findings.push(...checkFile(file, readFileSync(file, 'utf8'), vocab, root));
|
|
407
|
+
}
|
|
408
|
+
return { findings, checked: files.length };
|
|
409
|
+
}
|
package/bin/cli.mjs
CHANGED
|
@@ -3,17 +3,31 @@
|
|
|
3
3
|
// Subcommands:
|
|
4
4
|
// create <dir> Scaffold a new app that depends on this package.
|
|
5
5
|
// setup-claude [--force] Copy bundled Claude Code skills into ./.claude/skills/.
|
|
6
|
-
//
|
|
6
|
+
// components [id] List every component the project has, shipped and its own, with props and tokens.
|
|
7
|
+
// tokens [--family <name>] List every theme token by family, with its value.
|
|
8
|
+
// check-component [id] Validate a component (or every authored one) against the create-component skill contract.
|
|
9
|
+
// check-page [paths...] Validate pages against the build-page skill contract.
|
|
7
10
|
// generate-theme <brief> Build a theme from a 10-seed OKLCH brief and open it.
|
|
8
11
|
// adjust <ops.json> Apply radius/padding/gap/border-width ops to the open buffer.
|
|
9
12
|
// set-fonts <brief.json> Bind Google Fonts families to the theme's font stacks.
|
|
10
13
|
// migrate [...] Reconcile tokens.css, the data tree, and route references.
|
|
11
14
|
|
|
12
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeSync } from 'node:fs';
|
|
13
16
|
import { dirname, join, resolve } from 'node:path';
|
|
14
17
|
import { fileURLToPath } from 'node:url';
|
|
15
18
|
import process from 'node:process';
|
|
16
|
-
import { checkComponent, formatReport } from './check-component.mjs';
|
|
19
|
+
import { COMPONENT_RULES, checkComponent, discoverComponents, formatReport } from './check-component.mjs';
|
|
20
|
+
import { PAGE_RULES, checkPages, discoverPages } from './check-page.mjs';
|
|
21
|
+
import { describeComponents, describeTokens, formatComponents, formatTokens } from './lib/catalogue.mjs';
|
|
22
|
+
import { loadVocabulary } from './lib/tokenVocabulary.mjs';
|
|
23
|
+
import {
|
|
24
|
+
applySeverity,
|
|
25
|
+
countBySeverity,
|
|
26
|
+
formatFindings,
|
|
27
|
+
parseCheckFlags,
|
|
28
|
+
readChecksConfig,
|
|
29
|
+
toJson,
|
|
30
|
+
} from './lib/findings.mjs';
|
|
17
31
|
import {
|
|
18
32
|
runMigrate,
|
|
19
33
|
formatMigrateResult,
|
|
@@ -32,8 +46,28 @@ Commands:
|
|
|
32
46
|
create <dir> [--force] Scaffold a new Svelte + Vite app wired up with
|
|
33
47
|
live-tokens (editor, components, theme tokens)
|
|
34
48
|
setup-claude [--force] Install bundled Claude Code skills into ./.claude/skills/
|
|
35
|
-
|
|
49
|
+
components [id] [--json] List every component the project has, shipped and
|
|
50
|
+
its own (src/system/components plus any
|
|
51
|
+
"componentDirs" in live-tokens.config.json), with
|
|
52
|
+
the props each takes; with an id, that component's
|
|
53
|
+
props, variants, tokens, and defaults
|
|
54
|
+
tokens [--family <name>] [--json]
|
|
55
|
+
List every theme token the project's tokens.css
|
|
56
|
+
declares, by family, with its value
|
|
57
|
+
check-component [id] Validate <id>'s runtime, editor, and registration
|
|
36
58
|
against the live-tokens-create-component contract
|
|
59
|
+
check-page [paths...] Validate pages against the live-tokens-build-page
|
|
60
|
+
contract: catalogue components only, and every CSS
|
|
61
|
+
value a theme token. Checks every page under src/
|
|
62
|
+
when given no paths.
|
|
63
|
+
|
|
64
|
+
Both check commands accept:
|
|
65
|
+
--json Machine-readable findings, for a skill to iterate
|
|
66
|
+
against until the exit code is 0
|
|
67
|
+
--strict Treat warnings as errors
|
|
68
|
+
--off=<rule,...> Silence rules; --warn=/--error= change severity
|
|
69
|
+
(or set "checks": { "rules": {...} } in
|
|
70
|
+
live-tokens.config.json)
|
|
37
71
|
generate-theme <brief.json> [--no-activate] [--dry-run] [--carry-from <name>]
|
|
38
72
|
Build a full theme from a 10-seed OKLCH brief
|
|
39
73
|
(see the live-tokens-generate-theme skill),
|
|
@@ -79,6 +113,20 @@ Commands:
|
|
|
79
113
|
either is pending; route findings are advisory).
|
|
80
114
|
`;
|
|
81
115
|
|
|
116
|
+
// A large body written through console.log is cut at the pipe buffer when the
|
|
117
|
+
// process exits before stdout drains, so a query writes synchronously.
|
|
118
|
+
function writeOut(text) {
|
|
119
|
+
const buf = Buffer.from(`${text}\n`);
|
|
120
|
+
let offset = 0;
|
|
121
|
+
while (offset < buf.length) {
|
|
122
|
+
try {
|
|
123
|
+
offset += writeSync(1, buf, offset, buf.length - offset);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.code !== 'EAGAIN') throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
82
130
|
function fail(message, code = 1) {
|
|
83
131
|
console.error(message);
|
|
84
132
|
process.exit(code);
|
|
@@ -109,12 +157,61 @@ if (command === 'create' || command === 'init') {
|
|
|
109
157
|
}
|
|
110
158
|
}
|
|
111
159
|
|
|
160
|
+
function reportChecks(label, findings, checked, rules, opts) {
|
|
161
|
+
const resolved = applySeverity(findings, rules, opts, readChecksConfig(process.cwd()));
|
|
162
|
+
console.log(
|
|
163
|
+
opts.json
|
|
164
|
+
? toJson(resolved, { label, checked })
|
|
165
|
+
: formatFindings(resolved, { label, checked }),
|
|
166
|
+
);
|
|
167
|
+
process.exit(countBySeverity(resolved).errors === 0 ? 0 : 1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (command === 'components') {
|
|
171
|
+
const opts = parseCheckFlags(rest);
|
|
172
|
+
const list = describeComponents(loadVocabulary());
|
|
173
|
+
const id = opts.rest[0];
|
|
174
|
+
if (id && !list.some((c) => c.id === id)) fail(formatComponents(list, { id }));
|
|
175
|
+
writeOut(opts.json ? JSON.stringify(id ? list.find((c) => c.id === id) : list, null, 2) : formatComponents(list, { id }));
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (command === 'tokens') {
|
|
180
|
+
const opts = parseCheckFlags(rest);
|
|
181
|
+
const at = opts.rest.indexOf('--family');
|
|
182
|
+
const family = at >= 0 ? opts.rest[at + 1] : undefined;
|
|
183
|
+
const desc = describeTokens(loadVocabulary());
|
|
184
|
+
if (family && !desc.families.some((f) => f.family === family)) fail(formatTokens(desc, { family }));
|
|
185
|
+
writeOut(
|
|
186
|
+
opts.json
|
|
187
|
+
? JSON.stringify(family ? desc.families.find((f) => f.family === family) : desc, null, 2)
|
|
188
|
+
: formatTokens(desc, { family }),
|
|
189
|
+
);
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
|
|
112
193
|
if (command === 'check-component') {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
194
|
+
const opts = parseCheckFlags(rest);
|
|
195
|
+
const ids = opts.rest.length > 0 ? [opts.rest[0]] : discoverComponents();
|
|
196
|
+
if (ids.length === 0) {
|
|
197
|
+
console.log('✓ check-component: no component authored under src/system/components yet.');
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
const results = ids.map((id) => [id, checkComponent(id)]);
|
|
201
|
+
if (ids.length === 1 && !opts.json && !opts.strict && opts.off.length + opts.warn.length + opts.error.length === 0) {
|
|
202
|
+
const [id, result] = results[0];
|
|
203
|
+
console.log(formatReport(id, result));
|
|
204
|
+
process.exit(result.errors.length === 0 ? 0 : 1);
|
|
205
|
+
}
|
|
206
|
+
const label = ids.length === 1 ? `check-component ${ids[0]}` : 'check-component';
|
|
207
|
+
reportChecks(label, results.flatMap(([, r]) => r.findings), ids.length, COMPONENT_RULES, opts);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (command === 'check-page') {
|
|
211
|
+
const opts = parseCheckFlags(rest);
|
|
212
|
+
const targets = opts.rest.length > 0 ? opts.rest : discoverPages(process.cwd());
|
|
213
|
+
const { findings, checked } = checkPages(targets, { root: process.cwd() });
|
|
214
|
+
reportChecks('check-page', findings, checked, PAGE_RULES, opts);
|
|
118
215
|
}
|
|
119
216
|
|
|
120
217
|
if (command === 'generate-theme') {
|
|
@@ -270,6 +367,7 @@ const SAMPLE_PROMPTS = {
|
|
|
270
367
|
'live-tokens-generate-theme': 'make me a bright and cheerful theme',
|
|
271
368
|
'live-tokens-adjust-geometry': 'make the buttons pill shaped',
|
|
272
369
|
'live-tokens-pair-fonts': 'pair some fonts for this theme',
|
|
370
|
+
'live-tokens-fix-findings': 'make check:design pass',
|
|
273
371
|
};
|
|
274
372
|
|
|
275
373
|
const installedSamples = skills
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The registry as a query. Every component a project has, shipped or its own,
|
|
2
|
+
// with the props each takes and the tokens each declares, and every theme token
|
|
3
|
+
// grouped by family. Read from files through the same vocabulary the checkers
|
|
4
|
+
// use, so a skill or a script sees exactly what the checkers will hold it to.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { relative } from 'node:path';
|
|
8
|
+
import { CONTRACT_FAMILIES } from './tokenVocabulary.mjs';
|
|
9
|
+
|
|
10
|
+
/** The runtime file's leading HTML comment, which is where a component says what it is for. */
|
|
11
|
+
function descriptionOf(source) {
|
|
12
|
+
const m = source.match(/^\s*<!--([\s\S]*?)-->/);
|
|
13
|
+
if (!m) return '';
|
|
14
|
+
return m[1]
|
|
15
|
+
.split('\n')
|
|
16
|
+
.map((line) => line.trim())
|
|
17
|
+
.join(' ')
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim()
|
|
20
|
+
.replace(/^\S+\.svelte\s*[—–-]+\s*/, '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function familyOf(name) {
|
|
24
|
+
const stem = name.replace(/^--/, '');
|
|
25
|
+
const hit = CONTRACT_FAMILIES
|
|
26
|
+
.filter((f) => stem === f || stem.startsWith(`${f}-`))
|
|
27
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
28
|
+
return hit ?? stem.split('-')[0];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function describeComponents(vocab, { root = process.cwd() } = {}) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const entry of vocab.components.values()) {
|
|
34
|
+
const source = readFileSync(entry.file, 'utf8');
|
|
35
|
+
const props = entry.props
|
|
36
|
+
? [...entry.props.props].map((name) => ({
|
|
37
|
+
name,
|
|
38
|
+
type: entry.props.types.get(name) ?? '',
|
|
39
|
+
values: entry.props.enums.has(name) ? [...entry.props.enums.get(name)] : undefined,
|
|
40
|
+
}))
|
|
41
|
+
: [];
|
|
42
|
+
out.push({
|
|
43
|
+
id: entry.id,
|
|
44
|
+
name: entry.name,
|
|
45
|
+
origin: entry.origin,
|
|
46
|
+
file: relative(root, entry.file),
|
|
47
|
+
registered: entry.origin === 'shipped' || vocab.registered.has(entry.id),
|
|
48
|
+
description: descriptionOf(source),
|
|
49
|
+
variants: entry.props?.enums.get('variant') ? [...entry.props.enums.get('variant')] : [],
|
|
50
|
+
props,
|
|
51
|
+
tokens: [...entry.tokens].map(([name, value]) => ({ name, default: value })),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return out.sort((a, b) => a.origin.localeCompare(b.origin) || a.id.localeCompare(b.id));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function describeTokens(vocab, { root = process.cwd() } = {}) {
|
|
58
|
+
const values = new Map();
|
|
59
|
+
if (vocab.tokensCssPath) {
|
|
60
|
+
const css = readFileSync(vocab.tokensCssPath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ' ');
|
|
61
|
+
for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
|
|
62
|
+
if (!values.has(m[1])) values.set(m[1], m[2].trim());
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const byFamily = new Map();
|
|
66
|
+
for (const name of vocab.themeTokens) {
|
|
67
|
+
const family = familyOf(name);
|
|
68
|
+
if (!byFamily.has(family)) byFamily.set(family, []);
|
|
69
|
+
byFamily.get(family).push({ name, value: values.get(name) ?? '' });
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
tokensCss: vocab.tokensCssPath ? relative(root, vocab.tokensCssPath) : null,
|
|
73
|
+
families: [...byFamily].map(([family, tokens]) => ({ family, tokens })),
|
|
74
|
+
components: [...vocab.components.values()].map((c) => ({
|
|
75
|
+
id: c.id,
|
|
76
|
+
tokens: [...c.tokens].map(([name, value]) => ({ name, default: value })),
|
|
77
|
+
})),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatComponents(list, { id } = {}) {
|
|
82
|
+
const lines = [];
|
|
83
|
+
if (id) {
|
|
84
|
+
const c = list.find((x) => x.id === id);
|
|
85
|
+
if (!c) return `No component "${id}". Run \`live-tokens components\` for the list.`;
|
|
86
|
+
lines.push(`${c.name} (${c.id}, ${c.origin}${c.registered ? '' : ', NOT registered'}) ${c.file}`);
|
|
87
|
+
if (c.description) lines.push(` ${c.description}`);
|
|
88
|
+
if (c.props.length) {
|
|
89
|
+
lines.push(' props:');
|
|
90
|
+
for (const p of c.props) lines.push(` ${p.name}${p.values ? `: ${p.values.join(' | ')}` : p.type ? `: ${p.type}` : ''}`);
|
|
91
|
+
}
|
|
92
|
+
lines.push(` tokens (${c.tokens.length}):`);
|
|
93
|
+
for (const t of c.tokens) lines.push(` ${t.name}: ${t.default}`);
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
for (const c of list) {
|
|
97
|
+
const variants = c.variants.length ? ` variants: ${c.variants.join(', ')}` : '';
|
|
98
|
+
lines.push(`${c.id.padEnd(20)} ${c.origin.padEnd(8)} ${c.name}${c.registered ? '' : ' (NOT registered)'}${variants}`);
|
|
99
|
+
if (c.description) lines.push(`${''.padEnd(29)} ${c.description}`);
|
|
100
|
+
}
|
|
101
|
+
lines.push('');
|
|
102
|
+
lines.push(`${list.length} component(s). \`live-tokens components <id>\` prints one with its props and tokens.`);
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatTokens(desc, { family } = {}) {
|
|
107
|
+
const lines = [];
|
|
108
|
+
const families = family ? desc.families.filter((f) => f.family === family) : desc.families;
|
|
109
|
+
if (family && families.length === 0) {
|
|
110
|
+
return `No family "${family}". Families: ${desc.families.map((f) => f.family).join(', ')}.`;
|
|
111
|
+
}
|
|
112
|
+
lines.push(`Theme tokens from ${desc.tokensCss ?? '(no tokens.css found)'}`);
|
|
113
|
+
for (const f of families) {
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push(`${f.family} (${f.tokens.length})`);
|
|
116
|
+
for (const t of f.tokens) lines.push(` ${t.name}: ${t.value}`);
|
|
117
|
+
}
|
|
118
|
+
if (!family) {
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push(`Component tokens: ${desc.components.reduce((n, c) => n + c.tokens.length, 0)} across ${desc.components.length} component(s). \`live-tokens components <id>\` lists one component's.`);
|
|
121
|
+
}
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|