@motion-proto/live-tokens 0.20.0 → 0.21.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/README.md CHANGED
@@ -216,18 +216,20 @@ export default defineConfig({
216
216
 
217
217
  No `css: 'injected'` workaround, no `optimizeDeps` excludes — `vite build` works as-is. (You'll want the full `themeFileApi` plugin and `bootLiveTokens` / `<LiveTokensRouter>` from the Quick install section above when you're ready to persist edits to disk and ship a real app.)
218
218
 
219
- ## Greenfield? Use the starter
219
+ ## Greenfield? Scaffold a new app
220
220
 
221
- If you're starting from scratch, skip the manual wiring and clone the repo as a template — `App.svelte`, `main.ts`, the router, and a placeholder `Home.svelte` saying "put your content here" are all pre-wired.
221
+ If you're starting from scratch, skip the manual wiring:
222
222
 
223
223
  ```bash
224
- npx degit motionproto/live-tokens my-app
224
+ npx @motion-proto/live-tokens create my-app
225
225
  cd my-app
226
226
  npm install
227
227
  npm run dev
228
228
  ```
229
229
 
230
- Open http://localhost:5173 and replace `src/app/Home.svelte` with your content. The rest of the wiring is already done it's the same code the npm package ships, just with the App-shell scaffolding included.
230
+ This generates a Svelte + Vite app that **depends on** the package — `vite.config.ts`, `main.ts`, `App.svelte`, the `themeFileApi` plugin, and a placeholder `src/pages/Home.svelte` are all pre-wired. The token CSS is seeded from the version you scaffolded against, so it never drifts. Open http://localhost:5173 and replace `Home.svelte` with your content; upgrade the package later with `npm update`.
231
+
232
+ (The older `npx degit motionproto/live-tokens` route cloned this whole repo as your app — the package's source, tests, and all. `create` gives you a thin consumer app instead.)
231
233
 
232
234
  ## Consumer-authored components
233
235
 
package/bin/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // CLI for @motion-proto/live-tokens.
3
3
  // Subcommands:
4
+ // create <dir> Scaffold a new app that depends on this package.
4
5
  // setup-claude [--force] Copy bundled Claude Code skills into ./.claude/skills/.
5
6
  // check-component <id> Validate a component against the add-component skill contract.
6
7
 
@@ -10,10 +11,13 @@ import { fileURLToPath } from 'node:url';
10
11
  import process from 'node:process';
11
12
  import { checkComponent, formatReport } from './check-component.mjs';
12
13
  import { runMigrate, formatMigrateResult } from './migrate.mjs';
14
+ import { runCreate, formatCreateResult } from './create.mjs';
13
15
 
14
16
  const USAGE = `Usage: npx @motion-proto/live-tokens <command> [options]
15
17
 
16
18
  Commands:
19
+ create <dir> [--force] Scaffold a new Svelte + Vite app wired up with
20
+ live-tokens (editor, components, theme tokens)
17
21
  setup-claude [--force] Install bundled Claude Code skills into ./.claude/skills/
18
22
  check-component <id> Validate <id>'s runtime, editor, and registration
19
23
  against the live-tokens-create-component contract
@@ -35,6 +39,24 @@ if (!command || command === '--help' || command === '-h') {
35
39
  process.exit(0);
36
40
  }
37
41
 
42
+ const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
43
+
44
+ if (command === 'create' || command === 'init') {
45
+ const targetArg = rest.find((a) => !a.startsWith('-'));
46
+ if (!targetArg) {
47
+ fail(`Usage: npx @motion-proto/live-tokens create <project-directory>`);
48
+ }
49
+ const force = rest.includes('--force');
50
+ const targetDir = resolve(process.cwd(), targetArg);
51
+ try {
52
+ const result = runCreate({ targetDir, pkgRoot, force });
53
+ console.log(formatCreateResult(result, targetArg));
54
+ process.exit(0);
55
+ } catch (err) {
56
+ fail(err instanceof Error ? err.message : String(err));
57
+ }
58
+ }
59
+
38
60
  if (command === 'check-component') {
39
61
  const id = rest[0];
40
62
  if (!id) fail(`Usage: npx @motion-proto/live-tokens check-component <id>`);
@@ -70,7 +92,6 @@ if (process.platform === 'win32') {
70
92
 
71
93
  const force = rest.includes('--force');
72
94
 
73
- const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
74
95
  const srcSkills = join(pkgRoot, '.claude', 'skills');
75
96
 
76
97
  if (!existsSync(srcSkills)) {
package/bin/create.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // `create` subcommand: scaffold a new Svelte + Vite app that depends on the
2
+ // published package. Extracted from cli.mjs so it can be unit-tested.
3
+
4
+ import {
5
+ cpSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ renameSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { dirname, join, resolve } from 'node:path';
14
+
15
+ // npm strips dotfiles from published tarballs, so the template ships them
16
+ // renamed; `create` restores the leading dot on scaffold.
17
+ const DOTFILES = [['_gitignore', '.gitignore']];
18
+
19
+ // Seeded from THIS installed package (src → scaffold) so the generated app's
20
+ // token/theme CSS never drifts from the version it was created against.
21
+ const SEEDS = [
22
+ ['src/system/styles/tokens.css', 'src/system/styles/tokens.css'],
23
+ ['src/live-tokens/data/tokens.generated.css', 'src/live-tokens/data/tokens.generated.css'],
24
+ ['src/app/site.css', 'src/styles/site.css'],
25
+ ];
26
+
27
+ const PLACEHOLDER_FILES = ['package.json', 'index.html', 'README.md'];
28
+
29
+ export function appNameFrom(targetDir) {
30
+ return (
31
+ (targetDir.split(/[/\\]/).filter(Boolean).pop() || '')
32
+ .replace(/[^a-z0-9._-]+/gi, '-')
33
+ .replace(/^[-.]+|[-.]+$/g, '')
34
+ .toLowerCase() || 'live-tokens-app'
35
+ );
36
+ }
37
+
38
+ export function runCreate({ targetDir, pkgRoot, force = false }) {
39
+ const templateDir = join(pkgRoot, 'template');
40
+ if (!existsSync(templateDir)) {
41
+ throw new Error(`Template not found at ${templateDir}. Is the package installed correctly?`);
42
+ }
43
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0 && !force) {
44
+ throw new Error(`${targetDir} is not empty. Pass --force to scaffold into it anyway.`);
45
+ }
46
+
47
+ const appName = appNameFrom(targetDir);
48
+ const ltVersion = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version;
49
+
50
+ cpSync(templateDir, targetDir, { recursive: true });
51
+
52
+ for (const [from, to] of DOTFILES) {
53
+ const src = join(targetDir, from);
54
+ if (existsSync(src)) renameSync(src, join(targetDir, to));
55
+ }
56
+
57
+ for (const [srcRel, destRel] of SEEDS) {
58
+ const src = join(pkgRoot, srcRel);
59
+ const dest = join(targetDir, destRel);
60
+ mkdirSync(dirname(dest), { recursive: true });
61
+ if (existsSync(src)) cpSync(src, dest);
62
+ else writeFileSync(dest, '');
63
+ }
64
+
65
+ for (const rel of PLACEHOLDER_FILES) {
66
+ const p = join(targetDir, rel);
67
+ if (!existsSync(p)) continue;
68
+ const filled = readFileSync(p, 'utf8')
69
+ .replaceAll('__APP_NAME__', appName)
70
+ .replaceAll('__LT_VERSION__', `^${ltVersion}`);
71
+ writeFileSync(p, filled);
72
+ }
73
+
74
+ return { appName, targetDir: resolve(targetDir), ltVersion };
75
+ }
76
+
77
+ export function formatCreateResult({ appName, targetDir }, targetArg) {
78
+ return [
79
+ ``,
80
+ `Scaffolded ${appName} → ${targetDir}`,
81
+ ``,
82
+ `Next steps:`,
83
+ ` cd ${targetArg}`,
84
+ ` npm install`,
85
+ ` npm run dev`,
86
+ ``,
87
+ `Then open http://localhost:5173 and edit src/pages/Home.svelte.`,
88
+ ].join('\n');
89
+ }
@@ -52,6 +52,14 @@ function extractGlobalRootBody(source) {
52
52
  return bodies.join("\n");
53
53
  }
54
54
 
55
+ // src/editor/core/themes/parsers/colorOpacity.ts
56
+ var COLOR_OPACITY_RE = /^color-mix\(in srgb,\s*var\((--[a-z0-9-]+)\)\s+(\d+)%,\s*transparent\)$/i;
57
+ function parseColorOpacity(value) {
58
+ const m = value.trim().match(COLOR_OPACITY_RE);
59
+ if (!m) return null;
60
+ return { name: m[1], opacity: parseInt(m[2], 10) };
61
+ }
62
+
55
63
  // src/editor/core/storage/files/versionedFileResourceClient.ts
56
64
  function sanitizeFileName(name) {
57
65
  return name.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9\-_]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "") || "unnamed";
