@motion-proto/live-tokens 0.37.0 → 0.39.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/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.39.0 — One unified palette model (no gray "mode")
4
+
5
+ ### Changed (breaking)
6
+
7
+ - **The chromatic/gray palette split collapses into a single OKLCH (Lightness /
8
+ Chroma / Hue) model.** A neutral is no longer a special "mode"; it is an
9
+ ordinary low-chroma palette with calm defaults (a low but non-zero base chroma,
10
+ a wider neutral lightness ramp). One derivation path (`computePaletteColor`)
11
+ now serves every palette, and the Neutral / Alternate editors show the same
12
+ full L/C/H picker, lock-to-500 toggle, and derived-scale snapping as accents.
13
+ `PaletteConfig` drops its gray vocabulary (`tintHue`, `tintChroma`,
14
+ `grayLightnessCurve`, `graySaturationCurve`); `baseColor`, `lightnessCurve`,
15
+ and `saturationCurve` are now universal, and the internal `mode` prop is
16
+ removed from the palette editors.
17
+ - **Token names are unchanged** — this is a theme-config schema change, not a
18
+ `tokens.css` migration, so no consumer CSS or token references are affected.
19
+ The shipped `default.json` was regenerated; the neutral shift is sub-1-LSB per
20
+ channel and every derived `--surface-*` / `--text-*` / `--color-*` token is
21
+ byte-identical.
22
+
23
+ ### Fixed
24
+
25
+ - **A component's surface control no longer vanishes when its fill is a flat
26
+ colour.** `componentGradientSource` returned `undefined` for any non-gradient
27
+ alias and `GradientEditor` renders only `{#if gradient}`, so the surface editor
28
+ disappeared whenever a fill was a plain colour (e.g. SectionDivider's default
29
+ transparent). The editor now synthesizes a none/solid single-stop snapshot from
30
+ a flat (or absent) alias so the type picker always renders, and promotes the
31
+ flat alias to a real gradient on first edit. SectionDivider's section heading is
32
+ renamed "Background" → "Surface" to match Panel and disambiguate it from the
33
+ preview backdrop control.
34
+ - **Palette colour overrides now apply live while you drag.** A new Text /
35
+ Surfaces / Borders override previously reached neither the live page nor the
36
+ preview swatch until commit — `handleColorChange` only wrote to the store when
37
+ the key was already an override. Every drag tick now writes (the open session
38
+ collapses to one undo entry; cancel/confirm clean up no-ops), and the per-step
39
+ hex text tracks the live colour too.
40
+
41
+ ### Migration
42
+
43
+ - **Consumer themes migrate automatically on load.** `unifyGrayPalettes` (run in
44
+ `loadFromFile` after `renamePrimaryPaletteKey`) close-maps existing neutrals to
45
+ the unified form: `baseColor` snaps to the effective step-500 colour (preserving
46
+ the subtle tint and the neutral lightness ramp), the saturation curve becomes
47
+ flat-100, and the palette is locked to base. Every palette drops the four
48
+ vestigial gray fields, and the `gray-lightness` / `gray-saturation` curve-offset
49
+ keys fold into `lightness` / `saturation`. Default and flat-saturation neutrals
50
+ are visually identical; only a hand-shaped gray *saturation* curve migrates
51
+ approximately and may want a quick manual retune.
52
+
53
+ ## 0.38.0 — Overridable scroll reset for smooth-scroll hosts
54
+
55
+ ### Added
56
+
57
+ - **`setScrollReset(fn)` lets a host route navigation's scroll reset through its
58
+ own scroll system.** On a non-hash `navigate()` the router resets the viewport
59
+ to the top. That default calls `window.scrollTo(0, 0)`, which is invisible to
60
+ consumers driving scroll with a smooth-scroll library (Lenis, Locomotive):
61
+ their scroll position is decoupled from the window, so the rendered page stays
62
+ put — most visibly when `LiveTokensRouter` intercepts an in-page link (e.g. a
63
+ card) and the new page opens mid-scroll instead of at the top. Register a reset
64
+ that drives your provider (`setScrollReset(() => lenis.scrollTo(0, { immediate: true }))`)
65
+ and both the overlay's nav and intercepted links reset correctly. Hash targets
66
+ still skip the reset so in-page anchors are unaffected. Backward compatible —
67
+ unset, the native `window.scrollTo` behavior is unchanged.
68
+
3
69
  ## 0.37.0 — ImageLightbox `capNatural` accepts a multiple
