@motion-proto/live-tokens 0.38.0 → 0.40.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-pick-component/SKILL.md +9 -1
- package/CHANGELOG.md +71 -0
- package/dist-plugin/index.cjs +20 -72
- package/dist-plugin/index.js +20 -72
- package/package.json +1 -1
- package/src/editor/component-editor/IconButtonEditor.svelte +175 -0
- package/src/editor/component-editor/SectionDividerEditor.svelte +1 -1
- package/src/editor/component-editor/registry.ts +11 -0
- package/src/editor/core/palettes/paletteDerivation.ts +28 -83
- package/src/editor/core/store/editorStore.ts +2 -1
- package/src/editor/core/store/gradientSource.ts +49 -9
- package/src/editor/core/themes/migrations/2026-06-05-palette-unification.ts +90 -0
- package/src/editor/core/themes/themeTypes.ts +3 -8
- package/src/editor/docs/content/01-overview.md +3 -2
- package/src/editor/docs/content.generated.ts +1 -1
- package/src/editor/ui/ColorEditPanel.svelte +93 -172
- package/src/editor/ui/PaletteEditor.svelte +46 -207
- package/src/editor/ui/VariablesTab.svelte +2 -2
- package/src/editor/ui/palette/PaletteBase.svelte +29 -37
- package/src/editor/ui/palette/paletteEditorState.ts +12 -25
- package/src/editor/ui/palette/paletteMath.ts +22 -49
- package/src/live-tokens/data/themes/default.json +11 -391
- package/src/live-tokens/data/tokens.generated.css +14 -14
- package/src/system/components/IconButton.svelte +322 -0
|
@@ -17,14 +17,14 @@ import { hexToOklch, oklchToHex, gamutClamp } from './oklch';
|
|
|
17
17
|
import { type CurveAnchor, sampleCurve, makeAnchor } from '../../ui/curveEngine';
|
|
18
18
|
import type { PaletteConfig } from '../themes/themeTypes';
|
|
19
19
|
|
|
20
|
-
export type PaletteMode = 'chromatic' | 'gray';
|
|
21
|
-
|
|
22
20
|
export interface PaletteSpec {
|
|
23
21
|
label: string;
|
|
24
22
|
cssNamespace: string;
|
|
25
|
-
mode: PaletteMode;
|
|
26
23
|
emptySelector?: boolean;
|
|
27
24
|
initialColor: string;
|
|
25
|
+
/** Seed-default role only: neutrals start with a calm low-chroma base and
|
|
26
|
+
* the neutral lightness ramp. The derivation path is identical for all. */
|
|
27
|
+
neutral?: boolean;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -33,16 +33,16 @@ export interface PaletteSpec {
|
|
|
33
33
|
* here lets the store seed boot-time vars without depending on the UI tree.
|
|
34
34
|
*/
|
|
35
35
|
export const PALETTE_SPECS: readonly PaletteSpec[] = [
|
|
36
|
-
{ label: 'Neutral', cssNamespace: 'neutral',
|
|
37
|
-
{ label: 'Alternate', cssNamespace: 'alternate',
|
|
38
|
-
{ label: 'Background', cssNamespace: 'canvas',
|
|
39
|
-
{ label: 'Brand', cssNamespace: 'brand',
|
|
40
|
-
{ label: 'Accent', cssNamespace: 'accent',
|
|
41
|
-
{ label: 'Special', cssNamespace: 'special',
|
|
42
|
-
{ label: 'Info', cssNamespace: 'info',
|
|
43
|
-
{ label: 'Success', cssNamespace: 'success',
|
|
44
|
-
{ label: 'Warning', cssNamespace: 'warning',
|
|
45
|
-
{ label: 'Danger', cssNamespace: 'danger',
|
|
36
|
+
{ label: 'Neutral', cssNamespace: 'neutral', neutral: true, initialColor: '#70787e' },
|
|
37
|
+
{ label: 'Alternate', cssNamespace: 'alternate', neutral: true, initialColor: '#817b78' },
|
|
38
|
+
{ label: 'Background', cssNamespace: 'canvas', emptySelector: true, initialColor: '#1a1a2e' },
|
|
39
|
+
{ label: 'Brand', cssNamespace: 'brand', initialColor: '#c93636' },
|
|
40
|
+
{ label: 'Accent', cssNamespace: 'accent', initialColor: '#f49e0b' },
|
|
41
|
+
{ label: 'Special', cssNamespace: 'special', initialColor: '#8b5cf6' },
|
|
42
|
+
{ label: 'Info', cssNamespace: 'info', initialColor: '#3077e8' },
|
|
43
|
+
{ label: 'Success', cssNamespace: 'success', initialColor: '#21c45d' },
|
|
44
|
+
{ label: 'Warning', cssNamespace: 'warning', initialColor: '#e66e1a' },
|
|
45
|
+
{ label: 'Danger', cssNamespace: 'danger', initialColor: '#e8304f' },
|
|
46
46
|
] as const;
|
|
47
47
|
|
|
48
48
|
const PALETTE_STEPS = [
|
|
@@ -51,12 +51,6 @@ const PALETTE_STEPS = [
|
|
|
51
51
|
{ label: '850' }, { label: '900' }, { label: '950' },
|
|
52
52
|
];
|
|
53
53
|
|
|
54
|
-
const GRAY_STEPS = [
|
|
55
|
-
{ label: '100' }, { label: '200' }, { label: '300' }, { label: '400' },
|
|
56
|
-
{ label: '500' }, { label: '600' }, { label: '700' }, { label: '800' },
|
|
57
|
-
{ label: '850' }, { label: '900' }, { label: '950' },
|
|
58
|
-
];
|
|
59
|
-
|
|
60
54
|
interface ScaleStep { name: string; position: number; }
|
|
61
55
|
interface Scale { title: string; isText: boolean; steps: ScaleStep[]; }
|
|
62
56
|
|
|
@@ -97,9 +91,6 @@ const SCALES: readonly Scale[] = [
|
|
|
97
91
|
|
|
98
92
|
export const DEFAULT_PALETTE_LIGHTNESS = (): CurveAnchor[] => [makeAnchor(0, 95, 5), makeAnchor(100, 8, 5)];
|
|
99
93
|
export const DEFAULT_PALETTE_SATURATION = (): CurveAnchor[] => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)];
|
|
100
|
-
export const DEFAULT_GRAY_LIGHTNESS = (): CurveAnchor[] => [makeAnchor(0, 92, 5), makeAnchor(100, 3, 5)];
|
|
101
|
-
export const DEFAULT_GRAY_SATURATION = (): CurveAnchor[] => [makeAnchor(0, 20, 30), makeAnchor(100, 20, 30)];
|
|
102
|
-
export const DEFAULT_TINT_CHROMA = 0.04;
|
|
103
94
|
|
|
104
95
|
export const defaultScaleCurves = {
|
|
105
96
|
Surfaces: {
|
|
@@ -117,7 +108,6 @@ export const defaultScaleCurves = {
|
|
|
117
108
|
} as const;
|
|
118
109
|
|
|
119
110
|
function paletteStepKey(label: string): string { return `Palette-${label}`; }
|
|
120
|
-
function grayStepKey(label: string): string { return `gray-${label}`; }
|
|
121
111
|
function stepKey(scaleTitle: string, stepName: string): string { return `${scaleTitle}-${stepName}`; }
|
|
122
112
|
|
|
123
113
|
function stepIndexToX(index: number, total: number): number {
|
|
@@ -140,24 +130,6 @@ function computePaletteColor(
|
|
|
140
130
|
return oklchToHex(clamped.l, clamped.c, clamped.h);
|
|
141
131
|
}
|
|
142
132
|
|
|
143
|
-
function computeGrayColor(
|
|
144
|
-
index: number,
|
|
145
|
-
hue: number,
|
|
146
|
-
chroma: number,
|
|
147
|
-
grayLightnessCurve: CurveAnchor[],
|
|
148
|
-
graySaturationCurve: CurveAnchor[],
|
|
149
|
-
curveOffset: Record<string, number>,
|
|
150
|
-
): string {
|
|
151
|
-
const xPos = stepIndexToX(index, GRAY_STEPS.length);
|
|
152
|
-
const lOff = curveOffset['gray-lightness'] ?? 0;
|
|
153
|
-
const sOff = curveOffset['gray-saturation'] ?? 0;
|
|
154
|
-
const targetL = Math.max(0, Math.min(100, sampleCurve(grayLightnessCurve, xPos) + lOff)) / 100;
|
|
155
|
-
const satMul = Math.max(0, Math.min(2, (sampleCurve(graySaturationCurve, xPos) + sOff) / 100));
|
|
156
|
-
const targetC = chroma * satMul;
|
|
157
|
-
const clamped = gamutClamp(targetL, targetC, hue);
|
|
158
|
-
return oklchToHex(clamped.l, clamped.c, clamped.h);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
133
|
function computeDerivedColor(
|
|
162
134
|
step: ScaleStep,
|
|
163
135
|
base: string,
|
|
@@ -212,43 +184,22 @@ export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | und
|
|
|
212
184
|
const overrides = config.overrides ?? {};
|
|
213
185
|
const curveOffset = config.curveOffset ?? {};
|
|
214
186
|
const scaleCurves = config.scaleCurves ?? {};
|
|
187
|
+
const lightnessCurve = config.lightnessCurve ?? DEFAULT_PALETTE_LIGHTNESS();
|
|
188
|
+
const saturationCurve = config.saturationCurve ?? DEFAULT_PALETTE_SATURATION();
|
|
215
189
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const tintChroma = config.tintChroma ?? DEFAULT_TINT_CHROMA;
|
|
223
|
-
|
|
224
|
-
let gray500 = '#808080';
|
|
225
|
-
GRAY_STEPS.forEach((step, index) => {
|
|
226
|
-
const k = grayStepKey(step.label);
|
|
227
|
-
const hex = computeGrayColor(index, tintHue, tintChroma, grayLightnessCurve, graySaturationCurve, curveOffset);
|
|
228
|
-
const effective = (k in overrides) ? overrides[k] : hex;
|
|
229
|
-
out[`--color-${spec.cssNamespace}-${step.label}`] = effective;
|
|
230
|
-
if (step.label === '500') gray500 = hex;
|
|
231
|
-
});
|
|
232
|
-
baseForScales = gray500;
|
|
233
|
-
} else {
|
|
234
|
-
const lightnessCurve = config.lightnessCurve ?? DEFAULT_PALETTE_LIGHTNESS();
|
|
235
|
-
const saturationCurve = config.saturationCurve ?? DEFAULT_PALETTE_SATURATION();
|
|
236
|
-
|
|
237
|
-
PALETTE_STEPS.forEach((ps, index) => {
|
|
238
|
-
const k = paletteStepKey(ps.label);
|
|
239
|
-
const hex = computePaletteColor(index, baseColor, lightnessCurve, saturationCurve, curveOffset);
|
|
240
|
-
const effective = (k in overrides) ? overrides[k] : hex;
|
|
241
|
-
out[`--color-${spec.cssNamespace}-${ps.label}`] = effective;
|
|
242
|
-
});
|
|
243
|
-
baseForScales = baseColor;
|
|
244
|
-
}
|
|
190
|
+
PALETTE_STEPS.forEach((ps, index) => {
|
|
191
|
+
const k = paletteStepKey(ps.label);
|
|
192
|
+
const hex = computePaletteColor(index, baseColor, lightnessCurve, saturationCurve, curveOffset);
|
|
193
|
+
const effective = (k in overrides) ? overrides[k] : hex;
|
|
194
|
+
out[`--color-${spec.cssNamespace}-${ps.label}`] = effective;
|
|
195
|
+
});
|
|
245
196
|
|
|
246
197
|
for (const scale of SCALES) {
|
|
247
198
|
for (const step of scale.steps) {
|
|
248
199
|
const k = stepKey(scale.title, step.name);
|
|
249
200
|
const hex = (k in overrides)
|
|
250
201
|
? overrides[k]
|
|
251
|
-
: computeDerivedColor(step,
|
|
202
|
+
: computeDerivedColor(step, baseColor, scale.title, scaleCurves, curveOffset);
|
|
252
203
|
const varName = scaleToCssVar(scale.title, step.name, spec.cssNamespace);
|
|
253
204
|
if (varName) out[varName] = hex;
|
|
254
205
|
}
|
|
@@ -307,12 +258,11 @@ const HEX_RE = /^#[0-9a-f]{6}$/i;
|
|
|
307
258
|
*
|
|
308
259
|
* - **Snap** (gated by `_imported`): for any palette whose `_imported` flag
|
|
309
260
|
* is true, the imported `--color-{ns}-500` value is treated as the
|
|
310
|
-
* authoritative anchor
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* `themes/default.json`'s accent from teal to olive on first read.
|
|
261
|
+
* authoritative anchor and `baseColor` is snapped to it. The flag is then
|
|
262
|
+
* cleared. Editor-authored palettes (no `_imported`) are left untouched —
|
|
263
|
+
* see `temp/manifest-robustness-plan.md` §9 for why "snap on any
|
|
264
|
+
* divergence" was wrong: it would have flipped `themes/default.json`'s
|
|
265
|
+
* accent from teal to olive on first read.
|
|
316
266
|
*
|
|
317
267
|
* - **Consume** (always): every variable the palette's derivation produces
|
|
318
268
|
* is reported in `consumed` so the caller can strip it from
|
|
@@ -345,12 +295,7 @@ export function reconcilePalettesFromCssVars(
|
|
|
345
295
|
const anchorHex = cssVars[`--color-${spec.cssNamespace}-500`];
|
|
346
296
|
if (anchorHex && HEX_RE.test(anchorHex.trim())) {
|
|
347
297
|
const hex = anchorHex.trim();
|
|
348
|
-
|
|
349
|
-
const { c, h } = hexToOklch(hex);
|
|
350
|
-
next[spec.label] = { ...current, tintHue: h, tintChroma: c, _imported: false };
|
|
351
|
-
} else {
|
|
352
|
-
next[spec.label] = { ...current, baseColor: hex, _imported: false };
|
|
353
|
-
}
|
|
298
|
+
next[spec.label] = { ...current, baseColor: hex, _imported: false };
|
|
354
299
|
snapped.add(spec.label);
|
|
355
300
|
} else {
|
|
356
301
|
// No anchor in cssVariables to snap to — flag has nothing to do; clear
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
runMigrations,
|
|
29
29
|
} from '../themes/migrations';
|
|
30
30
|
import { renamePrimaryPaletteKey } from '../themes/migrations/2026-05-13-primary-to-brand';
|
|
31
|
+
import { unifyGrayPalettes } from '../themes/migrations/2026-06-05-palette-unification';
|
|
31
32
|
import { __resetRendererCacheForTests, installRenderer } from './editorRenderer';
|
|
32
33
|
import {
|
|
33
34
|
store,
|
|
@@ -511,7 +512,7 @@ const domainLoaders: Record<string, DomainLoader> = {
|
|
|
511
512
|
*/
|
|
512
513
|
export function loadFromFile(theme: Theme): void {
|
|
513
514
|
const next = emptyState();
|
|
514
|
-
next.palettes = renamePrimaryPaletteKey(structuredClone(theme.editorConfigs ?? {}));
|
|
515
|
+
next.palettes = unifyGrayPalettes(renamePrimaryPaletteKey(structuredClone(theme.editorConfigs ?? {})));
|
|
515
516
|
next.fonts.sources = structuredClone(theme.fontSources ?? []);
|
|
516
517
|
next.fonts.stacks = structuredClone(theme.fontStacks ?? []);
|
|
517
518
|
const rawVars = runMigrations(
|
|
@@ -84,6 +84,31 @@ function readComponentGradient(component: string, varName: string): GradientAlia
|
|
|
84
84
|
return ref?.kind === 'gradient' ? ref.value : undefined;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
const TRANSPARENT_TOKEN = '--color-transparent';
|
|
88
|
+
|
|
89
|
+
/** The flat colour an alias resolves to, or `null` when it reads as "no fill"
|
|
90
|
+
* (a transparent token/literal, or no alias at all). */
|
|
91
|
+
function flatAliasColor(ref: CssVarRef | undefined): { color: string; opacity: number } | null {
|
|
92
|
+
if (ref === undefined) return null;
|
|
93
|
+
if (ref.kind === 'token') {
|
|
94
|
+
return ref.name === TRANSPARENT_TOKEN ? null : { color: ref.name, opacity: ref.opacity ?? 100 };
|
|
95
|
+
}
|
|
96
|
+
if (ref.kind === 'literal') {
|
|
97
|
+
return ref.value === 'transparent' ? null : { color: ref.value, opacity: 100 };
|
|
98
|
+
}
|
|
99
|
+
return null; // gradient refs are handled by the caller
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Lift a flat-colour (or empty) alias into a single-stop gradient payload so
|
|
103
|
+
* the editor renders — a flat fill reads as `solid`, an empty/transparent one
|
|
104
|
+
* as `none`. The user can then promote it to a real gradient. */
|
|
105
|
+
function flatAliasToGradient(ref: CssVarRef | undefined): GradientAliasValue {
|
|
106
|
+
const flat = flatAliasColor(ref);
|
|
107
|
+
return flat
|
|
108
|
+
? { type: 'solid', angle: 135, stops: [{ position: 50, color: flat.color, opacity: flat.opacity }] }
|
|
109
|
+
: { type: 'none', angle: 135, stops: [{ position: 50, color: TRANSPARENT_TOKEN, opacity: 0 }] };
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
function writeComponentGradient(component: string, varName: string, value: GradientAliasValue): void {
|
|
88
113
|
const ref: CssVarRef = { kind: 'gradient', value };
|
|
89
114
|
setComponentAlias(component, varName, ref);
|
|
@@ -93,14 +118,18 @@ function writeComponentGradient(component: string, varName: string, value: Gradi
|
|
|
93
118
|
export function componentGradientSource(component: string, varName: string): GradientSource {
|
|
94
119
|
const current = derived(editorState, ($s) => {
|
|
95
120
|
const ref = $s.components[component]?.aliases[varName];
|
|
96
|
-
|
|
121
|
+
// A flat-colour (or absent) alias still yields a snapshot — synthesised as
|
|
122
|
+
// a solid/none single-stop fill — so the editor always renders and offers
|
|
123
|
+
// the type picker. Without this the whole section vanished for any
|
|
124
|
+
// non-gradient background (e.g. the default transparent divider fill).
|
|
125
|
+
const value = ref?.kind === 'gradient' ? ref.value : flatAliasToGradient(ref);
|
|
97
126
|
return {
|
|
98
|
-
type:
|
|
99
|
-
angle:
|
|
100
|
-
centerX:
|
|
101
|
-
aspectX:
|
|
102
|
-
aspectY:
|
|
103
|
-
stops:
|
|
127
|
+
type: value.type,
|
|
128
|
+
angle: value.angle,
|
|
129
|
+
centerX: value.centerX,
|
|
130
|
+
aspectX: value.aspectX,
|
|
131
|
+
aspectY: value.aspectY,
|
|
132
|
+
stops: value.stops.map((s) => ({ ...s })),
|
|
104
133
|
};
|
|
105
134
|
});
|
|
106
135
|
/** Read-modify-write through `mutate` so each edit is one history entry
|
|
@@ -110,6 +139,9 @@ export function componentGradientSource(component: string, varName: string): Gra
|
|
|
110
139
|
mutate(label, (s) => {
|
|
111
140
|
const slice = s.components[component] ?? (s.components[component] = { activeFile: 'default', aliases: {}, config: {} });
|
|
112
141
|
const ref = slice.aliases[varName];
|
|
142
|
+
// Seed the editable base from the existing gradient, or lift the current
|
|
143
|
+
// flat-colour alias into one so the first edit promotes (rather than
|
|
144
|
+
// discards) whatever fill was already there.
|
|
113
145
|
const base: GradientAliasValue =
|
|
114
146
|
ref?.kind === 'gradient'
|
|
115
147
|
? {
|
|
@@ -120,7 +152,7 @@ export function componentGradientSource(component: string, varName: string): Gra
|
|
|
120
152
|
...(ref.value.aspectY !== undefined ? { aspectY: ref.value.aspectY } : {}),
|
|
121
153
|
stops: ref.value.stops.map((st) => ({ ...st })),
|
|
122
154
|
}
|
|
123
|
-
:
|
|
155
|
+
: flatAliasToGradient(ref);
|
|
124
156
|
mutator(base);
|
|
125
157
|
slice.aliases[varName] = { kind: 'gradient', value: base };
|
|
126
158
|
});
|
|
@@ -135,7 +167,15 @@ export function componentGradientSource(component: string, varName: string): Gra
|
|
|
135
167
|
...(next.aspectY !== undefined ? { aspectY: next.aspectY } : {}),
|
|
136
168
|
stops: next.stops.map((s) => ({ ...s })),
|
|
137
169
|
}),
|
|
138
|
-
setType: (t) => update(`set gradient type ${varName}`, (g) => {
|
|
170
|
+
setType: (t) => update(`set gradient type ${varName}`, (g) => {
|
|
171
|
+
// Linear/radial need two endpoints; a flat fill carries only one, so
|
|
172
|
+
// mirror it into a 0→100 pair the user can then shape.
|
|
173
|
+
if ((t === 'linear' || t === 'radial') && g.stops.length < 2) {
|
|
174
|
+
const seed = g.stops[0] ?? { position: 0, color: TRANSPARENT_TOKEN, opacity: 100 };
|
|
175
|
+
g.stops = [{ ...seed, position: 0 }, { ...seed, position: 100 }];
|
|
176
|
+
}
|
|
177
|
+
g.type = t;
|
|
178
|
+
}),
|
|
139
179
|
setAngle: (a) => update(`set gradient angle ${varName}`, (g) => { g.angle = a; }),
|
|
140
180
|
setCenterX: (x) => update(`set gradient center ${varName}`, (g) => { g.centerX = x; }),
|
|
141
181
|
setAspect: (a) => update(`set gradient aspect ${varName}`, (g) => {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette unification: drop the gray palette "mode".
|
|
3
|
+
*
|
|
4
|
+
* Lives outside the `runMigrations` framework (whose contract is the flat
|
|
5
|
+
* `Record<string, string>` cssVariables bag) because it transforms the
|
|
6
|
+
* structured `editorConfigs` map, exactly like `renamePrimaryPaletteKey`.
|
|
7
|
+
* Applied unconditionally in `loadFromFile`; idempotent and self-sunsetting —
|
|
8
|
+
* once every saved theme has been resaved without the gray fields, this file
|
|
9
|
+
* is dead code and can be deleted.
|
|
10
|
+
*
|
|
11
|
+
* Two cases:
|
|
12
|
+
* - Neutral / Alternate (the former gray palettes): close-map to the unified
|
|
13
|
+
* shape. The picked base becomes the effective gray-500 (subtle tint +
|
|
14
|
+
* lightness preserved), the lightness curve keeps the neutral ramp, and the
|
|
15
|
+
* saturation curve flattens to 100 — chroma now flows from the base, not a
|
|
16
|
+
* flat-20 multiplier. Hand-shaped gray saturation curves are not reproduced
|
|
17
|
+
* (owner accepted approximate + manual retune).
|
|
18
|
+
* - Every other palette: the gray fields were always vestigial; drop them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { type CurveAnchor, sampleCurve } from '../../../ui/curveEngine';
|
|
22
|
+
import { hexToOklch, oklchToHex, gamutClamp } from '../../palettes/oklch';
|
|
23
|
+
import { DEFAULT_PALETTE_SATURATION } from '../../palettes/paletteDerivation';
|
|
24
|
+
import type { PaletteConfig } from '../themeTypes';
|
|
25
|
+
|
|
26
|
+
const NEUTRAL_LABELS = new Set(['Neutral', 'Alternate']);
|
|
27
|
+
|
|
28
|
+
const GRAY_STEP_COUNT = 11;
|
|
29
|
+
const GRAY_500_X = (4 / (GRAY_STEP_COUNT - 1)) * 100;
|
|
30
|
+
|
|
31
|
+
/** Legacy shape: the gray fields union'd onto every saved palette config. */
|
|
32
|
+
type LegacyGrayFields = {
|
|
33
|
+
tintHue?: number;
|
|
34
|
+
tintChroma?: number;
|
|
35
|
+
grayLightnessCurve?: CurveAnchor[];
|
|
36
|
+
graySaturationCurve?: CurveAnchor[];
|
|
37
|
+
};
|
|
38
|
+
type LegacyPaletteConfig = PaletteConfig & LegacyGrayFields;
|
|
39
|
+
|
|
40
|
+
/** Reproduces the deleted `computeGrayColor` at step 500. */
|
|
41
|
+
function effectiveGray500(cfg: LegacyPaletteConfig): string {
|
|
42
|
+
const hue = cfg.tintHue ?? 240;
|
|
43
|
+
const chroma = cfg.tintChroma ?? 0.04;
|
|
44
|
+
const lCurve = cfg.grayLightnessCurve ?? [];
|
|
45
|
+
const sCurve = cfg.graySaturationCurve ?? [];
|
|
46
|
+
const lOff = cfg.curveOffset?.['gray-lightness'] ?? 0;
|
|
47
|
+
const sOff = cfg.curveOffset?.['gray-saturation'] ?? 0;
|
|
48
|
+
const targetL = Math.max(0, Math.min(100, sampleCurve(lCurve, GRAY_500_X) + lOff)) / 100;
|
|
49
|
+
const satMul = Math.max(0, Math.min(2, (sampleCurve(sCurve, GRAY_500_X) + sOff) / 100));
|
|
50
|
+
const clamped = gamutClamp(targetL, chroma * satMul, hue);
|
|
51
|
+
return oklchToHex(clamped.l, clamped.c, clamped.h);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stripGrayFields(cfg: LegacyPaletteConfig): PaletteConfig {
|
|
55
|
+
const { tintHue, tintChroma, grayLightnessCurve, graySaturationCurve, ...rest } = cfg;
|
|
56
|
+
return rest;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Precondition: `cfg.grayLightnessCurve` is defined (caller-guarded). */
|
|
60
|
+
function unifyNeutral(cfg: LegacyPaletteConfig): PaletteConfig {
|
|
61
|
+
const {
|
|
62
|
+
'gray-lightness': grayLightnessOffset,
|
|
63
|
+
'gray-saturation': _graySaturationOffset,
|
|
64
|
+
lightness: _lightness,
|
|
65
|
+
saturation: _saturation,
|
|
66
|
+
...scaleOffsets
|
|
67
|
+
} = cfg.curveOffset ?? {};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
...stripGrayFields(cfg),
|
|
71
|
+
baseColor: effectiveGray500(cfg),
|
|
72
|
+
lightnessCurve: cfg.grayLightnessCurve!,
|
|
73
|
+
saturationCurve: DEFAULT_PALETTE_SATURATION(),
|
|
74
|
+
curveOffset: { ...scaleOffsets, lightness: grayLightnessOffset ?? 0, saturation: 0 },
|
|
75
|
+
anchorToBase: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function unifyGrayPalettes(
|
|
80
|
+
editorConfigs: Record<string, PaletteConfig>,
|
|
81
|
+
): Record<string, PaletteConfig> {
|
|
82
|
+
const out: Record<string, PaletteConfig> = {};
|
|
83
|
+
for (const [label, cfg] of Object.entries(editorConfigs)) {
|
|
84
|
+
const legacy = cfg as LegacyPaletteConfig;
|
|
85
|
+
out[label] = NEUTRAL_LABELS.has(label) && legacy.grayLightnessCurve !== undefined
|
|
86
|
+
? unifyNeutral(legacy)
|
|
87
|
+
: stripGrayFields(legacy);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
@@ -9,12 +9,8 @@ export interface GradientStop {
|
|
|
9
9
|
|
|
10
10
|
export interface PaletteConfig {
|
|
11
11
|
baseColor: string;
|
|
12
|
-
tintHue: number;
|
|
13
|
-
tintChroma?: number;
|
|
14
12
|
lightnessCurve: CurveAnchor[];
|
|
15
13
|
saturationCurve: CurveAnchor[];
|
|
16
|
-
grayLightnessCurve: CurveAnchor[];
|
|
17
|
-
graySaturationCurve: CurveAnchor[];
|
|
18
14
|
scaleCurves: Record<string, { lightness: CurveAnchor[]; saturation: CurveAnchor[] }>;
|
|
19
15
|
curveOffset: Record<string, number>;
|
|
20
16
|
overrides: Record<string, string>;
|
|
@@ -30,10 +26,9 @@ export interface PaletteConfig {
|
|
|
30
26
|
/**
|
|
31
27
|
* Set to true by importers when they overlay `cssVariables[--color-{ns}-*]`
|
|
32
28
|
* without owning the typed-state curves. The storage-layer reconciler uses
|
|
33
|
-
* it as an opt-in switch: snap `baseColor`
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* strict no-op for them.
|
|
29
|
+
* it as an opt-in switch: snap `baseColor` to the imported
|
|
30
|
+
* `--color-{ns}-500` anchor and clear the flag. Editor-authored themes
|
|
31
|
+
* never set this, so the reconciler is a strict no-op for them.
|
|
37
32
|
*
|
|
38
33
|
* Persists on disk for first-load reconciliation. After reconcile strips
|
|
39
34
|
* the palette-derived keys from `cssVariables`, subsequent reconciles find
|
|
@@ -18,8 +18,9 @@ style your site by editing tokens and components in a live editor. When it looks
|
|
|
18
18
|
|
|
19
19
|
- **Tokens**: the design-system primitives, colour palettes, type, spacing,
|
|
20
20
|
radius, shadow, and gradients, that apply across your whole site.
|
|
21
|
-
- **Components**: the package ships about 25 editable components (Button,
|
|
22
|
-
Dialog, Table, and more).You style components by changing
|
|
21
|
+
- **Components**: the package ships about 25 editable components (Button,
|
|
22
|
+
IconButton, Card, Dialog, Table, and more). You style components by changing
|
|
23
|
+
the tokens assigned to each property.
|
|
23
24
|
|
|
24
25
|
## Where to go next
|
|
25
26
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Source of truth: src/editor/docs/content/*.md · regenerate: npm run sync:docs
|
|
3
3
|
|
|
4
4
|
export const docContent: Record<string, string> = {
|
|
5
|
-
"01-overview": "# Overview\n\nLive Tokens is a design system for building Svelte microsites quickly. You\nstyle your site by editing tokens and components in a live editor. When it looks right, you save the manifest and ship it.\n\n## How it works\n\n- The editor runs in your dev server, on top of your real pages. You style in\n context, not in a separate sandbox.\n- Every change updates a CSS variable, so the page repaints instantly. No\n reload, no build step.\n- Saving writes a small JSON file into your project. Shipping bakes your chosen\n theme into a plain CSS file that the build bundles.\n- The editor is dev-only. Production ships plain CSS variables and the\n components you used, nothing else.\n\n## What you can edit\n\n- **Tokens**: the design-system primitives, colour palettes, type, spacing,\n radius, shadow, and gradients, that apply across your whole site.\n- **Components**: the package ships about 25 editable components (Button, Card
|
|
5
|
+
"01-overview": "# Overview\n\nLive Tokens is a design system for building Svelte microsites quickly. You\nstyle your site by editing tokens and components in a live editor. When it looks right, you save the manifest and ship it.\n\n## How it works\n\n- The editor runs in your dev server, on top of your real pages. You style in\n context, not in a separate sandbox.\n- Every change updates a CSS variable, so the page repaints instantly. No\n reload, no build step.\n- Saving writes a small JSON file into your project. Shipping bakes your chosen\n theme into a plain CSS file that the build bundles.\n- The editor is dev-only. Production ships plain CSS variables and the\n components you used, nothing else.\n\n## What you can edit\n\n- **Tokens**: the design-system primitives, colour palettes, type, spacing,\n radius, shadow, and gradients, that apply across your whole site.\n- **Components**: the package ships about 25 editable components (Button,\n IconButton, Card, Dialog, Table, and more). You style components by changing\n the tokens assigned to each property.\n\n## Where to go next\n\n- **[Getting started](getting-started.md)**: scaffold a project and make your\n first edit.\n- **[Editing tokens](editing-tokens.md)**: a tour of the editor.\n- **[Themes](themes-workflow.md)**: save, switch, and ship.\n- **[Creating components](creating-components.md)**: make your own components\n editable.\n",
|
|
6
6
|
"creating-components": "# Creating components\n\nThe package ships about 25 editable components. When you need one it doesn't\nhave, you can make your own Svelte component editable, so anyone using the\neditor can re-point its colours, type, and spacing without touching code.\n\nThe simplest way is to ask Claude. The package bundles a Claude Code skill that\nknows the conventions, writes the files, and checks the result for you.\n\n## Install the skills\n\n```bash\nnpx @motion-proto/live-tokens setup-claude\n```\n\nThis copies the bundled skills into your project's `.claude/skills/`. Once\nthey're there, Claude Code picks them up automatically.\n\n## Ask for a component\n\nDescribe what you want in plain English. Phrases like these trigger the skill:\n\n- \"Add a Toggle component to live-tokens\"\n- \"Make this Svelte component editable in the live-tokens editor\"\n- \"Create a Stat component with a value and a label\"\n\nClaude asks any clarifying questions it needs (which variants, which states,\nwhich parts), then writes the component, registers it with the editor, and runs\nits verification checklist. When it finishes, open `/live-tokens/components` to see your new\ncomponent in the editor and confirm everything works.\n\n## What you get\n\n- A runtime component whose editable properties default to your theme tokens.\n- An editor entry that appears under **Custom** in the `/live-tokens/components` view.\n- The naming and wiring handled for you, so the component fits the system.\n\nAdvanced authors who want to write a component by hand can read the naming and\nstate-model conventions shipped in the package\n(`src/system/styles/CONVENTIONS.md` and the skill's own `SKILL.md`).\n",
|
|
7
7
|
"editing-tokens": "# Editing tokens\n\nA tour of the editor. The page behind it repaints on every change; saving\nwrites a theme file you can reload later.\n\nThe editor has two views:\n\n- **Tokens**: the design-system primitives (colour, type, spacing, and so on).\n They apply everywhere your site uses them.\n- **Components**: per-component editors. Re-Assign what tokens a component uses\n without changing the underlying system.\n\nThis page covers **Tokens**. For components, see\n[Creating components](creating-components.md).\n\n## Palettes\n\nMost colour work happens here. Each palette (Brand, Accent, Neutral, Canvas,\nSuccess, Warning, Info, Danger, and a few more) has:\n\n- **Base colour.** Pick a hex; the palette derives an 11-step ramp (100 to 950)\n from it.\n- **Curves.** Two curves shape how lightness and saturation fall off across the\n ramp. Drag the handles to bias it darker, lighter, or more saturated.\n- **Overrides.** Lock a single step to a hand-picked hex when the curve doesn't\n land where you want.\n\nEditing a palette base ripples through every colour that depends on it, in real\ntime. Colours use OKLCH, so the ramp stays perceptually even across hues\nwithout muddy mid-tones.\n\n## Type\n\n- **Fonts.** Add sources from Google Fonts, Adobe (Typekit), a CSS URL, or an\n inline `@font-face`. The font loads in the page as soon as you add it.\n- **Stacks.** Named font cascades you reference by token, such as a display\n stack and a body stack.\n- **Sizes and weights.** A t-shirt scale (xs, sm, md, lg, xl, 2xl…) for size and\n a numeric scale (100 to 900) for weight.\n\n## Spacing, radius, shadow\n\nNumeric scales with a slider per step.\n\n- **Spacing**: the padding, gap, and margin scale.\n- **Radius**: none through full.\n- **Shadow**: colour, offset, blur, spread, and opacity per step, with stacked\n shadows supported.\n\nChange a step and every element using it repaints.\n\n## Overlays and gradients\n\n- **Overlays** are translucent tints layered over surfaces, like the subtle\n tint a card gets on hover. Set a colour and opacity per state.\n- **Gradients** are reusable gradient tokens with a stop list and direction, for\n hero panels and accent backgrounds.\n\n## Columns\n\nThe page-grid overlay. Set column count, gutter, and outer margin, and toggle\nthe visual guide with `Cmd/Ctrl+G`. Pages built on the column system reflow\nlive.\n\n## Saving\n\nThe editor saves to your browser continuously, so work survives a reload\nmid-edit. **Save** is a separate step: it writes a named theme file under\n`src/live-tokens/data/themes/`.\n\nThe header gives you undo/redo (`Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`) and a file\nmenu for New, Save, Save as, Switch, and Delete. You can keep many themes side\nby side; one is active at a time. See [Themes](themes-workflow.md) for the full\nlifecycle.\n",
|
|
8
8
|
"getting-started": "# Getting started\n\nScaffold a live token site in a moments. You need Node 20 or later, a\npackage manager (npm, pnpm, or yarn), and a browser. Open claude code in your repo and start building.\n\n## Scaffold a new app\n\n```bash\nnpm create @motion-proto/live-tokens@latest my-app\ncd my-app\nnpm install\nnpm run dev\n```\n\nOpen the URL Vite prints (usually `http://localhost:5173`). You get a\none-page Svelte + Vite app that depends on the published package, with the\neditor wired up and the full component set ready to import.\n\n`npx @motion-proto/live-tokens create my-app` runs the same scaffold without\nthe initialiser package.\n\n### What the scaffold gives you\n\nEvery editable file lives under `src/` and is committed, so `npm install` and\nversion upgrades never touch your styles. The package code stays in\n`node_modules`.\n\n| Path | What it is |\n|------|------------|\n| `src/pages/Home.svelte` | The starter page. Replace it with your own content. |\n| `src/App.svelte` | Your routes. `<LiveTokensRouter>` adds dev-only routes under a reserved `/live-tokens/*` namespace: `/live-tokens/editor`, `/live-tokens/components`, and `/live-tokens/docs`. |\n| `src/system/styles/tokens.css` | Your base token vocabulary, hand-authored. |\n| `src/styles/site.css` | Themed page typography, yours to edit. |\n\n## Your first edit\n\n1. Run `npm run dev` and open the home page.\n2. Click **Open Token Editor**, or visit `/live-tokens/editor`. The editor opens beside\n the page.\n3. Open **Palettes**, pick **Brand**, and change the base hex. The page\n repaints as you type.\n4. Open the file menu and choose **Save as**. A theme appears as JSON under\n `src/live-tokens/data/themes/`.\n5. Reload. Your saved theme is the active theme, so the page returns as you\n left it.\n\n## What you just changed\n\nEvery edit sets a CSS custom property on `:root`. Your components read those\nproperties through `var(--...)`. There is no token build step and no\npreprocessor rewriting your code: the page renders against plain CSS variables\nthe editor swaps live.\n\nTo ship, promote a theme to production in the editor. That bakes the theme's\nvariables into `src/live-tokens/data/tokens.generated.css`, which your build\nbundles alongside `tokens.css`. The editor itself never reaches production.\n\nAlready have a Svelte 5 + Vite app? The\n[README](https://github.com/motionproto/live-tokens#readme) covers installing\ninto an existing project.\n\n## Where to go next\n\n- **[Editing tokens](editing-tokens.md)**: a tour of the editor.\n- **[Themes](themes-workflow.md)**: save, switch, and ship.\n- **[Creating components](creating-components.md)**: make your own component\n editable.\n",
|