@@ -1013,11 +1021,13 @@ function themeFileApi(opts) {
1013
1021
  }
1014
1022
  function extractAliasDeclarations(body) {
1015
1023
  const aliases = {};
1016
- const re = /(--[a-z0-9-]+)\s*:\s*(var\(--[a-z0-9-]+\)|color-mix\(in srgb,\s*var\(--[a-z0-9-]+\)\s+\d+%,\s*transparent\))\s*;/gi;
1024
+ const re = /(--[a-z0-9-]+)\s*:\s*([^;]+);/gi;
1017
1025
  let m;
1018
1026
  while ((m = re.exec(body)) !== null) {
1019
- const plain = m[2].match(/^var\((--[a-z0-9-]+)\)$/i);
1020
- aliases[m[1]] = plain ? plain[1] : m[2];
1027
+ const value = m[2].trim();
1028
+ const plain = value.match(/^var\((--[a-z0-9-]+)\)$/i);
1029
+ if (plain) aliases[m[1]] = plain[1];
1030
+ else if (parseColorOpacity(value)) aliases[m[1]] = value;
1021
1031
  }
1022
1032
  return aliases;
1023
1033
  }
@@ -10,6 +10,14 @@ import {
10
10
  import fs2 from "fs";
11
11
  import path2 from "path";
12
12
 
13
+ // src/editor/core/themes/parsers/colorOpacity.ts
14
+ var COLOR_OPACITY_RE = /^color-mix\(in srgb,\s*var\((--[a-z0-9-]+)\)\s+(\d+)%,\s*transparent\)$/i;
15
+ function parseColorOpacity(value) {
16
+ const m = value.trim().match(COLOR_OPACITY_RE);
17
+ if (!m) return null;
18
+ return { name: m[1], opacity: parseInt(m[2], 10) };
19
+ }
20
+
13
21
  // src/editor/core/storage/files/versionedFileResourceClient.ts
14
22
  function sanitizeFileName(name) {
15
23
  return name.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9\-_]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "") || "unnamed";