4
70
 
5
71
  ### Added
@@ -192,16 +192,16 @@ function sampleCurve(anchors, xPos) {
192
192
 
193
193
  // src/editor/core/palettes/paletteDerivation.ts
194
194
  var PALETTE_SPECS = [
195
- { label: "Neutral", cssNamespace: "neutral", mode: "gray", initialColor: "#808080" },
196
- { label: "Alternate", cssNamespace: "alternate", mode: "gray", initialColor: "#808080" },
197
- { label: "Background", cssNamespace: "canvas", mode: "chromatic", emptySelector: true, initialColor: "#1a1a2e" },
198
- { label: "Brand", cssNamespace: "brand", mode: "chromatic", initialColor: "#c93636" },
199
- { label: "Accent", cssNamespace: "accent", mode: "chromatic", initialColor: "#f49e0b" },
200
- { label: "Special", cssNamespace: "special", mode: "chromatic", initialColor: "#8b5cf6" },
201
- { label: "Info", cssNamespace: "info", mode: "chromatic", initialColor: "#3077e8" },
202
- { label: "Success", cssNamespace: "success", mode: "chromatic", initialColor: "#21c45d" },
203
- { label: "Warning", cssNamespace: "warning", mode: "chromatic", initialColor: "#e66e1a" },
204
- { label: "Danger", cssNamespace: "danger", mode: "chromatic", initialColor: "#e8304f" }
195
+ { label: "Neutral", cssNamespace: "neutral", neutral: true, initialColor: "#70787e" },
196
+ { label: "Alternate", cssNamespace: "alternate", neutral: true, initialColor: "#817b78" },
197
+ { label: "Background", cssNamespace: "canvas", emptySelector: true, initialColor: "#1a1a2e" },
198
+ { label: "Brand", cssNamespace: "brand", initialColor: "#c93636" },
199
+ { label: "Accent", cssNamespace: "accent", initialColor: "#f49e0b" },
200
+ { label: "Special", cssNamespace: "special", initialColor: "#8b5cf6" },
201
+ { label: "Info", cssNamespace: "info", initialColor: "#3077e8" },
202
+ { label: "Success", cssNamespace: "success", initialColor: "#21c45d" },
203
+ { label: "Warning", cssNamespace: "warning", initialColor: "#e66e1a" },
204
+ { label: "Danger", cssNamespace: "danger", initialColor: "#e8304f" }
205
205
  ];
206
206
  var PALETTE_STEPS = [
207
207
  { label: "100" },
@@ -216,19 +216,6 @@ var PALETTE_STEPS = [
216
216
  { label: "900" },
217
217
  { label: "950" }
218
218
  ];
219
- var GRAY_STEPS = [
220
- { label: "100" },
221
- { label: "200" },
222
- { label: "300" },
223
- { label: "400" },
224
- { label: "500" },
225
- { label: "600" },
226
- { label: "700" },
227
- { label: "800" },
228
- { label: "850" },
229
- { label: "900" },
230
- { label: "950" }
231
- ];
232
219
  var SCALES = [
233
220
  {
234
221
  title: "Surfaces",
@@ -268,9 +255,6 @@ var SCALES = [
268
255
  ];
269
256
  var DEFAULT_PALETTE_LIGHTNESS = () => [makeAnchor(0, 95, 5), makeAnchor(100, 8, 5)];
270
257
  var DEFAULT_PALETTE_SATURATION = () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)];
271
- var DEFAULT_GRAY_LIGHTNESS = () => [makeAnchor(0, 92, 5), makeAnchor(100, 3, 5)];
272
- var DEFAULT_GRAY_SATURATION = () => [makeAnchor(0, 20, 30), makeAnchor(100, 20, 30)];
273
- var DEFAULT_TINT_CHROMA = 0.04;
274
258
  var defaultScaleCurves = {
275
259
  Surfaces: {
276
260
  lightness: () => [makeAnchor(0, 15, 5), makeAnchor(100, 47, 5)],
@@ -288,9 +272,6 @@ var defaultScaleCurves = {
288
272
  function paletteStepKey(label) {
289
273
  return `Palette-${label}`;
290
274
  }
291
- function grayStepKey(label) {
292
- return `gray-${label}`;
293
- }
294
275
  function stepKey(scaleTitle, stepName) {
295
276
  return `${scaleTitle}-${stepName}`;
296
277
  }
@@ -306,16 +287,6 @@ function computePaletteColor(index, base, lightnessCurve, saturationCurve, curve
306
287
  const clamped = gamutClamp(targetL, targetC, h);
307
288
  return oklchToHex(clamped.l, clamped.c, clamped.h);
308
289
  }
309
- function computeGrayColor(index, hue, chroma, grayLightnessCurve, graySaturationCurve, curveOffset) {
310
- const xPos = stepIndexToX(index, GRAY_STEPS.length);
311
- const lOff = curveOffset["gray-lightness"] ?? 0;
312
- const sOff = curveOffset["gray-saturation"] ?? 0;
313
- const targetL = Math.max(0, Math.min(100, sampleCurve(grayLightnessCurve, xPos) + lOff)) / 100;
314
- const satMul = Math.max(0, Math.min(2, (sampleCurve(graySaturationCurve, xPos) + sOff) / 100));
315
- const targetC = chroma * satMul;
316
- const clamped = gamutClamp(targetL, targetC, hue);
317
- return oklchToHex(clamped.l, clamped.c, clamped.h);
318
- }
319
290
  function computeDerivedColor(step, base, scaleTitle, scaleCurves, curveOffset) {
320
291
  const scale = SCALES.find((s) => s.title === scaleTitle);
321
292
  const idx = scale.steps.indexOf(step);
@@ -361,36 +332,18 @@ function derivePaletteVars(spec, config) {
361
332
  const overrides = config.overrides ?? {};
362
333
  const curveOffset = config.curveOffset ?? {};
363
334
  const scaleCurves = config.scaleCurves ?? {};
364
- let baseForScales;
365
- if (spec.mode === "gray") {
366
- const grayLightnessCurve = config.grayLightnessCurve ?? DEFAULT_GRAY_LIGHTNESS();
367
- const graySaturationCurve = config.graySaturationCurve ?? DEFAULT_GRAY_SATURATION();
368
- const tintHue = config.tintHue ?? 240;
369
- const tintChroma = config.tintChroma ?? DEFAULT_TINT_CHROMA;
370
- let gray500 = "#808080";
371
- GRAY_STEPS.forEach((step, index) => {
372
- const k = grayStepKey(step.label);
373
- const hex = computeGrayColor(index, tintHue, tintChroma, grayLightnessCurve, graySaturationCurve, curveOffset);
374
- const effective = k in overrides ? overrides[k] : hex;
375
- out[`--color-${spec.cssNamespace}-${step.label}`] = effective;
376
- if (step.label === "500") gray500 = hex;
377
- });
378
- baseForScales = gray500;
379
- } else {
380
- const lightnessCurve = config.lightnessCurve ?? DEFAULT_PALETTE_LIGHTNESS();
381
- const saturationCurve = config.saturationCurve ?? DEFAULT_PALETTE_SATURATION();
382
- PALETTE_STEPS.forEach((ps, index) => {
383
- const k = paletteStepKey(ps.label);
384
- const hex = computePaletteColor(index, baseColor, lightnessCurve, saturationCurve, curveOffset);
385
- const effective = k in overrides ? overrides[k] : hex;
386
- out[`--color-${spec.cssNamespace}-${ps.label}`] = effective;
387
- });
388
- baseForScales = baseColor;
389
- }
335
+ const lightnessCurve = config.lightnessCurve ?? DEFAULT_PALETTE_LIGHTNESS();
336
+ const saturationCurve = config.saturationCurve ?? DEFAULT_PALETTE_SATURATION();
337
+ PALETTE_STEPS.forEach((ps, index) => {
338
+ const k = paletteStepKey(ps.label);
339
+ const hex = computePaletteColor(index, baseColor, lightnessCurve, saturationCurve, curveOffset);
340
+ const effective = k in overrides ? overrides[k] : hex;
341
+ out[`--color-${spec.cssNamespace}-${ps.label}`] = effective;
342
+ });
390
343
  for (const scale of SCALES) {
391
344
  for (const step of scale.steps) {
392
345
  const k = stepKey(scale.title, step.name);
393
- const hex = k in overrides ? overrides[k] : computeDerivedColor(step, baseForScales, scale.title, scaleCurves, curveOffset);
346
+ const hex = k in overrides ? overrides[k] : computeDerivedColor(step, baseColor, scale.title, scaleCurves, curveOffset);
394
347
  const varName = scaleToCssVar(scale.title, step.name, spec.cssNamespace);
395
348
  if (varName) out[varName] = hex;
396
349
  }
@@ -451,12 +404,7 @@ function reconcilePalettesFromCssVars(palettes, cssVars) {
451
404
  const anchorHex = cssVars[`--color-${spec.cssNamespace}-500`];
452
405
  if (anchorHex && HEX_RE.test(anchorHex.trim())) {
453
406
  const hex = anchorHex.trim();
454
- if (spec.mode === "gray") {
455
- const { c, h } = hexToOklch(hex);
456
- next[spec.label] = { ...current, tintHue: h, tintChroma: c, _imported: false };
457
- } else {
458
- next[spec.label] = { ...current, baseColor: hex, _imported: false };
459
- }
407
+ next[spec.label] = { ...current, baseColor: hex, _imported: false };
460
408
  snapped.add(spec.label);
461
409
  } else {
462
410
  next[spec.label] = { ...current, _imported: false };
@@ -151,16 +151,16 @@ function sampleCurve(anchors, xPos) {
151
151
 
152
152
  // src/editor/core/palettes/paletteDerivation.ts
153
153
  var PALETTE_SPECS = [
154
- { label: "Neutral", cssNamespace: "neutral", mode: "gray", initialColor: "#808080" },
155
- { label: "Alternate", cssNamespace: "alternate", mode: "gray", initialColor: "#808080" },
156
- { label: "Background", cssNamespace: "canvas", mode: "chromatic", emptySelector: true, initialColor: "#1a1a2e" },
157
- { label: "Brand", cssNamespace: "brand", mode: "chromatic", initialColor: "#c93636" },
158
- { label: "Accent", cssNamespace: "accent", mode: "chromatic", initialColor: "#f49e0b" },
159
- { label: "Special", cssNamespace: "special", mode: "chromatic", initialColor: "#8b5cf6" },
160
- { label: "Info", cssNamespace: "info", mode: "chromatic", initialColor: "#3077e8" },
161
- { label: "Success", cssNamespace: "success", mode: "chromatic", initialColor: "#21c45d" },
162
- { label: "Warning", cssNamespace: "warning", mode: "chromatic", initialColor: "#e66e1a" },
163
- { label: "Danger", cssNamespace: "danger", mode: "chromatic", initialColor: "#e8304f" }
154
+ { label: "Neutral", cssNamespace: "neutral", neutral: true, initialColor: "#70787e" },
155
+ { label: "Alternate", cssNamespace: "alternate", neutral: true, initialColor: "#817b78" },
156
+ { label: "Background", cssNamespace: "canvas", emptySelector: true, initialColor: "#1a1a2e" },
157
+ { label: "Brand", cssNamespace: "brand", initialColor: "#c93636" },
158
+ { label: "Accent", cssNamespace: "accent", initialColor: "#f49e0b" },
159
+ { label: "Special", cssNamespace: "special", initialColor: "#8b5cf6" },
160
+ { label: "Info", cssNamespace: "info", initialColor: "#3077e8" },
161
+ { label: "Success", cssNamespace: "success", initialColor: "#21c45d" },
162
+ { label: "Warning", cssNamespace: "warning", initialColor: "#e66e1a" },
163
+ { label: "Danger", cssNamespace: "danger", initialColor: "#e8304f" }
164
164
  ];
165
165
  var PALETTE_STEPS = [
166
166
  { label: "100" },
@@ -175,19 +175,6 @@ var PALETTE_STEPS = [
175
175
  { label: "900" },
176
176
  { label: "950" }
177
177
  ];
178
- var GRAY_STEPS = [
179
- { label: "100" },
180
- { label: "200" },
181
- { label: "300" },
182
- { label: "400" },
183
- { label: "500" },
184
- { label: "600" },
185
- { label: "700" },
186
- { label: "800" },
187
- { label: "850" },
188
- { label: "900" },
189
- { label: "950" }
190
- ];
191
178
  var SCALES = [
192
179
  {
193
180
  title: "Surfaces",
@@ -227,9 +214,6 @@ var SCALES = [
227
214
  ];
228
215
  var DEFAULT_PALETTE_LIGHTNESS = () => [makeAnchor(0, 95, 5), makeAnchor(100, 8, 5)];
229
216
  var DEFAULT_PALETTE_SATURATION = () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)];
230
- var DEFAULT_GRAY_LIGHTNESS = () => [makeAnchor(0, 92, 5), makeAnchor(100, 3, 5)];
231
- var DEFAULT_GRAY_SATURATION = () => [makeAnchor(0, 20, 30), makeAnchor(100, 20, 30)];
232
- var DEFAULT_TINT_CHROMA = 0.04;
233
217
  var defaultScaleCurves = {
234
218
  Surfaces: {
235
219
  lightness: () => [makeAnchor(0, 15, 5), makeAnchor(100, 47, 5)],
@@ -247,9 +231,6 @@ var defaultScaleCurves = {
247
231
  function paletteStepKey(label) {
248
232
  return `Palette-${label}`;
249
233
  }
250
- function grayStepKey(label) {
251
- return `gray-${label}`;
252
- }
253
234
  function stepKey(scaleTitle, stepName) {
254
235
  return `${scaleTitle}-${stepName}`;
255
236
  }
@@ -265,16 +246,6 @@ function computePaletteColor(index, base, lightnessCurve, saturationCurve, curve
265
246
  const clamped = gamutClamp(targetL, targetC, h);
266
247
  return oklchToHex(clamped.l, clamped.c, clamped.h);
267
248
  }
268
- function computeGrayColor(index, hue, chroma, grayLightnessCurve, graySaturationCurve, curveOffset) {
269
- const xPos = stepIndexToX(index, GRAY_STEPS.length);
270
- const lOff = curveOffset["gray-lightness"] ?? 0;
271
- const sOff = curveOffset["gray-saturation"] ?? 0;
272
- const targetL = Math.max(0, Math.min(100, sampleCurve(grayLightnessCurve, xPos) + lOff)) / 100;
273
- const satMul = Math.max(0, Math.min(2, (sampleCurve(graySaturationCurve, xPos) + sOff) / 100));
274
- const targetC = chroma * satMul;
275
- const clamped = gamutClamp(targetL, targetC, hue);
276
- return oklchToHex(clamped.l, clamped.c, clamped.h);
277
- }
278
249
  function computeDerivedColor(step, base, scaleTitle, scaleCurves, curveOffset) {
279
250
  const scale = SCALES.find((s) => s.title === scaleTitle);
280
251
  const idx = scale.steps.indexOf(step);
@@ -320,36 +291,18 @@ function derivePaletteVars(spec, config) {
320
291
  const overrides = config.overrides ?? {};
321
292
  const curveOffset = config.curveOffset ?? {};
322
293
  const scaleCurves = config.scaleCurves ?? {};
323
- let baseForScales;
324
- if (spec.mode === "gray") {
325
- const grayLightnessCurve = config.grayLightnessCurve ?? DEFAULT_GRAY_LIGHTNESS();
326
- const graySaturationCurve = config.graySaturationCurve ?? DEFAULT_GRAY_SATURATION();
327
- const tintHue = config.tintHue ?? 240;
328
- const tintChroma = config.tintChroma ?? DEFAULT_TINT_CHROMA;
329
- let gray500 = "#808080";
330
- GRAY_STEPS.forEach((step, index) => {
331
- const k = grayStepKey(step.label);
332
- const hex = computeGrayColor(index, tintHue, tintChroma, grayLightnessCurve, graySaturationCurve, curveOffset);
333
- const effective = k in overrides ? overrides[k] : hex;
334
- out[`--color-${spec.cssNamespace}-${step.label}`] = effective;
335
- if (step.label === "500") gray500 = hex;
336
- });
337
- baseForScales = gray500;
338
- } else {
339
- const lightnessCurve = config.lightnessCurve ?? DEFAULT_PALETTE_LIGHTNESS();
340
- const saturationCurve = config.saturationCurve ?? DEFAULT_PALETTE_SATURATION();
341
- PALETTE_STEPS.forEach((ps, index) => {
342
- const k = paletteStepKey(ps.label);
343
- const hex = computePaletteColor(index, baseColor, lightnessCurve, saturationCurve, curveOffset);
344
- const effective = k in overrides ? overrides[k] : hex;
345
- out[`--color-${spec.cssNamespace}-${ps.label}`] = effective;
346
- });
347
- baseForScales = baseColor;
348
- }
294
+ const lightnessCurve = config.lightnessCurve ?? DEFAULT_PALETTE_LIGHTNESS();
295
+ const saturationCurve = config.saturationCurve ?? DEFAULT_PALETTE_SATURATION();
296
+ PALETTE_STEPS.forEach((ps, index) => {
297
+ const k = paletteStepKey(ps.label);
298
+ const hex = computePaletteColor(index, baseColor, lightnessCurve, saturationCurve, curveOffset);
299
+ const effective = k in overrides ? overrides[k] : hex;
300
+ out[`--color-${spec.cssNamespace}-${ps.label}`] = effective;
301
+ });
349
302
  for (const scale of SCALES) {
350
303
  for (const step of scale.steps) {
351
304
  const k = stepKey(scale.title, step.name);
352
- const hex = k in overrides ? overrides[k] : computeDerivedColor(step, baseForScales, scale.title, scaleCurves, curveOffset);
305
+ const hex = k in overrides ? overrides[k] : computeDerivedColor(step, baseColor, scale.title, scaleCurves, curveOffset);
353
306
  const varName = scaleToCssVar(scale.title, step.name, spec.cssNamespace);
354
307
  if (varName) out[varName] = hex;
355
308
  }
@@ -410,12 +363,7 @@ function reconcilePalettesFromCssVars(palettes, cssVars) {
410
363
  const anchorHex = cssVars[`--color-${spec.cssNamespace}-500`];
411
364
  if (anchorHex && HEX_RE.test(anchorHex.trim())) {
412
365
  const hex = anchorHex.trim();
413
- if (spec.mode === "gray") {
414
- const { c, h } = hexToOklch(hex);
415
- next[spec.label] = { ...current, tintHue: h, tintChroma: c, _imported: false };
416
- } else {
417
- next[spec.label] = { ...current, baseColor: hex, _imported: false };
418
- }
366
+ next[spec.label] = { ...current, baseColor: hex, _imported: false };
419
367
  snapped.add(spec.label);
420
368
  } else {
421
369
  next[spec.label] = { ...current, _imported: false };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -428,7 +428,7 @@
428
428
  {#snippet compositeControls(_stateName)}
429
429
  <div class="gradient-bg-section">
430
430
  <GradientEditor
431
- sectionLabel="Background"
431
+ sectionLabel="Surface"
432
432
  source={gradientSources[v.key]}
433
433
  stopIdPrefix={`sectiondivider-${v.key}`}
434
434
  familyFilter={getColorFamily(v.key)}
@@ -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', mode: 'gray', initialColor: '#808080' },
37
- { label: 'Alternate', cssNamespace: 'alternate', mode: 'gray', initialColor: '#808080' },
38
- { label: 'Background', cssNamespace: 'canvas', mode: 'chromatic', emptySelector: true, initialColor: '#1a1a2e' },
39
- { label: 'Brand', cssNamespace: 'brand', mode: 'chromatic', initialColor: '#c93636' },
40
- { label: 'Accent', cssNamespace: 'accent', mode: 'chromatic', initialColor: '#f49e0b' },
41
- { label: 'Special', cssNamespace: 'special', mode: 'chromatic', initialColor: '#8b5cf6' },
42
- { label: 'Info', cssNamespace: 'info', mode: 'chromatic', initialColor: '#3077e8' },
43
- { label: 'Success', cssNamespace: 'success', mode: 'chromatic', initialColor: '#21c45d' },
44
- { label: 'Warning', cssNamespace: 'warning', mode: 'chromatic', initialColor: '#e66e1a' },
45
- { label: 'Danger', cssNamespace: 'danger', mode: 'chromatic', initialColor: '#e8304f' },
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
- let baseForScales: string;
217
-
218
- if (spec.mode === 'gray') {
219
- const grayLightnessCurve = config.grayLightnessCurve ?? DEFAULT_GRAY_LIGHTNESS();
220
- const graySaturationCurve = config.graySaturationCurve ?? DEFAULT_GRAY_SATURATION();
221
- const tintHue = config.tintHue ?? 240;
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, baseForScales, scale.title, scaleCurves, curveOffset);
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. Chromatic palettes get `baseColor` snapped to it;
311
- * gray palettes (Neutral/Alternate) get `tintHue` + `tintChroma` derived
312
- * from it via OKLCH. The flag is then cleared. Editor-authored palettes
313
- * (no `_imported`) are left untouched see `temp/manifest-robustness-plan.md`
314
- * §9 for why "snap on any divergence" was wrong: it would have flipped
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
- if (spec.mode === 'gray') {
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
@@ -13,6 +13,17 @@ function rememberPrev(current: string) {
13
13
 
14
14
  export const route = writable<string>('/');
15
15
 
16
+ // Scroll reset on navigation. The default jumps the native viewport to the top,
17
+ // which is invisible to consumers that drive scrolling with a smooth-scroll
18
+ // library (Lenis, Locomotive): their internal scroll position is decoupled from
19
+ // the window, so `window.scrollTo` leaves the rendered page where it was.
20
+ // `setScrollReset` lets such hosts route the reset through their own provider.
21
+ let scrollReset = () => window.scrollTo(0, 0);
22
+
23
+ export function setScrollReset(fn: () => void) {
24
+ scrollReset = fn;
25
+ }
26
+
16
27
  let initialised = false;
17
28
 
18
29
  /**
@@ -43,7 +54,7 @@ export function navigate(path: string) {
43
54
  rememberPrev(window.location.pathname || '/');
44
55
  history.pushState(null, '', path);
45
56
  if (!path.includes('#')) {
46
- window.scrollTo(0, 0);
57
+ scrollReset();
47
58
  }
48
59
  }
49
60
  route.set(pathname);
@@ -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(