@motion-proto/live-tokens 0.52.1 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.claude/skills/live-tokens-adjust-shape-space/SKILL.md +7 -1
  2. package/CHANGELOG.md +63 -0
  3. package/bin/adjust.mjs +16 -39
  4. package/bin/generate-theme.mjs +20 -6
  5. package/dist-plugin/adjust/index.cjs +781 -21
  6. package/dist-plugin/adjust/index.d.cts +2 -1
  7. package/dist-plugin/adjust/index.d.ts +2 -1
  8. package/dist-plugin/adjust/index.js +43 -21
  9. package/dist-plugin/{chunk-6OZFXIQI.js → chunk-232GZGQU.js} +207 -12
  10. package/dist-plugin/chunk-NE6N66EE.js +753 -0
  11. package/dist-plugin/{chunk-JIGIIE5P.js → chunk-PB2JTK2H.js} +123 -0
  12. package/dist-plugin/generateColorsAndType/index.cjs +48 -32
  13. package/dist-plugin/generateColorsAndType/index.d.cts +2 -1
  14. package/dist-plugin/generateColorsAndType/index.d.ts +2 -1
  15. package/dist-plugin/generateColorsAndType/index.js +7 -773
  16. package/dist-plugin/{themeTypes-Dky03HL9.d.cts → index-DpTIRZ2H.d.cts} +49 -21
  17. package/dist-plugin/{themeTypes-Dky03HL9.d.ts → index-DpTIRZ2H.d.ts} +49 -21
  18. package/dist-plugin/index.cjs +1159 -85
  19. package/dist-plugin/index.js +102 -80
  20. package/dist-plugin/migrateData/index.cjs +956 -14
  21. package/dist-plugin/migrateData/index.js +10 -3
  22. package/package.json +6 -4
  23. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +9 -23
  24. package/src/editor/component-editor/scaffolding/LinkedBlock.svelte +8 -8
  25. package/src/editor/core/components/adjustAliases.ts +49 -22
  26. package/src/editor/core/components/aliasKinds.ts +7 -2
  27. package/src/editor/core/components/componentConfigService.ts +1 -1
  28. package/src/editor/core/store/editorStore.ts +7 -197
  29. package/src/editor/core/store/editorTypes.ts +9 -30
  30. package/src/editor/core/themes/lookSummary.ts +3 -25
  31. package/src/editor/core/themes/migrateComponentConfig.ts +205 -0
  32. package/src/editor/core/themes/migrations/2026-05-19-sectiondivider-rich-gradient.ts +1 -1
  33. package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +1 -1
  34. package/src/editor/core/themes/migrations/2026-05-22-sectiondivider-intrinsics-to-css.ts +1 -1
  35. package/src/editor/core/themes/parsers/gradient.ts +211 -0
  36. package/src/editor/core/themes/slices/gradients.ts +5 -58
  37. package/src/editor/core/themes/themeService.ts +22 -9
  38. package/src/editor/core/themes/themeTypes.ts +71 -21
  39. package/src/editor/docs/content/themes-workflow.md +9 -11
  40. package/src/editor/docs/content/where-themes-live.md +2 -2
  41. package/src/editor/docs/content.generated.ts +2 -2
  42. package/src/editor/index.ts +1 -0
  43. package/src/editor/styles/ui-editor.css +5 -2
  44. package/src/editor/ui/PaletteEditor.svelte +30 -15
  45. package/src/editor/ui/ThemePanel.svelte +24 -15
  46. package/src/editor/ui/Toggle.svelte +22 -22
  47. package/src/editor/ui/UIPillButton.svelte +1 -1
  48. package/src/editor/ui/UIReveal.svelte +47 -0
  49. package/src/editor/ui/UISquareButton.svelte +1 -1
  50. package/src/editor/ui/colors/ColorsTab.svelte +4 -5
  51. package/src/editor/ui/palette/OverridesPanel.svelte +4 -3
  52. package/src/editor/ui/palette/PaletteJumpButton.svelte +3 -3
  53. package/src/editor/ui/palette/ScaleCurveEditor.svelte +3 -2
  54. package/src/live-tokens/data/themes/autumn.json +77 -41
  55. package/src/live-tokens/data/themes/halloween.json +53 -52
  56. package/src/live-tokens/data/themes/midnight-study.json +136 -68
  57. package/src/live-tokens/data/themes/ocean.json +77 -41
  58. package/src/live-tokens/data/themes/royal-velvet.json +43 -42
  59. package/src/live-tokens/data/themes/spring-meadow.json +86 -52
  60. package/src/live-tokens/data/themes/sunset.json +101 -40
@@ -47,29 +47,7 @@ export function lookProductionState({
47
47
  };
48
48
  }
49
49
 