@@ -753,11 +761,13 @@ function themeFileApi(opts) {
753
761
  }
754
762
  function extractAliasDeclarations(body) {
755
763
  const aliases = {};
756
- const re = /(--[a-z0-9-]+)\s*:\s*(var\(--[a-z0-9-]+\)|color-mix\(in srgb,\s*var\(--[a-z0-9-]+\)\s+\d+%,\s*transparent\))\s*;/gi;
764
+ const re = /(--[a-z0-9-]+)\s*:\s*([^;]+);/gi;
757
765
  let m;
758
766
  while ((m = re.exec(body)) !== null) {
759
- const plain = m[2].match(/^var\((--[a-z0-9-]+)\)$/i);
760
- aliases[m[1]] = plain ? plain[1] : m[2];
767
+ const value = m[2].trim();
768
+ const plain = value.match(/^var\((--[a-z0-9-]+)\)$/i);
769
+ if (plain) aliases[m[1]] = plain[1];
770
+ else if (parseColorOpacity(value)) aliases[m[1]] = value;
761
771
  }
762
772
  return aliases;
763
773
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -26,6 +26,7 @@
26
26
  "dist-plugin",
27
27
  ".claude/skills",
28
28
  "bin",
29
+ "template",
29
30
  "!**/*.test.ts",
30
31
  "!**/*.spec.ts",
31
32
  "!**/__tests__/**",
@@ -89,7 +90,8 @@
89
90
  "check:no-style-imports": "node scripts/check-no-style-imports.mjs",
90
91
  "check:editor-font-isolation": "node scripts/check-editor-font-isolation.mjs",
91
92
  "check:smoke-install": "bash scripts/smoke-install.sh",
92
- "prepublishOnly": "npm run check:no-style-imports && npm run check:editor-font-isolation && npm run build:lib && npm run check:smoke-install"
93
+ "check:smoke-create": "bash scripts/smoke-create.sh",
94
+ "prepublishOnly": "npm run check:no-style-imports && npm run check:editor-font-isolation && npm run build:lib && npm run check:smoke-install && npm run check:smoke-create"
93
95
  },
94
96
  "peerDependencies": {
95
97
  "@sveltejs/vite-plugin-svelte": "^7.0",
@@ -26,7 +26,7 @@
26
26
  import { listManifests, saveAsManifest } from '../../core/manifests/manifestService';
27
27
  import type { ManifestMeta } from '../../core/themes/themeTypes';
28
28
  import { CURRENT_COMPONENT_SCHEMA_VERSION } from '../../core/themes/migrations';
29
- import type { CssVarRef } from '../../core/store/editorTypes';
29
+ import { refToDiskValue } from '../../core/store/cssVarRef';
30
30
  import { safeFetch } from '../../core/storage/storage';
31
31
  import { API_BASE } from '../../core/storage/apiBase';
32
32
  import { flashStatus } from '../../core/flashStatus';
@@ -126,12 +126,6 @@
126
126
  }
127
127
  }
128
128
 
