@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.
- package/.claude/skills/live-tokens-adjust-shape-space/SKILL.md +7 -1
- package/CHANGELOG.md +63 -0
- package/bin/adjust.mjs +16 -39
- package/bin/generate-theme.mjs +20 -6
- package/dist-plugin/adjust/index.cjs +781 -21
- package/dist-plugin/adjust/index.d.cts +2 -1
- package/dist-plugin/adjust/index.d.ts +2 -1
- package/dist-plugin/adjust/index.js +43 -21
- package/dist-plugin/{chunk-6OZFXIQI.js → chunk-232GZGQU.js} +207 -12
- package/dist-plugin/chunk-NE6N66EE.js +753 -0
- package/dist-plugin/{chunk-JIGIIE5P.js → chunk-PB2JTK2H.js} +123 -0
- package/dist-plugin/generateColorsAndType/index.cjs +48 -32
- package/dist-plugin/generateColorsAndType/index.d.cts +2 -1
- package/dist-plugin/generateColorsAndType/index.d.ts +2 -1
- package/dist-plugin/generateColorsAndType/index.js +7 -773
- package/dist-plugin/{themeTypes-Dky03HL9.d.cts → index-DpTIRZ2H.d.cts} +49 -21
- package/dist-plugin/{themeTypes-Dky03HL9.d.ts → index-DpTIRZ2H.d.ts} +49 -21
- package/dist-plugin/index.cjs +1159 -85
- package/dist-plugin/index.js +102 -80
- package/dist-plugin/migrateData/index.cjs +956 -14
- package/dist-plugin/migrateData/index.js +10 -3
- package/package.json +6 -4
- package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +9 -23
- package/src/editor/component-editor/scaffolding/LinkedBlock.svelte +8 -8
- package/src/editor/core/components/adjustAliases.ts +49 -22
- package/src/editor/core/components/aliasKinds.ts +7 -2
- package/src/editor/core/components/componentConfigService.ts +1 -1
- package/src/editor/core/store/editorStore.ts +7 -197
- package/src/editor/core/store/editorTypes.ts +9 -30
- package/src/editor/core/themes/lookSummary.ts +3 -25
- package/src/editor/core/themes/migrateComponentConfig.ts +205 -0
- package/src/editor/core/themes/migrations/2026-05-19-sectiondivider-rich-gradient.ts +1 -1
- package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +1 -1
- package/src/editor/core/themes/migrations/2026-05-22-sectiondivider-intrinsics-to-css.ts +1 -1
- package/src/editor/core/themes/parsers/gradient.ts +211 -0
- package/src/editor/core/themes/slices/gradients.ts +5 -58
- package/src/editor/core/themes/themeService.ts +22 -9
- package/src/editor/core/themes/themeTypes.ts +71 -21
- package/src/editor/docs/content/themes-workflow.md +9 -11
- package/src/editor/docs/content/where-themes-live.md +2 -2
- package/src/editor/docs/content.generated.ts +2 -2
- package/src/editor/index.ts +1 -0
- package/src/editor/styles/ui-editor.css +5 -2
- package/src/editor/ui/PaletteEditor.svelte +30 -15
- package/src/editor/ui/ThemePanel.svelte +24 -15
- package/src/editor/ui/Toggle.svelte +22 -22
- package/src/editor/ui/UIPillButton.svelte +1 -1
- package/src/editor/ui/UIReveal.svelte +47 -0
- package/src/editor/ui/UISquareButton.svelte +1 -1
- package/src/editor/ui/colors/ColorsTab.svelte +4 -5
- package/src/editor/ui/palette/OverridesPanel.svelte +4 -3
- package/src/editor/ui/palette/PaletteJumpButton.svelte +3 -3
- package/src/editor/ui/palette/ScaleCurveEditor.svelte +3 -2
- package/src/live-tokens/data/themes/autumn.json +77 -41
- package/src/live-tokens/data/themes/halloween.json +53 -52
- package/src/live-tokens/data/themes/midnight-study.json +136 -68
- package/src/live-tokens/data/themes/ocean.json +77 -41
- package/src/live-tokens/data/themes/royal-velvet.json +43 -42
- package/src/live-tokens/data/themes/spring-meadow.json +86 -52
- 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
|
-
|
|
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 `
|
|
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
|
-
* `
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
132
|
-
*
|
|
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
|
|
145
|
-
const configs = await Promise.all(
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
/**
|