@motion-proto/live-tokens 0.55.0 → 0.56.1

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 (38) hide show
  1. package/.claude/skills/{live-tokens-adjust-shape-space → live-tokens-adjust-geometry}/SKILL.md +5 -5
  2. package/.claude/skills/live-tokens-build-page/SKILL.md +2 -0
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +20 -357
  4. package/.claude/skills/live-tokens-create-component/references/fixed-overlays.md +3 -0
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +58 -0
  6. package/.claude/skills/live-tokens-create-component/references/linked-siblings.md +70 -0
  7. package/.claude/skills/live-tokens-generate-theme/SKILL.md +79 -98
  8. package/.claude/skills/live-tokens-generate-theme/references/named-themes.md +18 -0
  9. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +88 -0
  10. package/.claude/skills/live-tokens-pick-component/SKILL.md +4 -1
  11. package/CHANGELOG.md +108 -0
  12. package/README.md +20 -7
  13. package/bin/cli.mjs +41 -3
  14. package/bin/set-fonts.mjs +280 -0
  15. package/dist-plugin/adjust/index.d.cts +2 -2
  16. package/dist-plugin/adjust/index.d.ts +2 -2
  17. package/dist-plugin/fontPairing/index.cjs +411 -0
  18. package/dist-plugin/fontPairing/index.d.cts +109 -0
  19. package/dist-plugin/fontPairing/index.d.ts +109 -0
  20. package/dist-plugin/fontPairing/index.js +320 -0
  21. package/dist-plugin/generateColorsAndType/index.d.cts +2 -2
  22. package/dist-plugin/generateColorsAndType/index.d.ts +2 -2
  23. package/dist-plugin/index-4N-Orzzi.d.cts +3 -0
  24. package/dist-plugin/index-4N-Orzzi.d.ts +3 -0
  25. package/dist-plugin/{index-DpTIRZ2H.d.cts → themeTypes-BAqtv4XO.d.cts} +1 -3
  26. package/dist-plugin/{index-DpTIRZ2H.d.ts → themeTypes-BAqtv4XO.d.ts} +1 -3
  27. package/package.json +3 -2
  28. package/src/editor/core/fonts/applyFontPairing.ts +157 -0
  29. package/src/editor/core/fonts/fontPairing.ts +13 -6
  30. package/src/editor/core/fonts/googleFontsUrl.ts +123 -0
  31. package/src/editor/core/fonts/weightCoverage.ts +92 -0
  32. package/src/editor/docs/content/themes-workflow.md +39 -0
  33. package/src/editor/docs/content.generated.ts +1 -1
  34. package/src/editor/ui/ColorEditPanel.svelte +16 -1
  35. package/src/editor/ui/PaletteEditor.svelte +124 -31
  36. package/src/editor/ui/ProjectFontsSection.svelte +24 -36
  37. package/src/editor/ui/palette/OverridesPanel.svelte +5 -2
  38. package/src/editor/ui/palette/PaletteBase.svelte +13 -0
package/bin/cli.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // check-component <id> Validate a component against the add-component skill contract.
7
7
  // generate-theme <brief> Build a theme from a 10-seed OKLCH brief and open it.
8
8
  // adjust <ops.json> Apply radius/padding/gap/border-width ops to the open buffer.
9
+ // set-fonts <brief.json> Bind Google Fonts families to the theme's font stacks.
9
10
  // migrate [...] Reconcile tokens.css, the data tree, and route references.
10
11
 
11
12
  import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
@@ -23,6 +24,7 @@ import { runMigrateRoutes, formatRouteResult } from './migrate-routes.mjs';
23
24
  import { runCreate, formatCreateResult } from './create.mjs';
24
25
  import { runGenerateTheme, formatGenerateThemeResult } from './generate-theme.mjs';
25
26
  import { runAdjust, formatAdjustResult } from './adjust.mjs';
27
+ import { runSetFonts, formatSetFontsResult } from './set-fonts.mjs';
26
28
 