129
- function refToDiskValue(ref: CssVarRef): AliasDiskValue {
130
- if (ref.kind === 'token') return ref.name;
131
- if (ref.kind === 'literal') return ref.value;
132
- return { kind: 'gradient', value: ref.value };
133
- }
134
-
135
129
  function currentAliases(): Record<string, AliasDiskValue> {
136
130
  const slice = get(editorState).components[component];
137
131
  if (!slice) return {};
@@ -9,6 +9,7 @@
9
9
  import { mutate } from '../../core/store/editorStore';
10
10
  import { getDeclaredValue } from '../../core/palettes/tokenRegistry';
11
11
  import type { CssVarRef } from '../../core/store/editorTypes';
12
+ import { cssStringToRef, refToCss } from '../../core/store/cssVarRef';
12
13
  import { getEditorContext } from './editorContext';
13
14
  import type { Token, TypeGroupConfig } from './types';
14
15
  import type { Sibling } from './siblings';
@@ -112,9 +113,7 @@
112
113
  instead of falling back to its own family's default. */
113
114
  function declaredToRef(declared: string | null): CssVarRef | null {
114
115
  if (!declared) return null;
115
- const m = declared.match(/^\s*var\((--[a-z0-9-]+)\)\s*$/i);
116
- if (m) return { kind: 'token', name: m[1] };
117
- return { kind: 'literal', value: declared };
116
+ return cssStringToRef(declared.trim());
118
117
  }
119
118
 
120
119
  /** Extract a transparency percentage from a `color-mix(in srgb, var(--X) N%, transparent)`
@@ -179,9 +178,8 @@
179
178
  const effectiveValue = (varName: string): string | null => {
180
179
  const ref = slice.aliases[varName];
181
180
  if (!ref) return getDeclaredValue(varName);
182
- if (ref.kind === 'token') return `var(${ref.name})`;
183
- if (ref.kind === 'literal') return ref.value;
184
- return null;
181
+ if (ref.kind === 'gradient') return null;
182
+ return refToCss(ref);
185
183
  };
186
184
 
187
185
  const apply = (srcVar: string, dstVar: string) => {
@@ -5,11 +5,14 @@ import {
5
5
  editorState,
6
6
  } from '../../core/store/editorStore';
7
7
  import type { CssVarRef } from '../../core/store/editorTypes';
8
+ import { refToCss } from '../../core/store/cssVarRef';
8
9
  import type { Token } from './types';
9
10
 
11
+ /** Stable per-ref identity key: refs that render to the same CSS share a key,
12
+ * so siblings with equal values bucket together regardless of ref kind. */
10
13
  function aliasKey(ref: CssVarRef | undefined): string {
11
14
  if (!ref) return '';
12
- return ref.kind === 'token' ? `t:${ref.name}` : `v:${ref.value}`;
15
+ return refToCss(ref);
13
16
  }
14
17
 
15
18
  const TYPOGRAPHY_PROP_SUFFIXES = ['font-family', 'font-size', 'font-weight', 'line-height'] as const;
@@ -1,7 +1,7 @@
1
1
  import { get } from 'svelte/store';
2
2
  import type { AliasDiskValue, ComponentConfig } from '../themes/themeTypes';
3
3
  import { editorState, markComponentSaved } from '../store/editorStore';
4
- import type { CssVarRef } from '../store/editorTypes';
4
+ import { refToDiskValue } from '../store/cssVarRef';
5
5
  import { CURRENT_COMPONENT_SCHEMA_VERSION } from '../themes/migrations';
6
6
  import {
7
7
  listComponentConfigs,
@@ -24,11 +24,6 @@ export type SaveActiveComponentResult =
24
24
  | { ok: true; fileName: string; displayName: string }
25
25
  | { ok: false; reason: 'default' | 'no-state' | 'error'; error?: unknown };
26
26
 
27
- function refToDiskValue(ref: CssVarRef): AliasDiskValue {
28
- if (ref.kind === 'token') return ref.name;
29
- if (ref.kind === 'literal') return ref.value;
30
- return { kind: 'gradient', value: ref.value };
31
- }
32
27
 
33
28
  export async function saveActiveComponentConfig(
34
29
  component: string,
@@ -23,7 +23,7 @@ import tokensCss from '../../../system/styles/tokens.css?raw';
23
23
  import { editorState } from '../store/editorStore';
24
24
  import type { EditorState } from '../store/editorTypes';
25
25
  import { extractGlobalRootBody } from '../themes/parsers/globalRootBlock';
26
- import { formatGradientValue } from '../themes/slices/gradients';
26
+ import { refToCss } from '../store/cssVarRef';
27
27
 
28
28
  // Re-exported for tests and downstream consumers that previously imported it
29
29
  // from this module. The canonical implementation lives in `./parsers/globalRootBlock`
@@ -112,9 +112,7 @@ function buildOverlayRegistry(
112
112
  const overrides = new Map<string, string>();
113
113
  for (const slice of Object.values(components)) {
114
114
  for (const [varName, ref] of Object.entries(slice.aliases)) {
115
- if (ref.kind === 'token') overrides.set(varName, `var(${ref.name})`);
116
- else if (ref.kind === 'literal') overrides.set(varName, ref.value);
117
- else overrides.set(varName, formatGradientValue(ref.value));
115
+ overrides.set(varName, refToCss(ref));
118
116
  }
119
117
  }
120
118
  const getDeclared = (v: string): string | null =>
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The single classifier + renderer for `CssVarRef`. Every place that turns a
3
+ * CSS value string into a ref, or a ref back into CSS, routes through here so
4
+ * the three ref kinds (`token`, `literal`, `gradient`) are interpreted
5
+ * identically across the disk loader, the live-edit path, the registry, and the
6
+ * component renderer.
7
+ *
8
+ * A `token` may carry an optional `opacity` (a colour below 100%). Opacity
9
+ * serialization is delegated to `parsers/colorOpacity`, the one place that knows
10
+ * the `color-mix(in srgb, var(--token) NN%, transparent)` shape.
11
+ */
12
+ import type { CssVarRef } from './editorTypes';
13
+ import type { AliasDiskValue } from '../themes/themeTypes';
14
+ import { formatGradientValue } from '../themes/slices/gradients';
15
+ import { formatColorOpacity, parseColorOpacity } from '../themes/parsers/colorOpacity';
16
+
17
+ /** True when a token carries a non-trivial opacity (a colour below 100%). */
18
+ function hasOpacity(opacity: number | undefined): opacity is number {
19
+ return opacity != null && opacity < 100;
20
+ }
21
+
22
+ /** Render a ref to the CSS value string written to a custom property. */
23
+ export function refToCss(ref: CssVarRef): string {
24
+ switch (ref.kind) {
25
+ case 'token':
26
+ return hasOpacity(ref.opacity) ? formatColorOpacity(ref.name, ref.opacity) : `var(${ref.name})`;
27
+ case 'literal':
28
+ return ref.value;
29
+ case 'gradient':
30
+ return formatGradientValue(ref.value);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Classify a CSS value string into a ref. Accepts a bare token name (`--x`,
36
+ * the disk convention), a wrapped alias (`var(--x)`, the declared-value form),
37
+ * a colour-opacity expression, or anything else (a raw literal). Never produces
38
+ * a gradient — gradients are stored as structured objects, not strings.
39
+ */
40
+ export function cssStringToRef(value: string): CssVarRef {
41
+ const op = parseColorOpacity(value);
42
+ if (op) return { kind: 'token', name: op.name, opacity: op.opacity };
43
+ if (value.startsWith('--')) return { kind: 'token', name: value };
44
+ const wrapped = value.match(/^var\((--[a-z0-9-]+)\)$/);
45
+ if (wrapped) return { kind: 'token', name: wrapped[1] };
46
+ return { kind: 'literal', value };
47
+ }
48
+
49
+ /**
50
+ * Serialize a ref to its on-disk `AliasDiskValue`. Mirrors `refToCss` except a
51
+ * token is stored as its bare name (`--x`, not `var(--x)`) — the editor's disk
52
+ * convention — and a gradient is a structured object rather than a CSS string.
53
+ */
54
+ export function refToDiskValue(ref: CssVarRef): AliasDiskValue {
55
+ switch (ref.kind) {
56
+ case 'token':
57
+ return hasOpacity(ref.opacity) ? formatColorOpacity(ref.name, ref.opacity) : ref.name;
58
+ case 'literal':
59
+ return ref.value;
60
+ case 'gradient':
61
+ return { kind: 'gradient', value: ref.value };
62
+ }
63
+ }
64
+
65
+ /** Structural equality across all ref kinds. */
66
+ export function cssVarRefEqual(a: CssVarRef | undefined, b: CssVarRef | undefined): boolean {
67
+ if (!a || !b) return a === b;
68
+ if (a.kind !== b.kind) return false;
69
+ if (a.kind === 'token') {
70
+ const bt = b as { kind: 'token'; name: string; opacity?: number };
71
+ return a.name === bt.name && (a.opacity ?? 100) === (bt.opacity ?? 100);
72
+ }
73
+ if (a.kind === 'literal') return a.value === (b as { kind: 'literal'; value: string }).value;
74
+ const av = a.value;
75
+ const bv = (b as { kind: 'gradient'; value: typeof a.value }).value;
76
+ if (av.type !== bv.type || av.angle !== bv.angle || av.stops.length !== bv.stops.length) return false;
77
+ if ((av.aspectX ?? 1) !== (bv.aspectX ?? 1)) return false;
78
+ if ((av.aspectY ?? 1) !== (bv.aspectY ?? 1)) return false;
79
+ for (let i = 0; i < av.stops.length; i++) {
80
+ const sa = av.stops[i];
81
+ const sb = bv.stops[i];
82
+ if (sa.position !== sb.position || sa.color !== sb.color || (sa.opacity ?? 100) !== (sb.opacity ?? 100)) {
83
+ return false;
84
+ }
85
+ }
86
+ return true;
87
+ }
@@ -21,6 +21,7 @@
21
21
  import type { CssVarRef, EditorState, GradientAliasValue } from './editorTypes';
22
22
  import type { AliasDiskValue, Theme } from '../themes/themeTypes';
23
23
  import { KNOWN_COMPONENT_CONFIG_KEYS } from '../components/componentConfigKeys';
24
+ import { cssStringToRef } from './cssVarRef';
24
25
  import {
25
26
  CURRENT_THEME_SCHEMA_VERSION,
26
27
  CURRENT_COMPONENT_SCHEMA_VERSION,
@@ -421,9 +422,7 @@ function splitAliasesAndConfig(
421
422
  if (config[key] === undefined) config[key] = value;
422
423
  continue;
423
424
  }
424
- aliases[key] = value.startsWith('--')
425
- ? { kind: 'token', name: value }
426
- : { kind: 'literal', value };
425
+ aliases[key] = cssStringToRef(value);
427
426
  }
428
427
  return { aliases, config };
429
428
  }
@@ -100,7 +100,11 @@ export interface GradientAliasValue {
100
100
  }
101
101
 
102
102
  export type CssVarRef =
103
- | { kind: 'token'; name: string }
103
+ /** An alias to a design token. `opacity` is an optional integer percent set
104
+ * only for a colour carried below 100% (serializes to
105
+ * `color-mix(in srgb, var(name) opacity%, transparent)`); absent means fully
106
+ * opaque, which also covers every non-colour alias (radius, spacing, font). */
107
+ | { kind: 'token'; name: string; opacity?: number }
104
108
  | { kind: 'literal'; value: string }
105
109
  | { kind: 'gradient'; value: GradientAliasValue };
106
110
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The single source of truth for a design-token colour carried at reduced
3
+ * opacity. A colour token below 100% opacity serializes as:
4
+ *
5
+ * color-mix(in srgb, var(--token) NN%, transparent)
6
+ *
7
+ * 100% opacity is NOT represented here — a fully opaque colour collapses to a
8
+ * plain `var(--token)` alias (`CssVarRef` kind `'token'`). Everything that
9
+ * reads, writes, or validates this form must go through `parseColorOpacity` /
10
+ * `formatColorOpacity` so the canonical shape lives in exactly one place.
11
+ *
12
+ * This module is intentionally dependency-free so the dev-server vite plugin
13
+ * (a separate build) can import it the same way it imports `globalRootBlock`.
14
+ */
15
+
16
+ /** `opacity` is an integer percentage in [0, 100). */
17
+ export interface ColorOpacity {
18
+ /** The design-token name, e.g. `--surface-neutral-lower`. */
19
+ name: string;
20
+ /** Integer percentage, 0–99 (100 is not an opacity colour — it's a token). */
21
+ opacity: number;
22
+ }
23
+
24
+ const COLOR_OPACITY_RE =
25
+ /^color-mix\(in srgb,\s*var\((--[a-z0-9-]+)\)\s+(\d+)%,\s*transparent\)$/i;
26
+
27
+ /**
28
+ * Parse a `color-mix(in srgb, var(--token) NN%, transparent)` string into its
29
+ * token name and integer opacity. Returns null for any other value (plain
30
+ * aliases, raw literals, gradients).
31
+ */
32
+ export function parseColorOpacity(value: string): ColorOpacity | null {
33
+ const m = value.trim().match(COLOR_OPACITY_RE);
34
+ if (!m) return null;
35
+ return { name: m[1], opacity: parseInt(m[2], 10) };
36
+ }
37
+
38
+ /** Serialize a token + integer opacity back to the canonical color-mix string. */
39
+ export function formatColorOpacity(name: string, opacity: number): string {
40
+ return `color-mix(in srgb, var(${name}) ${opacity}%, transparent)`;
41
+ }
@@ -32,7 +32,7 @@
32
32
  import { writable, derived, get, type Readable } from 'svelte/store';
33
33
  import type { CssVarRef, EditorState } from '../../store/editorTypes';
34
34
  import { store, mutate } from '../../store/editorCore';
35
- import { formatGradientValue } from './gradients';
35
+ import { refToCss, cssVarRefEqual } from '../../store/cssVarRef';
36
36
  import { CASCADING_COMPONENT_CONFIG_KEYS } from '../../components/componentConfigKeys';
37
37
 
38
38
  const EMPTY_COMPONENT_BASELINE = JSON.stringify({ aliases: {}, config: {} });
@@ -45,9 +45,7 @@ export function componentsToVars(components: EditorState['components']): Record<
45
45
  const out: Record<string, string> = {};
46
46
  for (const slice of Object.values(components)) {
47
47
  for (const [varName, ref] of Object.entries(slice.aliases)) {
48
- if (ref.kind === 'token') out[varName] = `var(${ref.name})`;
49
- else if (ref.kind === 'literal') out[varName] = ref.value;
50
- else out[varName] = formatGradientValue(ref.value);
48
+ out[varName] = refToCss(ref);
51
49
  }
52
50
  for (const [key, value] of Object.entries(slice.config)) {
53
51
  if (CASCADING_COMPONENT_CONFIG_KEYS.has(key) && typeof value === 'string') {
@@ -248,25 +246,6 @@ export function getComponentPropertySiblings(component: string, varName: string)
248
246
  return siblings;
249
247
  }
250
248
 
251
- function cssVarRefEqual(a: CssVarRef | undefined, b: CssVarRef | undefined): boolean {
252
- if (!a || !b) return a === b;
253
- if (a.kind !== b.kind) return false;
254
- if (a.kind === 'token') return a.name === (b as { kind: 'token'; name: string }).name;
255
- if (a.kind === 'literal') return a.value === (b as { kind: 'literal'; value: string }).value;
256
- // gradient: structural compare on type, angle, aspect axes, and stops.
257
- const av = a.value;
258
- const bv = (b as { kind: 'gradient'; value: typeof a.value }).value;
259
- if (av.type !== bv.type || av.angle !== bv.angle || av.stops.length !== bv.stops.length) return false;
260
- if ((av.aspectX ?? 1) !== (bv.aspectX ?? 1)) return false;
261
- if ((av.aspectY ?? 1) !== (bv.aspectY ?? 1)) return false;
262
- for (let i = 0; i < av.stops.length; i++) {
263
- const sa = av.stops[i];
264
- const sb = bv.stops[i];
265
- if (sa.position !== sb.position || sa.color !== sb.color || (sa.opacity ?? 100) !== (sb.opacity ?? 100)) return false;
266
- }
267
- return true;
268
- }
269
-
270
249
  /** True iff `varName` is not individually opted out, has ≥2 declared siblings,
271
250
  * and the linked siblings agree — either all sharing the same explicit alias,
272
251
  * or all having no override (linked at the upstream default). */
@@ -6,6 +6,7 @@
6
6
  import { resolveAliasChain } from '../core/palettes/tokenRegistry';
7
7
  import { editorState } from '../core/store/editorStore';
8
8
  import { formatGradientStops } from '../core/themes/slices/gradients';
9
+ import { formatColorOpacity, parseColorOpacity } from '../core/themes/parsers/colorOpacity';
9
10
  import type { GradientToken } from '../core/store/editorTypes';
10
11
  import UITokenSelector from './UITokenSelector.svelte';
11
12
 
@@ -249,24 +250,19 @@
249
250
  return null;
250
251
  }
251
252
 
252
- function parseOpacity(raw: string): { inner: string; opacity: number } | null {
253
- const m = raw.match(/^color-mix\(in srgb,\s*(var\(--[a-z0-9-]+\))\s+(\d+)%,\s*transparent\)$/);
254
- if (!m) return null;
255
- return { inner: m[1], opacity: parseInt(m[2]) };
256
- }
257
-
258
253
  function parseStatic(raw: string): { name: 'white' | 'black'; opacity: number } | null {
259
254
  const direct = raw.match(/^var\(--color-(white|black)\)$/);
260
255
  if (direct) return { name: direct[1] as 'white' | 'black', opacity: 100 };
261
- const wrapped = raw.match(/^color-mix\(in srgb,\s*var\(--color-(white|black)\)\s+(\d+)%,\s*transparent\)$/);
262
- if (wrapped) return { name: wrapped[1] as 'white' | 'black', opacity: parseInt(wrapped[2]) };
256
+ const op = parseColorOpacity(raw);
257
+ const m = op?.name.match(/^--color-(white|black)$/);
258
+ if (op && m) return { name: m[1] as 'white' | 'black', opacity: op.opacity };
263
259
  return null;
264
260
  }
265
261
 
266
262
  function buildValue(varName: string): string | null {
267
263
  if (varName === variable && opacity >= 100) return null;
268
264
  if (opacity >= 100) return varName;
269
- return `color-mix(in srgb, var(${varName}) ${opacity}%, transparent)`;
265
+ return formatColorOpacity(varName, opacity);
270
266
  }
271
267
 
272
268
  function applyOpacity() {
@@ -399,9 +395,9 @@
399
395
  }
400
396
  chosenStatic = null;
401
397
 
402
- const opacityParsed = parseOpacity(raw);
398
+ const opacityParsed = parseColorOpacity(raw);
403
399
  if (opacityParsed) {
404
- const parsed = parseRef(opacityParsed.inner);
400
+ const parsed = parseRef(`var(${opacityParsed.name})`);
405
401
  if (parsed) {
406
402
  chosenCategory = parsed.category;
407
403
  chosenFamily = parsed.family;
@@ -3,6 +3,7 @@
3
3
  import type { Snippet } from 'svelte';
4
4
  import { setCssVar, removeCssVar, CSS_VAR_CHANGE_EVENT } from '../core/cssVarSync';
5
5
  import type { CssVarRef } from '../core/store/editorTypes';
6
+ import { cssStringToRef } from '../core/store/cssVarRef';
6
7
  import {
7
8
  editorState,
8
9
  setComponentAlias,
@@ -116,14 +117,13 @@
116
117
  if (component) {
117
118
  const useLinked = isLinkedDisplay;
118
119
  if (semanticName) {
119
- // Mirror splitAliasesAndConfig: a `--…` reference becomes a token
120
- // (rendered as `var(name)`); anything else (color-mix expressions,
121
- // `transparent`, gradient tokens already wrapped) is a literal whose
122
- // value is emitted as-is. Storing complex CSS as a token would render
120
+ // Same classifier as the disk loader (cssStringToRef): a `--…`
121
+ // reference becomes a token (rendered as `var(name)`); a
122
+ // `color-mix(... var(--token) NN%, transparent)` becomes a colour ref;
123
+ // anything else (`transparent`, a materialized gradient) is a literal
124
+ // emitted as-is. Storing complex CSS as a token would render
123
125
  // `var(color-mix(...))`, which is invalid and breaks the preview.
124
- const ref: CssVarRef = semanticName.startsWith('--')
125
- ? { kind: 'token', name: semanticName }
126
- : { kind: 'literal', value: semanticName };
126
+ const ref: CssVarRef = cssStringToRef(semanticName);
127
127
  if (useLinked) setComponentAliasLinked(component, variable, ref);
128
128
  else setComponentAlias(component, variable, ref);
129
129
  } else {
@@ -0,0 +1,36 @@
1
+ # __APP_NAME__
2
+
3
+ Scaffolded with [`@motion-proto/live-tokens`](https://github.com/motionproto/live-tokens).
4
+
5
+ ```bash
6
+ npm install
7
+ npm run dev
8
+ ```
9
+
10
+ Open http://localhost:5173.
11
+
12
+ ## What you have
13
+
14
+ - `src/pages/Home.svelte` — the starter page. Replace it with your own content.
15
+ - `src/App.svelte` — your routes. `<LiveTokensRouter>` adds the dev-only
16
+ `/editor` (theme tokens) and `/components` (per-component aliases) routes.
17
+ - `src/system/styles/tokens.css` — your theme token vocabulary. The dev server
18
+ writes edits here when you use the in-browser editor.
19
+ - `src/styles/site.css` — themed page typography. Yours to edit.
20
+
21
+ ## Editing live
22
+
23
+ Run `npm run dev`, then click **Open Token Editor** on the home page (or visit
24
+ `/editor`). Changes persist to `src/system/styles/tokens.css` and the JSON under
25
+ `src/live-tokens/data/`. The editor is dev-only — `npm run build` ships plain
26
+ CSS variables and the components you used, nothing else.
27
+
28
+ ## Adding components
29
+
30
+ The package ships ~25 editable components (Button, Card, Table, Dialog, …).
31
+ Import them from `@motion-proto/live-tokens/components/<Name>.svelte`. To author
32
+ your own editable component, install the Claude Code skills:
33
+
34
+ ```bash
35
+ npx @motion-proto/live-tokens setup-claude
36
+ ```
@@ -0,0 +1,15 @@
1
+ node_modules
2
+ dist
3
+ dist-ssr
4
+ *.local
5
+ *.log
6
+
7
+ # Editor-written backups stay local to each machine; the live JSON/CSS commit.
8
+ src/live-tokens/data/themes/_backups/
9
+ src/live-tokens/data/manifests/_backups/
10
+ src/live-tokens/data/component-configs/*/_backups/
11
+ src/system/styles/_backups/
12
+
13
+ .DS_Store
14
+ .vscode/*
15
+ !.vscode/extensions.json
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>__APP_NAME__</title>
7
+ </head>
8
+ <body style="margin: 0;">
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.ts"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "check": "svelte-check --tsconfig ./tsconfig.json"
11
+ },
12
+ "dependencies": {
13
+ "@motion-proto/live-tokens": "__LT_VERSION__"
14
+ },
15
+ "devDependencies": {
16
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
17
+ "@types/node": "^25.9.1",
18
+ "sass": "^1.98.0",
19
+ "svelte": "^5.55.5",
20
+ "svelte-check": "^4.4.8",
21
+ "typescript": "~6.0.3",
22
+ "vite": "^8.0.14"
23
+ }
24
+ }
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import { LiveTokensRouter } from '@motion-proto/live-tokens';
3
+
4
+ // <LiveTokensRouter> owns the dev-only /editor and /components routes.
5
+ // Declare your own pages here. Lazy imports keep each page's CSS
6
+ // side-effects out of the editor routes; omit `label` to keep a route
7
+ // reachable by URL but hidden from the overlay nav rail.
8
+ const pages = {
9
+ '/': {
10
+ lazy: () => import('./pages/Home.svelte'),
11
+ label: 'Home',
12
+ icon: 'fa-home',
13
+ source: 'src/pages/Home.svelte',
14
+ },
15
+ };
16
+ </script>
17
+
18
+ <LiveTokensRouter {pages} />
@@ -0,0 +1,12 @@
1
+ // Token CSS order matters: defaults → editor overrides → fonts. The first two
2
+ // are project-local (the dev plugin writes to them); fonts ship with the package.
3
+ import './system/styles/tokens.css';
4
+ import './live-tokens/data/tokens.generated.css';
5
+ import '@motion-proto/live-tokens/app/fonts.css';
6
+
7
+ import { bootLiveTokens, configureEditor } from '@motion-proto/live-tokens';
8
+ import App from './App.svelte';
9
+
10
+ configureEditor({ storagePrefix: 'app-' });
11
+
12
+ bootLiveTokens(App, '#app');
@@ -0,0 +1,84 @@
1
+ <script lang="ts">
2
+ // site.css carries themed page typography (bare h1/p/a rules that consume
3
+ // theme tokens). Imported per-page, not globally, so the editor routes stay
4
+ // theme-immune. It's yours to edit — see src/styles/site.css.
5
+ import '../styles/site.css';
6
+ import Card from '@motion-proto/live-tokens/components/Card.svelte';
7
+ import Button from '@motion-proto/live-tokens/components/Button.svelte';
8
+ import { navigate } from '@motion-proto/live-tokens';
9
+
10
+ const isDev = import.meta.env.DEV;
11
+ </script>
12
+
13
+ <div class="home">
14
+ <section class="stub">
15
+ <Card>
16
+ <span class="eyebrow">Your app lives here</span>
17
+ <h1>Home</h1>
18
+ <p>
19
+ Replace this with your own content. Edit <code>src/pages/Home.svelte</code>
20
+ to get started, or add routes in <code>src/App.svelte</code>. Components
21
+ and theme tokens are editable live while <code>npm run dev</code> is running.
22
+ </p>
23
+ {#if isDev}
24
+ <div class="actions">
25
+ <Button on:click={() => navigate('/editor')}>Open Token Editor</Button>
26
+ <Button variant="secondary" on:click={() => navigate('/components')}>Components</Button>
27
+ </div>
28
+ {/if}
29
+ </Card>
30
+ </section>
31
+ </div>
32
+
33
+ <style>
34
+ .home {
35
+ display: grid;
36
+ grid-template-columns: repeat(var(--columns-count), 1fr);
37
+ column-gap: var(--columns-gutter);
38
+ max-width: var(--columns-max-width);
39
+ margin: 0 auto;
40
+ padding: var(--space-48) var(--space-32);
41
+ min-height: 100vh;
42
+ align-content: center;
43
+ }
44
+
45
+ .stub {
46
+ grid-column: 4 / span 6;
47
+ }
48
+
49
+ .eyebrow {
50
+ display: block;
51
+ font-size: var(--font-size-sm);
52
+ font-weight: var(--font-weight-normal);
53
+ text-transform: uppercase;
54
+ color: var(--text-tertiary);
55
+ margin-bottom: var(--space-8);
56
+ }
57
+
58
+ h1 {
59
+ font-family: var(--font-display);
60
+ font-size: var(--font-size-4xl);
61
+ color: var(--text-primary);
62
+ margin: 0 0 var(--space-12);
63
+ }
64
+
65
+ p {
66
+ color: var(--text-secondary);
67
+ line-height: 1.6;
68
+ }
69
+
70
+ code {
71
+ background: var(--surface-neutral-high);
72
+ padding: 2px 6px;
73
+ border-radius: var(--radius-sm);
74
+ font-family: var(--font-mono, monospace);
75
+ font-size: 0.9em;
76
+ }
77
+
78
+ .actions {
79
+ display: flex;
80
+ gap: var(--space-12);
81
+ flex-wrap: wrap;
82
+ margin-top: var(--space-20);
83
+ }
84
+ </style>
@@ -0,0 +1,2 @@
1
+ /// <reference types="svelte" />
2
+ /// <reference types="vite/client" />
@@ -0,0 +1,6 @@
1
+ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
2
+
3
+ /** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
4
+ export default {
5
+ preprocess: vitePreprocess(),
6
+ };
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "verbatimModuleSyntax": true,
14
+ "types": ["vite/client"]
15
+ },
16
+ "include": ["src/**/*.ts", "src/**/*.svelte"]
17
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vite';
2
+ import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3
+ import { themeFileApi } from '@motion-proto/live-tokens/vite-plugin';
4
+
5
+ export default defineConfig({
6
+ plugins: [
7
+ svelte({ preprocess: vitePreprocess() }),
8
+ // Dev-only: persists editor changes to src/system/styles/tokens.css and
9
+ // the JSON under src/live-tokens/data/. No effect on `vite build`.
10
+ themeFileApi({ tokensCssPath: 'src/system/styles/tokens.css' }),
11
+ ],
12
+ });