50
- /**
51
- * How many components run something other than what the open theme carries.
52
- *
53
- * Two facts are free on the client: whether the theme embeds a config for a
54
- * component, and which layer the component's live config resolves from. A
55
- * component the theme carries but which runs the shipped default is off it, and
56
- * so is a component running a buffer the theme carries nothing for. A buffer
57
- * over a config the theme does carry says nothing either way — that is the
58
- * state an apply leaves — so it stays uncounted.
59
- *
60
- * The Default theme breaks that rule: it is the one theme that embeds every
61
- * component's DEFAULT config by value (full set, not delta), so carrying an
62
- * entry says nothing. For it, off-theme is simply "buffered".
63
- */
64
- export function countComponentsOffLook(
65
- components: ComponentSummary[],
66
- lookConfigs: Record<string, unknown> | null | undefined,
67
- lookIsDefault = false,
68
- ): number {
69
- const carried = lookConfigs ?? {};
70
- return components.filter((c) => {
71
- if (lookIsDefault) return c.source === 'working';
72
- const inLook = Object.prototype.hasOwnProperty.call(carried, c.name);
73
- return inLook ? c.source === 'default' : c.source === 'working';
74
- }).length;
50
+ /** How many components run an unsaved buffer, diverging from what the open theme carries. */
51
+ export function countComponentsOffLook(components: ComponentSummary[]): number {
52
+ return components.filter((c) => c.source === 'working').length;
75
53
  }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Component-config migration, disk-shape in and disk-shape out. Extracted
3
+ * from `editorStore.ts` (Wave 1 of `docs/plans/theme-completeness.md`) so the
4
+ * server (`normalizeTheme`) can run the same migration an embedded theme
5
+ * config gets on the client, instead of reading it raw.
6
+ */
7
+
8
+ import type { AliasDiskValue } from './themeTypes';
9
+ import { runMigrations } from './migrations';
10
+
11
+ /**
12
+ * Migrate a disk-shape alias bag through the registered migration runner.
13
+ *
14
+ * The runner is string-typed by contract (most migrations only do
15
+ * key/value string rewrites). Object-valued entries — currently just
16
+ * inline `{ kind: 'gradient', value: ... }` payloads — pass through
17
+ * untouched: we split the bag into string/object subsets, run the runner
18
+ * on the string subset, then merge.
19
+ *
20
+ * For sectiondivider's legacy 7-flat-token gradients we run a synthesis
21
+ * step BEFORE the migration runner that would strip those tokens. The
22
+ * synthesized gradient object lands in the object subset and survives
23
+ * the stripping pass.
24
+ */
25
+ export function migrateComponentConfig(
26
+ component: string,
27
+ aliasesIn: Record<string, AliasDiskValue>,
28
+ rawConfig: Record<string, unknown> | undefined,
29
+ fileVersion: number,
30
+ ): { aliases: Record<string, AliasDiskValue>; config: Record<string, unknown> } {
31
+ const stringPart: Record<string, string> = {};
32
+ const objectPart: Record<string, AliasDiskValue> = {};
33
+ for (const [k, v] of Object.entries(aliasesIn)) {
34
+ if (typeof v === 'string') stringPart[k] = v;
35
+ else objectPart[k] = v;
36
+ }
37
+ // String-valued config entries join the migration input so a migration can
38
+ // rename or relocate them. After migration the bag flows through
39
+ // `splitAliasesAndConfig` which routes KNOWN keys back to config and the
40
+ // rest to aliases — this is what lets a migration *move* a key from the
41
+ // config bucket to the aliases bucket by also removing it from KNOWN.
42
+ // Config wins over an aliases-bucket collision on the same key: the legacy
43
+ // single-bucket → split-bucket move preserved "explicit config field beats
44
+ // legacy alias-bucketed value", and that contract still holds.
45
+ // Non-string config values (rare; reserved for future structured payloads)
46
+ // pass through untouched.
47
+ const configOut: Record<string, unknown> = {};
48
+ for (const [k, v] of Object.entries(rawConfig ?? {})) {
49
+ if (typeof v === 'string') stringPart[k] = v;
50
+ else configOut[k] = v;
51
+ }
52
+ if (component === 'sectiondivider') {
53
+ synthesizeSectionDividerGradients(stringPart, objectPart);
54
+ }
55
+ const migratedString = runMigrations('component-config', fileVersion, stringPart, { component });
56
+ const migratedObject = component === 'sectiondivider'
57
+ ? renameSectionDividerObjectSlots(objectPart)
58
+ : objectPart;
59
+ return {
60
+ aliases: { ...migratedString, ...migratedObject },
61
+ config: configOut,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * v8→v9 companion to `componentMigration_2026_05_20_sectiondividerSlimVariants`:
67
+ * the structured background payload is reshaped from six color families →
68
+ * three size variants (lg/md/sm). Canvas's gradient is taken as the canonical
69
+ * seed and fanned across all three new variants; other families' gradients
70
+ * are dropped. Idempotent — keys already at the final shape pass through.
71
+ *
72
+ * Sources accepted (in priority order, first-found wins for canvas's slot):
73
+ * --sectiondivider-canvas-background (current/in-progress shape)
74
+ * --sectiondivider-canvas-gradient (v7-era shape)
75
+ * --sectiondivider-color-canvas-background (intermediate shape that landed
76
+ * briefly during the color-prop pivot)
77
+ *
78
+ * The migration runner only sees string keys, so this object-shape rename
79
+ * has to live alongside the runner call.
80
+ */
81
+ const FAMILIES = ['canvas', 'neutral', 'alternate', 'primary', 'accent', 'special'] as const;
82
+ const SIZE_VARIANTS = ['lg', 'md', 'sm'] as const;
83
+ function renameSectionDividerObjectSlots(
84
+ objectPart: Record<string, AliasDiskValue>,
85
+ ): Record<string, AliasDiskValue> {
86
+ // Find canvas's gradient under any historical key shape.
87
+ const canvasSourceKeys = [
88
+ '--sectiondivider-canvas-background',
89
+ '--sectiondivider-canvas-gradient',
90
+ '--sectiondivider-color-canvas-background',
91
+ ];
92
+ let canvasGradient: AliasDiskValue | undefined;
93
+ for (const key of canvasSourceKeys) {
94
+ if (objectPart[key] !== undefined) {
95
+ canvasGradient = objectPart[key];
96
+ break;
97
+ }
98
+ }
99
+
100
+ // If we found a canvas gradient (any era), fan it into the three new
101
+ // per-variant background slots and drop every other family's gradient.
102
+ // If no canvas gradient is found, pass through non-family keys verbatim
103
+ // so non-migration data (theme tokens, other components) isn't touched.
104
+ const out: Record<string, AliasDiskValue> = {};
105
+ for (const [key, value] of Object.entries(objectPart)) {
106
+ // Skip any sectiondivider per-family gradient; the canvas fan-out emits
107
+ // the canonical set below.
108
+ let isFamilyGradient = false;
109
+ for (const f of FAMILIES) {
110
+ if (
111
+ key === `--sectiondivider-${f}-background`
112
+ || key === `--sectiondivider-${f}-gradient`
113
+ || key === `--sectiondivider-color-${f}-background`
114
+ ) {
115
+ isFamilyGradient = true;
116
+ break;
117
+ }
118
+ }
119
+ // Pass through anything that's already at the final lg/md/sm shape so
120
+ // re-running the migration is idempotent.
121
+ if (isFamilyGradient) continue;
122
+ out[key] = value;
123
+ }
124
+ if (canvasGradient !== undefined) {
125
+ for (const v of SIZE_VARIANTS) {
126
+ out[`--sectiondivider-${v}-background`] = canvasGradient;
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** SectionDivider variants that ship gradient aliases. */
133
+ const SECTIONDIVIDER_VARIANTS = ['canvas', 'neutral', 'alternate', 'primary', 'accent', 'special'] as const;
134
+
135
+ /**
136
+ * Collapse legacy 7-flat-token gradients (`{angle, stop-{1,2,3}-{color,position}}`)
137
+ * for each sectiondivider variant into one structured gradient alias at
138
+ * `--sectiondivider-{v}-gradient`. The flat tokens stay in `stringPart`
139
+ * for the migration runner to strip; the structured payload joins
140
+ * `objectPart` so the merge produces the new shape.
141
+ *
142
+ * Skips variants that already declare a gradient alias (object form) so
143
+ * round-tripping doesn't overwrite user edits.
144
+ */
145
+ function synthesizeSectionDividerGradients(
146
+ stringPart: Record<string, string>,
147
+ objectPart: Record<string, AliasDiskValue>,
148
+ ): void {
149
+ const GRADIENT_ANGLE_TOKENS: Record<string, number> = {
150
+ '--gradient-angle-horizontal': 90,
151
+ '--gradient-angle-vertical': 180,
152
+ '--gradient-angle-diagonal': 135,
153
+ '--gradient-angle-counter-diagonal': 45,
154
+ };
155
+ const GRADIENT_STOP_POSITIONS: Record<string, number> = {
156
+ '--gradient-stop-start': 0,
157
+ '--gradient-stop-mid': 50,
158
+ '--gradient-stop-end': 100,
159
+ };
160
+ const parseAngle = (raw: string | undefined): number => {
161
+ if (!raw) return 135;
162
+ if (raw.startsWith('--')) return GRADIENT_ANGLE_TOKENS[raw] ?? 135;
163
+ const m = raw.match(/^(-?\d+(?:\.\d+)?)\s*deg$/i);
164
+ return m ? parseFloat(m[1]) : 135;
165
+ };
166
+ const parsePosition = (raw: string | undefined, fallback: number): number => {
167
+ if (!raw) return fallback;
168
+ if (raw.startsWith('--')) return GRADIENT_STOP_POSITIONS[raw] ?? fallback;
169
+ const m = raw.match(/^(-?\d+(?:\.\d+)?)\s*%$/);
170
+ return m ? parseFloat(m[1]) : fallback;
171
+ };
172
+ const parseStopColor = (raw: string | undefined): { color: string; opacity?: number } | null => {
173
+ if (!raw) return null;
174
+ if (raw.startsWith('--')) return { color: raw };
175
+ const mix = raw.match(/^color-mix\(\s*in\s+srgb\s*,\s*var\((--[a-z0-9-]+)\)\s+([\d.]+)%\s*,\s*transparent\s*\)$/i);
176
+ if (mix) return { color: mix[1], opacity: parseFloat(mix[2]) };
177
+ return null;
178
+ };
179
+ for (const v of SECTIONDIVIDER_VARIANTS) {
180
+ const slot = `--sectiondivider-${v}-gradient`;
181
+ if (slot in objectPart) continue;
182
+ const angleKey = `${slot}-angle`;
183
+ const has1 = stringPart[`${slot}-stop-1-color`] !== undefined;
184
+ const has2 = stringPart[`${slot}-stop-2-color`] !== undefined;
185
+ const has3 = stringPart[`${slot}-stop-3-color`] !== undefined;
186
+ if (!has1 && !has2 && !has3) continue;
187
+ const stops: { position: number; color: string; opacity?: number }[] = [];
188
+ const positionDefaults = [0, 50, 100];
189
+ for (let i = 1; i <= 3; i++) {
190
+ const c = parseStopColor(stringPart[`${slot}-stop-${i}-color`]);
191
+ if (!c) continue;
192
+ const pos = parsePosition(stringPart[`${slot}-stop-${i}-position`], positionDefaults[i - 1]);
193
+ stops.push({ position: pos, color: c.color, ...(c.opacity !== undefined ? { opacity: c.opacity } : {}) });
194
+ }
195
+ if (stops.length < 2) continue;
196
+ objectPart[slot] = {
197
+ kind: 'gradient',
198
+ value: {
199
+ type: 'linear',
200
+ angle: parseAngle(stringPart[angleKey]),
201
+ stops,
202
+ },
203
+ };
204
+ }
205
+ }
@@ -7,7 +7,7 @@ import type { Migration } from './index';
7
7
  * carrying `{type, angle, stops[]}` inline on the component slice.
8
8
  *
9
9
  * The structured value is synthesized *before* this migration runs — see
10
- * `synthesizeSectionDividerGradients` in `editorStore.ts`. The synthesizer
10
+ * `synthesizeSectionDividerGradients` in `migrateComponentConfig.ts`. The synthesizer
11
11
  * walks the legacy 7-token shape and emits a `kind: 'gradient'` alias into
12
12
  * the load path's object subset, where it survives this stripping pass and
13
13
  * lands in the in-memory slice.
@@ -18,7 +18,7 @@ import type { Migration } from './index';
18
18
  * `*-padding` → `*-spacing`
19
19
  *
20
20
  * The structured background payload moves through a companion rename in
21
- * `migrateComponentAliases` (editorStore.ts), since the runner only sees
21
+ * `migrateComponentConfig` (migrateComponentConfig.ts), since the runner only sees
22
22
  * string keys.
23
23
  */
24
24
  const FAMILIES = ['canvas', 'neutral', 'alternate', 'primary', 'accent', 'special'] as const;
@@ -24,7 +24,7 @@ import type { Migration } from './index';
24
24
  * - both undefined → emit nothing (default 'none' applies via CSS)
25
25
  * - `show-hairline` unset but a real position exists → preserve the position
26
26
  *
27
- * The migration touches the unified bag produced by `migrateComponentAliases`
27
+ * The migration touches the unified bag produced by `migrateComponentConfig`
28
28
  * (which now pre-merges string-valued config entries). The post-migration
29
29
  * `splitAliasesAndConfig` step routes the new keys to `aliases` (since
30
30
  * `KNOWN_COMPONENT_CONFIG_KEYS` no longer contains them) while keeping
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The single source of truth for the CSS form of a structured gradient value.
3
+ * A component alias that owns a gradient serializes to one of two shapes:
4
+ *
5
+ * linear-gradient(<angle>deg, <stop>, <stop>, …)
6
+ * radial-gradient(<shape> at <cx>% 50%, <stop>, <stop>, …)
7
+ *
8
+ * `solid` and `none` are NOT represented here. `solid` renders as its first
9
+ * stop's colour and `none` as `transparent`, both indistinguishable in CSS
10
+ * from an ordinary colour alias, so `parseGradientValue` never claims them —
11
+ * see the round-trip contract below. Everything that writes or reads this form
12
+ * must go through `formatGradientValue` / `parseGradientValue` so the shape
13
+ * lives in exactly one place.
14
+ *
15
+ * ## Round-trip contract
16
+ *
17
+ * `formatGradientValue(parseGradientValue(css)) === css` for every string
18
+ * `formatGradientValue` can emit. The reverse holds only up to CSS
19
+ * equivalence: the format drops decompositions that have no rendered meaning,
20
+ * so parsing returns the canonical member of the equivalence class.
21
+ *
22
+ * - A radial's `angle` is never emitted (it exists so toggling the type back
23
+ * to linear restores the user's angle), so it parses back as 0.
24
+ * - `centerX: 50` and an absent `centerX` both emit `at 50% 50%`; parsing
25
+ * returns the absent form.
26
+ * - An ellipse emits `radius * aspectX` and `radius * aspectY`, three
27
+ * unknowns in two numbers. `radius` is vestigial (no setter writes it and
28
+ * `GradientSourceSnapshot` has no field for it), so parsing folds it into
29
+ * the aspects against `RADIAL_BASE_PX`. The two emitted numbers are
30
+ * unchanged, which is all CSS sees.
31
+ *
32
+ * This module depends only on its sibling `colorOpacity` parser, so the
33
+ * dev-server vite plugin (a separate build) can import it the same way it
34
+ * imports `globalRootBlock`.
35
+ */
36
+ import { parseColorOpacity, formatColorOpacity } from './colorOpacity';
37
+
38
+ export interface GradientStop {
39
+ /** 0–100 percentage along the gradient axis. */
40
+ position: number;
41
+ /** A design-token name (`--surface-neutral-low`) or a raw CSS colour. */
42
+ color: string;
43
+ /** 0–100 alpha. Absent means 100. */
44
+ opacity?: number;
45
+ /** `false` marks the stop an explicit off-palette override: the stop picker
46
+ * opens to any colour rather than the current family, and a family-swap
47
+ * rewrite skips the stop so its colour survives. Absent means `true`.
48
+ * It has no CSS form, so `parseGradientValue` never produces it and
49
+ * `formatGradientValue` ignores it. It lives on the shared stop because
50
+ * the persisted stop carries it. */
51
+ monochrome?: boolean;
52
+ }
53
+
54
+ export interface GradientValue {
55
+ type: 'linear' | 'radial' | 'solid' | 'none';
56
+ angle: number;
57
+ radius?: number;
58
+ centerX?: number;
59
+ aspectX?: number;
60
+ aspectY?: number;
61
+ stops: GradientStop[];
62
+ }
63
+
64
+ /** Base radius (px) an ellipse's aspect factors scale, when the gradient has
65
+ * no explicit `radius` but needs concrete dimensions to express its shape.
66
+ * A pleasant mid-size that reads as a soft glow inside typical component
67
+ * containers. */
68
+ export const RADIAL_BASE_PX = 100;
69
+
70
+ /** Resolve a stop's colour + opacity into a CSS colour value without the
71
+ * trailing `${position}%`. Shared by the stop formatter (which appends the
72
+ * position) and the solid path (which doesn't). */
73
+ function formatStopColor(s: GradientStop): string {
74
+ const opacity = s.opacity ?? 100;
75
+ if (s.color.startsWith('--')) {
76
+ return opacity >= 100 ? `var(${s.color})` : formatColorOpacity(s.color, opacity);
77
+ }
78
+ // A raw colour is not a design token, so colorOpacity, which owns the token
79
+ // form, cannot express it.
80
+ return opacity >= 100 ? s.color : `color-mix(in srgb, ${s.color} ${opacity}%, transparent)`;
81
+ }
82
+
83
+ /** Stops portion only, for callers that supply their own gradient function
84
+ * wrapper (the palette selector overrides the angle per slot while keeping
85
+ * the token's stop list and its `var(--color-…)` refs). */
86
+ export function formatGradientStops(stops: readonly GradientStop[]): string {
87
+ return stops.map((s) => `${formatStopColor(s)} ${s.position}%`).join(', ');
88
+ }
89
+
90
+ function formatRadialShape(v: GradientValue): string {
91
+ const ax = v.aspectX ?? 1;
92
+ const ay = v.aspectY ?? 1;
93
+ if (ax === 1 && ay === 1) {
94
+ return v.radius && v.radius > 0 ? `circle ${v.radius}px` : 'circle';
95
+ }
96
+ const base = v.radius && v.radius > 0 ? v.radius : RADIAL_BASE_PX;
97
+ return `ellipse ${(base * ax).toFixed(2)}px ${(base * ay).toFixed(2)}px`;
98
+ }
99
+
100
+ /** Serialize a structured gradient value (theme token or component-owned)
101
+ * into a CSS background declaration.
102
+ * - `none` → `transparent` (no background paint).
103
+ * - `solid` → the first stop's colour (no gradient function).
104
+ * - `linear` → `linear-gradient(<angle>deg, <stops>)`.
105
+ * - `radial` → `radial-gradient(<shape> at <centerX>% 50%, <stops>)`, where
106
+ * shape is `circle [radius]` at aspect 1 and `ellipse rx ry` otherwise.
107
+ * Aspects are independent stretch factors, not an area-preserving ratio:
108
+ * both at 8 is a circle eight times the size of both at 1. */
109
+ export function formatGradientValue(v: GradientValue): string {
110
+ if (v.type === 'none') return 'transparent';
111
+ if (v.type === 'solid') {
112
+ const first = v.stops[0];
113
+ return first ? formatStopColor(first) : 'transparent';
114
+ }
115
+ const stops = formatGradientStops(v.stops);
116
+ if (v.type === 'linear') return `linear-gradient(${v.angle}deg, ${stops})`;
117
+ return `radial-gradient(${formatRadialShape(v)} at ${v.centerX ?? 50}% 50%, ${stops})`;
118
+ }
119
+
120
+ const NUM = String.raw`-?\d+(?:\.\d+)?`;
121
+ const LINEAR_RE = new RegExp(String.raw`^linear-gradient\(\s*(${NUM})deg\s*,\s*(.+)\)$`, 'i');
122
+ const RADIAL_RE = new RegExp(
123
+ String.raw`^radial-gradient\(\s*(circle|circle\s+${NUM}px|ellipse\s+${NUM}px\s+${NUM}px)\s+at\s+(${NUM})%\s+50%\s*,\s*(.+)\)$`,
124
+ 'i',
125
+ );
126
+ const STOP_RE = new RegExp(String.raw`^(.*?)\s+(${NUM})%$`);
127
+ const CIRCLE_RADIUS_RE = new RegExp(String.raw`^circle\s+(${NUM})px$`, 'i');
128
+ const ELLIPSE_RE = new RegExp(String.raw`^ellipse\s+(${NUM})px\s+(${NUM})px$`, 'i');
129
+ const VAR_RE = /^var\((--[a-z0-9-]+)\)$/i;
130
+
131
+ /** Split on top-level separators only. Stop colours nest commas inside
132
+ * `color-mix(…)`, so a plain `split(',')` shreds them. */
133
+ function splitTopLevel(s: string): string[] {
134
+ const parts: string[] = [];
135
+ let depth = 0;
136
+ let start = 0;
137
+ for (let i = 0; i < s.length; i++) {
138
+ const c = s[i];
139
+ if (c === '(') depth++;
140
+ else if (c === ')') depth--;
141
+ else if (c === ',' && depth === 0) {
142
+ parts.push(s.slice(start, i));
143
+ start = i + 1;
144
+ }
145
+ }
146
+ parts.push(s.slice(start));
147
+ return parts;
148
+ }
149
+
150
+ function parseStop(raw: string): GradientStop | null {
151
+ const m = raw.trim().match(STOP_RE);
152
+ if (!m) return null;
153
+ const position = parseFloat(m[2]);
154
+ const color = m[1].trim();
155
+ const opacity = parseColorOpacity(color);
156
+ if (opacity) return { position, color: opacity.name, opacity: opacity.opacity };
157
+ const token = color.match(VAR_RE);
158
+ return { position, color: token ? token[1] : color };
159
+ }
160
+
161
+ function parseStops(raw: string): GradientStop[] | null {
162
+ const stops: GradientStop[] = [];
163
+ for (const part of splitTopLevel(raw)) {
164
+ const stop = parseStop(part);
165
+ if (!stop) return null;
166
+ stops.push(stop);
167
+ }
168
+ return stops.length > 0 ? stops : null;
169
+ }
170
+
171
+ function parseRadialShape(raw: string): Pick<GradientValue, 'radius' | 'aspectX' | 'aspectY'> | null {
172
+ const shape = raw.trim();
173
+ if (/^circle$/i.test(shape)) return {};
174
+ const circle = shape.match(CIRCLE_RADIUS_RE);
175
+ if (circle) return { radius: parseFloat(circle[1]) };
176
+ const ellipse = shape.match(ELLIPSE_RE);
177
+ if (!ellipse) return null;
178
+ // Aspects of exactly 1 are dropped, matching `setGradientAspect`, so the
179
+ // parsed value stays the canonical minimal shape.
180
+ const ax = parseFloat(ellipse[1]) / RADIAL_BASE_PX;
181
+ const ay = parseFloat(ellipse[2]) / RADIAL_BASE_PX;
182
+ return { ...(ax === 1 ? {} : { aspectX: ax }), ...(ay === 1 ? {} : { aspectY: ay }) };
183
+ }
184
+
185
+ /**
186
+ * Recover a structured gradient from the CSS `formatGradientValue` emits.
187
+ * Returns null for anything else, including the plain colour a `solid` or
188
+ * `none` gradient renders as: claiming those would turn every ordinary colour
189
+ * alias into a gradient object.
190
+ */
191
+ export function parseGradientValue(value: string): GradientValue | null {
192
+ const css = value.trim();
193
+ const linear = css.match(LINEAR_RE);
194
+ if (linear) {
195
+ const stops = parseStops(linear[2]);
196
+ return stops && { type: 'linear', angle: parseFloat(linear[1]), stops };
197
+ }
198
+ const radial = css.match(RADIAL_RE);
199
+ if (!radial) return null;
200
+ const shape = parseRadialShape(radial[1]);
201
+ const stops = parseStops(radial[3]);
202
+ if (!shape || !stops) return null;
203
+ const centerX = parseFloat(radial[2]);
204
+ return {
205
+ type: 'radial',
206
+ angle: 0,
207
+ ...shape,
208
+ ...(centerX === 50 ? {} : { centerX }),
209
+ stops,
210
+ };
211
+ }
@@ -4,9 +4,12 @@
4
4
  * (`--color-brand-500`); the renderer wraps them in `var(...)` so palette
5
5
  * edits flow through.
6
6
  */
7
- import type { EditorState, GradientToken, GradientTokenStop, GradientType, GradientAliasValue } from '../../store/editorTypes';
7
+ import type { EditorState, GradientToken, GradientTokenStop, GradientType } from '../../store/editorTypes';
8
8
  import type { GradientDiskToken } from '../themeTypes';
9
9
  import { mutate } from '../../store/editorCore';
10
+ import { formatGradientValue, formatGradientStops as formatStopList } from '../parsers/gradient';
11
+
12
+ export { formatGradientValue };
10
13
 
11
14
  export function makeDefaultGradients(): GradientToken[] {
12
15
  return [
@@ -70,67 +73,11 @@ export function loadGradientsFromFile(
70
73
  if (matches) next.gradients.tokens = structuredClone(gradients) as GradientToken[];
71
74
  }
72
75
 
73
- function formatGradientStop(s: GradientTokenStop): string {
74
- return `${formatGradientStopColor(s)} ${s.position}%`;
75
- }
76
-
77
76
  /** Stops portion only — used by the palette selector to materialize a
78
77
  * linear-gradient with a per-slot angle override while keeping the token's
79
78
  * stop list (and its `var(--color-…)` refs, which propagate palette edits). */
80
79
  export function formatGradientStops(t: GradientToken): string {
81
- return t.stops.map(formatGradientStop).join(', ');
82
- }
83
-
84
- /** Serialize a structured gradient value (theme token or component-owned)
85
- * into a CSS background declaration.
86
- * - `none` → `transparent` (no background paint).
87
- * - `solid` → the first stop's color (no gradient function).
88
- * - `linear` → `linear-gradient(<angle>, <stops>)`.
89
- * - `radial` → `radial-gradient(<shape> at <centerX>% 50%, <stops>)`.
90
- * centerX defaults to 50. Shape is `circle [radius]` when the
91
- * aspect ratio is 1 (or absent); for aspect ≠ 1 we emit
92
- * `ellipse rx ry` anchored to `radius || 100px`, area-preserved
93
- * so the R=1 boundary is continuous with the legacy circle. */
94
- export function formatGradientValue(v: GradientAliasValue): string {
95
- if (v.type === 'none') return 'transparent';
96
- if (v.type === 'solid') {
97
- const first = v.stops[0];
98
- if (!first) return 'transparent';
99
- return formatGradientStopColor(first);
100
- }
101
- const stops = v.stops.map(formatGradientStop).join(', ');
102
- if (v.type === 'linear') return `linear-gradient(${v.angle}deg, ${stops})`;
103
- const cx = v.centerX ?? 50;
104
- return `radial-gradient(${formatRadialShape(v)} at ${cx}% 50%, ${stops})`;
105
- }
106
-
107
- /** Default base radius (px) when the gradient has no explicit `radius` but
108
- * needs concrete dimensions to express its aspect ratio. Chosen as a
109
- * pleasant-looking mid-size that reads as a "soft glow" inside typical
110
- * component containers; the user can dial `radius` to override. */
111
- const DEFAULT_RADIAL_BASE_PX = 100;
112
-
113
- function formatRadialShape(v: GradientAliasValue): string {
114
- const ax = v.aspectX ?? 1;
115
- const ay = v.aspectY ?? 1;
116
- if (ax === 1 && ay === 1) {
117
- return v.radius && v.radius > 0 ? `circle ${v.radius}px` : 'circle';
118
- }
119
- const base = v.radius && v.radius > 0 ? v.radius : DEFAULT_RADIAL_BASE_PX;
120
- const rx = base * ax;
121
- const ry = base * ay;
122
- return `ellipse ${rx.toFixed(2)}px ${ry.toFixed(2)}px`;
123
- }
124
-
125
- /** Resolve a stop's color + opacity into a CSS color value without the
126
- * trailing `${position}%`. Shared by the gradient-stop formatter (which
127
- * appends the position) and the solid path (which doesn't). */
128
- function formatGradientStopColor(s: GradientTokenStop): string {
129
- const base = s.color.startsWith('--') ? `var(${s.color})` : s.color;
130
- const opacity = s.opacity ?? 100;
131
- return opacity >= 100
132
- ? base
133
- : `color-mix(in srgb, ${base} ${opacity}%, transparent)`;
80
+ return formatStopList(t.stops);
134
81
  }
135
82
 
136
83
  function formatGradient(t: GradientToken): string {
@@ -1,15 +1,19 @@
1
- import type { Theme, ThemeMeta, ThemeBundle, ColorsAndType, ComponentConfig } from './themeTypes';
1
+ import type { Theme, ThemeMeta, ThemeBundle, ColorsAndType, ComponentConfig, ThemeFillReport } from './themeTypes';
2
2
  import { versionedFileResource } from '../storage/files/versionedFileResourceClient';
3
3
  import { API_BASE } from '../storage/apiBase';
4
4
  import { liveMovedSinceBake } from '../productionPulse';
5
5
  import { listComponents, getActiveComponentConfig } from '../components/componentConfigService';
6
6
  import { getActiveColorsAndType } from './colorsAndTypeService';
7
7
  import { broadcastAppliedTheme, hydrateAppliedTheme } from './themeDocumentSync';
8
+ import { CURRENT_COMPONENT_SCHEMA_VERSION } from './migrations';
9
+ import { THEME_SCHEMA_VERSION } from './themeTypes';
10
+
11
+ export type { ThemeFillReport };
8
12
 
9
13
  /**
10
14
  * REST client for theme files, the documents of the editor. A theme carries a
11
15
  * whole look by value: the colors and type plus a config for every component
12
- * that is off its default. One theme is open (`themes/_active.json`) and one is
16
+ * this install has. One theme is open (`themes/_active.json`) and one is
13
17
  * published (`themes/_production.json`); the live look is the open theme plus
14
18
  * whatever the `_working` buffers hold over it.
15
19
  *
@@ -61,6 +65,9 @@ export interface ApplyThemeResult {
61
65
  componentConfigs: Record<string, ComponentConfig>;
62
66
  /** Components the theme carries data for that this install doesn't have. */
63
67
  skippedComponents: string[];
68
+ /** What the completeness fill (`normalizeTheme`) had to add to reach this
69
+ * theme, reported so the panel can name it. */
70
+ filled: ThemeFillReport;
64
71
  }
65
72
 
66
73
  /**
@@ -128,8 +135,9 @@ function withoutLiveMarkers<T extends { _fileName?: string; _source?: unknown }>
128
135
 
129
136
  /**
130
137
  * The look as it stands: the live colors and type plus the live config of
131
- * every component that sits off its default, all by value. Delta encoding — a
132
- * component absent from the map runs the local default, which stays canonical.
138
+ * every component this install has, all by value. A theme is a complete
139
+ * document (`docs/plans/theme-completeness.md`), so the capture carries every
140
+ * component, not just the ones off their default.
133
141
  *
134
142
  * Everything comes from the live read doors, so a capture takes the buffers
135
143
  * over the open theme's own copies. The colors and type are normalised on the
@@ -141,10 +149,10 @@ async function captureLook(): Promise<Pick<Theme, 'colorsAndType' | 'componentCo
141
149
  if (!liveColorsAndType) {
142
150
  throw new Error('No live colors and type to capture');
143
151
  }
144
- const overridden = (await listComponents()).filter((c) => c.source !== 'default');
145
- const configs = await Promise.all(overridden.map((c) => getActiveComponentConfig(c.name)));
152
+ const components = await listComponents();
153
+ const configs = await Promise.all(components.map((c) => getActiveComponentConfig(c.name)));
146
154
  const componentConfigs: Record<string, ComponentConfig> = {};
147
- overridden.forEach((c, i) => {
155
+ components.forEach((c, i) => {
148
156
  const config = configs[i];
149
157
  if (config) componentConfigs[c.name] = withoutLiveMarkers(config);
150
158
  });
@@ -166,7 +174,8 @@ export async function saveAsTheme(
166
174
  name: displayName,
167
175
  createdAt: now,
168
176
  updatedAt: now,
169
- schemaVersion: 3,
177
+ schemaVersion: THEME_SCHEMA_VERSION,
178
+ componentSchemaVersion: CURRENT_COMPONENT_SCHEMA_VERSION,
170
179
  ...look,
171
180
  });
172
181
  liveMovedSinceBake.set(true);
@@ -188,7 +197,8 @@ export async function saveActiveTheme(displayName?: string): Promise<void> {
188
197
  name: displayName ?? active.name,
189
198
  createdAt: active.createdAt,
190
199
  updatedAt: new Date().toISOString(),
191
- schemaVersion: 3,
200
+ schemaVersion: THEME_SCHEMA_VERSION,
201
+ componentSchemaVersion: CURRENT_COMPONENT_SCHEMA_VERSION,
192
202
  ...look,
193
203
  });
194
204
  liveMovedSinceBake.set(true);
@@ -203,6 +213,9 @@ export interface ImportThemeResult {
203
213
  /** Refs a v1 bundle failed to carry, as `colors-and-type:<name>` /
204
214
  * `<comp>/<name>`. Each fell back to the default. */
205
215
  dropped: string[];
216
+ /** What the completeness fill (`normalizeTheme`) had to add to reach this
217
+ * theme, reported so the panel can name it. */
218
+ filled: ThemeFillReport;
206
219
  }
207
220
 
208
221
  /**