27
29
  const USAGE = `Usage: npx @motion-proto/live-tokens <command> [options]
28
30
 
@@ -47,11 +49,22 @@ Commands:
47
49
  adjust <ops.json> [--dry-run]
48
50
  Move radius, padding, gap, and border-width
49
51
  aliases along their token scales (see the
50
- live-tokens-adjust-shape-space skill). Reads each
52
+ live-tokens-adjust-geometry skill). Reads each
51
53
  component's live config and writes the result to
52
54
  that component's unsaved buffer, so save the open
53
55
  theme in the editor to keep it. --dry-run prints
54
56
  the report without writing.
57
+ set-fonts <brief.json> [--dry-run] [--no-verify]
58
+ Bind Google Fonts families to --font-display,
59
+ --font-sans, --font-serif and --font-mono (see
60
+ the live-tokens-pair-fonts skill). Each family is
61
+ verified against the Google Fonts API and the URL
62
+ is negotiated from the weights it actually has.
63
+ Writes the result to the unsaved colors-and-type
64
+ buffer, so save the open theme in the editor to
65
+ keep it. --dry-run prints the report without
66
+ writing; --no-verify skips the network and
67
+ requires an explicit URL per family.
55
68
  migrate [--check] [--write] [--tokens <path>]
56
69
  Reconcile your project with the installed package:
57
70
  applies additive tokens.css migrations, moves a
@@ -148,6 +161,30 @@ if (command === 'adjust') {
148
161
  }
149
162
  }
150
163
 
164
+ if (command === 'set-fonts') {
165
+ const briefPath = rest.find((a) => !a.startsWith('-'));
166
+ if (!briefPath) {
167
+ fail(`Usage: npx @motion-proto/live-tokens set-fonts <brief.json> [--dry-run] [--no-verify]`);
168
+ }
169
+ if (rest.includes('--no-activate')) {
170
+ fail(
171
+ `set-fonts has no --no-activate: it edits the open buffer, which is what the page already runs. ` +
172
+ `Drop the flag and re-run.`,
173
+ );
174
+ }
175
+ try {
176
+ const result = await runSetFonts({
177
+ briefPath,
178
+ dryRun: rest.includes('--dry-run'),
179
+ verify: !rest.includes('--no-verify'),
180
+ });
181
+ console.log(formatSetFontsResult(result));
182
+ process.exit(0);
183
+ } catch (err) {
184
+ fail(`set-fonts failed: ${err instanceof Error ? err.message : String(err)}`);
185
+ }
186
+ }
187
+
151
188
  if (command === 'migrate') {
152
189
  const check = rest.includes('--check');
153
190
  const write = rest.includes('--write');
@@ -229,8 +266,9 @@ const SAMPLE_PROMPTS = {
229
266
  'live-tokens-build-page': 'build a pricing page using live-tokens components',
230
267
  'live-tokens-pick-component': "what's the difference between TabBar and SegmentedControl?",
231
268
  'live-tokens-create-component': 'author a new Toggle component for my live-tokens project',
232
- 'live-tokens-generate-theme': 'make me a bright and cheerful color theme',
233
- 'live-tokens-adjust-shape-space': 'make the buttons pill shaped',
269
+ 'live-tokens-generate-theme': 'make me a bright and cheerful theme',
270
+ 'live-tokens-adjust-geometry': 'make the buttons pill shaped',
271
+ 'live-tokens-pair-fonts': 'pair some fonts for this theme',
234
272
  };
235
273
 
236
274
  const installedSamples = skills
@@ -0,0 +1,280 @@
1
+ // `live-tokens set-fonts` worker.
2
+ //
3
+ // Reads a pairing brief (JSON), verifies each family against Google Fonts,
4
+ // binds it to a font stack via the compiled engine (dist-plugin/fontPairing —
5
+ // the CLI never imports TS sources), and writes the result into
6
+ // `colors-and-type/_working.json`: the same buffer the editor's own font edits
7
+ // land in, so a retype is an unsaved edit the user saves into a theme when they
8
+ // want to keep it. Named colors-and-type files, themes, tokens.css and
9
+ // fonts.css are never touched, and nothing is activated.
10
+
11
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join, relative, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ import { resolveTokensCssPath } from './migrate.mjs';
16
+
17
+ const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const ENGINE = resolve(pkgRoot, 'dist-plugin/fontPairing/index.js');
19
+ const packageDataDir = join(pkgRoot, 'src/live-tokens/data');
20
+
21
+ // Google serves a different stylesheet per user agent; ask as a browser would
22
+ // so the weight census matches what the page will actually load.
23
+ const BROWSER_UA =
24
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
25
+
26
+ const SOURCE_LABELS = {
27
+ working: 'your unsaved edits',
28
+ theme: 'the open theme',
29
+ default: 'the package default',
30
+ };
31
+
32
+ async function loadEngine() {
33
+ if (!existsSync(ENGINE)) {
34
+ throw new Error(
35
+ `font pairing engine not found at ${relative(process.cwd(), ENGINE)}. ` +
36
+ `Build the plugin first (npm run build:plugin).`,
37
+ );
38
+ }
39
+ return import(ENGINE);
40
+ }
41
+
42
+ function readJson(path) {
43
+ return JSON.parse(readFileSync(path, 'utf8'));
44
+ }
45
+
46
+ function readJsonIfExists(path) {
47
+ return existsSync(path) ? readJson(path) : null;
48
+ }
49
+
50
+ function browserFetch(url) {
51
+ return fetch(url, { headers: { 'user-agent': BROWSER_UA } });
52
+ }
53
+
54
+ /** The open theme, read the way every other door reads it: the local file
55
+ * first, then the copy the installed package ships. */
56
+ function readActiveTheme(themesDir) {
57
+ const slug = readJsonIfExists(join(themesDir, '_active.json'))?.activeFile ?? 'default';
58
+ const theme =
59
+ readJsonIfExists(join(themesDir, `${slug}.json`)) ??
60
+ readJsonIfExists(join(packageDataDir, 'themes', `${slug}.json`));
61
+ return theme ? { slug, theme } : null;
62
+ }
63
+
64
+ /** Mirrors the dev server's live colors-and-type resolution: the unsaved
65
+ * buffer, then the open theme's own copy, then the package default. */
66
+ function readLiveColorsAndType(colorsAndTypeDir, active) {
67
+ const working = readJsonIfExists(join(colorsAndTypeDir, '_working.json'));
68
+ if (working) return { colorsAndType: working, source: 'working' };
69
+ if (active?.theme?.colorsAndType) return { colorsAndType: active.theme.colorsAndType, source: 'theme' };
70
+ const shipped =
71
+ readJsonIfExists(join(colorsAndTypeDir, 'default.json')) ??
72
+ readJsonIfExists(join(packageDataDir, 'colors-and-type', 'default.json'));
73
+ if (!shipped) throw new Error(`no colors and type to read at ${colorsAndTypeDir}`);
74
+ return { colorsAndType: shipped, source: 'default' };
75
+ }
76
+
77
+ function normalizeFace(slot, value) {
78
+ if (typeof value === 'string') return { name: value };
79
+ if (value && typeof value === 'object' && typeof value.name === 'string') {
80
+ return { name: value.name, ...(value.url ? { url: value.url } : {}) };
81
+ }
82
+ throw new Error(`brief slot "${slot}" must be a family name or { "name": "...", "url": "..." }`);
83
+ }
84
+
85
+ function sameJson(a, b) {
86
+ return JSON.stringify(a) === JSON.stringify(b);
87
+ }
88
+
89
+ /** `engine` and `fetcher` are test seams; the CLI always runs the compiled
90
+ * bundle against the live API. */
91
+ export async function runSetFonts({
92
+ briefPath,
93
+ dryRun = false,
94
+ verify = true,
95
+ root = process.cwd(),
96
+ colorsAndTypeDir,
97
+ themesDir,
98
+ tokensCssPath,
99
+ engine,
100
+ fetcher = browserFetch,
101
+ } = {}) {
102
+ const {
103
+ applyFontPairing,
104
+ resolveGoogleFont,
105
+ requiredWeights,
106
+ weightCoverage,
107
+ SLOT_ORDER,
108
+ SLOT_VARIABLES,
109
+ readLiveTokensConfig,
110
+ resolveDataDirs,
111
+ } = engine ?? (await loadEngine());
112
+
113
+ const briefFull = resolve(root, briefPath);
114
+ if (!existsSync(briefFull)) throw new Error(`brief file not found at ${relative(root, briefFull)}`);
115
+ let brief;
116
+ try {
117
+ brief = readJson(briefFull);
118
+ } catch (err) {
119
+ throw new Error(`brief file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
120
+ }
121
+
122
+ const requested = SLOT_ORDER.filter((slot) => brief[slot] !== undefined).map((slot) => ({
123
+ slot,
124
+ face: normalizeFace(slot, brief[slot]),
125
+ }));
126
+ if (requested.length === 0) {
127
+ throw new Error(`brief names no slot. Use one or more of: ${SLOT_ORDER.join(', ')}.`);
128
+ }
129
+
130
+ const resolved = colorsAndTypeDir && themesDir ? null : resolveDataDirs();
131
+ const colorsDir = colorsAndTypeDir ?? resolved.colorsAndTypeDir;
132
+ const themes = themesDir ?? resolved.themesDir;
133
+ if (!existsSync(colorsDir)) {
134
+ throw new Error(`no colors and type at ${relative(root, colorsDir)}. Run the dev server once to create it.`);
135
+ }
136
+
137
+ const active = readActiveTheme(themes);
138
+ const { colorsAndType, source } = readLiveColorsAndType(colorsDir, active);
139
+
140
+ const pairing = {};
141
+ const faces = [];
142
+ for (const { slot, face } of requested) {
143
+ if (!verify) {
144
+ if (!face.url) {
145
+ throw new Error(
146
+ `--no-verify needs an explicit URL for "${slot}". Give it as ` +
147
+ `{ "name": "${face.name}", "url": "https://fonts.googleapis.com/css2?..." } ` +
148
+ `or drop the flag and let the CLI negotiate one.`,
149
+ );
150
+ }
151
+ pairing[slot] = { name: face.name, url: face.url };
152
+ faces.push({ slot, name: face.name, url: face.url, weights: [], italics: false, verified: false });
153
+ continue;
154
+ }
155
+ const found = face.url
156
+ ? { name: face.name, url: face.url, weights: [], italics: false }
157
+ : await resolveGoogleFont(face.name, fetcher);
158
+ pairing[slot] = { name: found.name, url: found.url, weights: found.weights, italics: found.italics };
159
+ faces.push({ slot, ...found, verified: true, pinned: Boolean(face.url) });
160
+ }
161
+
162
+ const { colorsAndType: next, report } = applyFontPairing(colorsAndType, pairing);
163
+
164
+ const tokensPath = resolveTokensCssPath(tokensCssPath, readLiveTokensConfig().tokensCssPath, root);
165
+ let coverage = [];
166
+ if (tokensPath && existsSync(tokensPath)) {
167
+ const covered = {};
168
+ for (const face of faces) {
169
+ if (!face.verified || face.weights.length === 0) continue;
170
+ covered[SLOT_VARIABLES[face.slot]] = { name: face.name, weights: face.weights, italics: face.italics };
171
+ }
172
+ coverage = weightCoverage(
173
+ requiredWeights(readFileSync(tokensPath, 'utf8'), next.cssVariables ?? {}),
174
+ covered,
175
+ );
176
+ }
177
+
178
+ const workingPath = join(colorsDir, '_working.json');
179
+ const savedColorsAndType =
180
+ active?.theme?.colorsAndType ??
181
+ readJsonIfExists(join(colorsDir, 'default.json')) ??
182
+ readJsonIfExists(join(packageDataDir, 'colors-and-type', 'default.json'));
183
+
184
+ // Returning the buffer to what the open theme already holds is a discard,
185
+ // not an edit — the same call the dev server's own PUT makes.
186
+ const backToSaved = savedColorsAndType !== null && sameJson(next, savedColorsAndType);
187
+ let wrote = null;
188
+ if (!dryRun && report.changed) {
189
+ if (backToSaved) {
190
+ if (existsSync(workingPath)) rmSync(workingPath);
191
+ wrote = 'cleared';
192
+ } else {
193
+ writeFileSync(workingPath, JSON.stringify(next, null, 2) + '\n');
194
+ wrote = 'buffer';
195
+ }
196
+ }
197
+
198
+ return {
199
+ briefPath: briefFull,
200
+ colorsAndTypeDir: colorsDir,
201
+ workingPath,
202
+ openTheme: active?.slug ?? null,
203
+ source,
204
+ verified: verify,
205
+ dryRun,
206
+ faces,
207
+ changes: report.changes,
208
+ dropped: report.dropped,
209
+ coverage,
210
+ changed: report.changed,
211
+ wrote,
212
+ };
213
+ }
214
+
215
+ export function formatSetFontsResult(result) {
216
+ const root = process.cwd();
217
+ const lines = [];
218
+
219
+ if (!result.changed) {
220
+ lines.push('Nothing to change: those faces are already bound to those stacks.');
221
+ return lines.join('\n');
222
+ }
223
+
224
+ const from =
225
+ result.source === 'theme' && result.openTheme
226
+ ? `theme "${result.openTheme}"`
227
+ : SOURCE_LABELS[result.source];
228
+ const verb = result.dryRun ? 'Would set' : 'Set';
229
+ lines.push(`${verb} ${result.changes.length} font stack(s), reading from ${from}.`);
230
+
231
+ const width = Math.max(0, ...result.changes.map((c) => c.variable.length));
232
+ for (const change of result.changes) {
233
+ lines.push(` ${change.variable.padEnd(width)} ${change.from ?? '(none)'} → ${change.to}`);
234
+ }
235
+
236
+ for (const face of result.faces) {
237
+ if (!face.verified) continue;
238
+ const weights = face.weights.length > 0 ? `${face.weights.join(', ')}` : 'single weight';
239
+ lines.push(`\n${face.name} (${face.slot})`);
240
+ lines.push(` weights: ${weights}${face.italics ? ' + italics' : ', no italics'}`);
241
+ lines.push(` ${face.url}`);
242
+ }
243
+
244
+ if (result.dropped.length > 0) {
245
+ lines.push(
246
+ `\nDropped ${result.dropped.length} source(s) no stack references any more: ` +
247
+ `${result.dropped.map((d) => d.names.join(', ')).join('; ')}.`,
248
+ );
249
+ }
250
+
251
+ const gaps = result.coverage.filter((c) => c.missing.length > 0);
252
+ if (gaps.length > 0) {
253
+ lines.push('\nWeight coverage:');
254
+ for (const gap of gaps) {
255
+ lines.push(
256
+ ` ${gap.family} (${gap.stack}) has no ${gap.missing.join(', ')}; ` +
257
+ `tokens ask for ${gap.required.join(', ')}. The browser will synthesize the rest.`,
258
+ );
259
+ }
260
+ }
261
+ const body = result.coverage.find((c) => c.stack === '--font-sans');
262
+ if (body && !body.italics) {
263
+ lines.push(`\n${body.family} has no italics. Emphasis in body text will be synthesized.`);
264
+ }
265
+
266
+ if (result.wrote === 'buffer') {
267
+ lines.push(
268
+ `\nReload the app to see it. This is an unsaved edit: save the open theme in the ` +
269
+ `editor's Theme panel to keep it, or load a theme to discard it.`,
270
+ );
271
+ } else if (result.wrote === 'cleared') {
272
+ lines.push(
273
+ `\nThat is what the open theme already holds, so the unsaved buffer was discarded. ` +
274
+ `Reload the app to see it.`,
275
+ );
276
+ } else if (result.dryRun) {
277
+ lines.push(`\nDry run: nothing written under ${relative(root, result.colorsAndTypeDir)}.`);
278
+ }
279
+ return lines.join('\n');
280
+ }
@@ -1,6 +1,6 @@
1
- import { A as AliasDiskValue, b as ComponentConfig } from '../index-DpTIRZ2H.cjs';
2
- export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-DpTIRZ2H.cjs';
1
+ import { A as AliasDiskValue, a as ComponentConfig } from '../themeTypes-BAqtv4XO.cjs';
3
2
  export { r as readLiveTokensConfig, a as resolveDataDirs } from '../dataPaths-DBN0RPuT.cjs';
3
+ export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-4N-Orzzi.cjs';
4
4
 
5
5
  type AdjustKind = 'radius' | 'padding' | 'gap' | 'border-width';
6
6
  interface AdjustOp {
@@ -1,6 +1,6 @@
1
- import { A as AliasDiskValue, b as ComponentConfig } from '../index-DpTIRZ2H.js';
2
- export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-DpTIRZ2H.js';
1
+ import { A as AliasDiskValue, a as ComponentConfig } from '../themeTypes-BAqtv4XO.js';
3
2
  export { r as readLiveTokensConfig, a as resolveDataDirs } from '../dataPaths-DBN0RPuT.js';
3
+ export { C as CURRENT_COMPONENT_SCHEMA_VERSION } from '../index-4N-Orzzi.js';
4
4
 
5
5
  type AdjustKind = 'radius' | 'padding' | 'gap' | 'border-width';
6
6
  interface AdjustOp {