@motion-proto/live-tokens 0.41.0 → 0.42.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,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.42.0 — Canvas, and every ramp places its base color
4
+
5
+ ### Changed (breaking)
6
+
7
+ - **The "Background" palette family is renamed to "Canvas".** Its CSS namespace
8
+ was always `canvas` (`--color-canvas-*`, `--surface-canvas-*`), and every other
9
+ surface that names the family already said Canvas: the token selector,
10
+ SectionDivider's variant families, the shadows backdrop picker. Only the
11
+ palette label said Background, so the label joins the namespace. **No token
12
+ names change**, so consumer CSS is unaffected. The label doubles as a storage
13
+ key, so theme migration `2026-07-29-background-palette-to-canvas` renames the
14
+ `editorConfigs` key and any `harmonyAxes` family binding. It applies
15
+ automatically both when a theme file loads and when a persisted editor session
16
+ hydrates, and it runs before the axes are sanitized, which would otherwise drop
17
+ the now-ineligible family and silently unbind the axis. The shipped
18
+ `default.json` is rewritten under the new key.
19
+
20
+ ### Added
21
+
22
+ - **The Colors view's swatch rows read as ramps.** Both rows (core families and
23
+ functional families) order their swatches by base-color luminance, light to
24
+ dark, matching the palette ramps. The selected swatch dock-magnifies through
25
+ the same `dockGrow` policy the palette grid and the derived-scale strip already
26
+ share, and it animates to its new position as its lightness is dialed, so the
27
+ selection is legible while it walks the row.
28
+
29
+ ### Removed
30
+
31
+ - `Theme.harmonyOrder` and its migration path. The field only ever existed
32
+ between v0.40.1 and v0.41.0, superseded by `harmonyAxes` inside the same
33
+ release cycle, so nothing on disk carries it: a 0.41.0-saved theme wrote both
34
+ fields and `harmonyAxes` was already authoritative on load. Themes with
35
+ neither field still resolve to the default axes, with the unbound Quaternary
36
+ offset from the anchor's own hue.
37
+
38
+ ### Fixed
39
+
40
+ - **Palette ramps now show the enlarged slot for the step the base color sits
41
+ at.** The dock magnification keys off `anchorPlacement`, which the July 24
42
+ migration only wrote for configs carrying the legacy locked-500 curve anchor;
43
+ a palette whose `anchorToBase` was on but never re-edited had no placement, so
44
+ its ramp never magnified anything. Theme migration
45
+ `2026-07-29-place-base-anchors` places those configs up front by pinning the
46
+ base color at the step whose curve lightness is nearest the base L, the same
47
+ thing a base-color edit has always done. It applies on theme load and on
48
+ session hydrate, skips configs that are already placed or have `anchorToBase`
49
+ off, and self-sunsets once every theme has been resaved with a placement.
50
+
51
+ ### Migration
52
+
53
+ - Both migrations in this release are **theme-file migrations**: they run
54
+ automatically when a theme loads and when a persisted editor session hydrates.
55
+ No token names changed, so `tokens.css` is untouched and there is nothing for
56
+ `npx live-tokens migrate` to do. Consumers upgrade by bumping the dependency.
57
+
3
58
  ## 0.41.0 — A Colors view, and semantic text styles
4
59
 
5
60
  ### Added
@@ -198,7 +198,7 @@ function sampleCurve(anchors, xPos) {
198
198
  var PALETTE_SPECS = [
199
199
  { label: "Brand", cssNamespace: "brand", initialColor: { l: 0.5573, c: 0.1841, h: 25.49 } },
200
200
  { label: "Accent", cssNamespace: "accent", initialColor: { l: 0.7674, c: 0.164, h: 70.44 } },
201
- { label: "Background", cssNamespace: "canvas", emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
201
+ { label: "Canvas", cssNamespace: "canvas", emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
202
202
  { label: "Neutral", cssNamespace: "neutral", neutral: true, initialColor: { l: 0.5679, c: 0.0134, h: 240.07 } },
203
203
  { label: "Alternate", cssNamespace: "alternate", neutral: true, displayLabel: "Alternate (neutral)", initialColor: { l: 0.5873, c: 87e-4, h: 48.57 } },
204
204
  { label: "Special", cssNamespace: "special", initialColor: { l: 0.6056, c: 0.2189, h: 292.72 } },
@@ -157,7 +157,7 @@ function sampleCurve(anchors, xPos) {
157
157
  var PALETTE_SPECS = [
158
158
  { label: "Brand", cssNamespace: "brand", initialColor: { l: 0.5573, c: 0.1841, h: 25.49 } },
159
159
  { label: "Accent", cssNamespace: "accent", initialColor: { l: 0.7674, c: 0.164, h: 70.44 } },
160
- { label: "Background", cssNamespace: "canvas", emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
160
+ { label: "Canvas", cssNamespace: "canvas", emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
161
161
  { label: "Neutral", cssNamespace: "neutral", neutral: true, initialColor: { l: 0.5679, c: 0.0134, h: 240.07 } },
162
162
  { label: "Alternate", cssNamespace: "alternate", neutral: true, displayLabel: "Alternate (neutral)", initialColor: { l: 0.5873, c: 87e-4, h: 48.57 } },
163
163
  { label: "Special", cssNamespace: "special", initialColor: { l: 0.6056, c: 0.2189, h: 292.72 } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -21,7 +21,7 @@ export type HarmonyMode =
21
21
  | 'custom';
22
22
 
23
23
  /** Families the user may order/include on the harmony axes (dev-declared pool). */
24
- export const HARMONY_ELIGIBLE: readonly string[] = ['Brand', 'Accent', 'Background', 'Special', 'Neutral', 'Alternate'];
24
+ export const HARMONY_ELIGIBLE: readonly string[] = ['Brand', 'Accent', 'Canvas', 'Special', 'Neutral', 'Alternate'];
25
25
 
26
26
  export const AXIS_COUNT = 4;
27
27
  export const AXIS_ROLES = ['Anchor', 'Secondary', 'Tertiary', 'Quaternary'] as const;
@@ -33,29 +33,6 @@ export interface HarmonyAxis {
33
33
  family: string | null;
34
34
  }
35
35
 
36
- // Slot 0 is the anchor (Brand), then Accent, Background — the pre-axes default.
37
- const LEGACY_DEFAULT_ORDER: readonly string[] = ['Brand', 'Accent', 'Background'];
38
-
39
- /**
40
- * Coerce an untrusted legacy `harmonyOrder` (theme JSON) into a valid order:
41
- * keep only eligible entries, first occurrence of each. A non-array, empty, or
42
- * fully-invalid input falls back to the default order. Private to
43
- * `axesFromLegacyOrder`, the sole migration entry point.
44
- */
45
- function sanitizeLegacyOrder(input: unknown): string[] {
46
- if (!Array.isArray(input)) return [...LEGACY_DEFAULT_ORDER];
47
- const seen = new Set<string>();
48
- const out: string[] = [];
49
- for (const entry of input) {
50
- if (typeof entry !== 'string') continue;
51
- if (!HARMONY_ELIGIBLE.includes(entry)) continue;
52
- if (seen.has(entry)) continue;
53
- seen.add(entry);
54
- out.push(entry);
55
- }
56
- return out.length > 0 ? out : [...LEGACY_DEFAULT_ORDER];
57
- }
58
-
59
36
  // In-range hues pass through bit-exact: the mod-360 round trip perturbs the
60
37
  // last mantissa bits, which would make a preserved hue compare unequal.
61
38
  const norm = (h: number): number => (h >= 0 && h < 360 ? h : ((h % 360) + 360) % 360);
@@ -116,7 +93,7 @@ export function defaultHarmonyAxes(): HarmonyAxis[] {
116
93
  return [
117
94
  { hue: anchorHue, family: 'Brand' },
118
95
  { hue: specInitialHue('Accent'), family: 'Accent' },
119
- { hue: specInitialHue('Background'), family: 'Background' },
96
+ { hue: specInitialHue('Canvas'), family: 'Canvas' },
120
97
  { hue: norm(anchorHue + 270), family: null },
121
98
  ];
122
99
  }
@@ -196,33 +173,10 @@ export function sanitizeHarmonyAxes(
196
173
  axis.hue = norm(palettes[axis.family].baseColor.h);
197
174
  }
198
175
  }
199
- return axes;
200
- }
201
-
202
- /**
203
- * Migrate a legacy `harmonyOrder` into axes: `order[i]` binds to axis `i` (via
204
- * the same eligibility/dedup rules as the old model), seeded at its palette's
205
- * `baseColor.h` (or the family's spec hue when no palette loaded). Unfilled axes
206
- * stay unbound at the default hues, Quaternary offset from the migrated anchor.
207
- */
208
- export function axesFromLegacyOrder(
209
- order: unknown,
210
- palettes: Record<string, PaletteConfig>,
211
- ): HarmonyAxis[] {
212
- const legacy = sanitizeLegacyOrder(order);
213
- const defaults = defaultHarmonyAxes();
214
- const hueOf = (family: string): number =>
215
- norm(palettes[family] ? palettes[family].baseColor.h : specInitialHue(family));
216
- const axes: HarmonyAxis[] = [];
217
- for (let i = 0; i < AXIS_COUNT; i++) {
218
- const family = legacy[i] ?? null;
219
- if (family) {
220
- axes.push({ hue: hueOf(family), family });
221
- } else if (i === AXIS_COUNT - 1) {
222
- axes.push({ hue: norm(axes[0].hue + 270), family: null });
223
- } else {
224
- axes.push({ hue: defaults[i].hue, family: null });
225
- }
226
- }
176
+ // With no stored axes (a theme predating them), the default Quaternary hue is
177
+ // an offset from the anchor, so it follows the anchor's palette-seeded hue
178
+ // rather than the spec hue the defaults were built from.
179
+ const last = axes[AXIS_COUNT - 1];
180
+ if (!Array.isArray(input) && last.family === null) last.hue = norm(axes[0].hue + 270);
227
181
  return axes;
228
182
  }
@@ -40,7 +40,7 @@ export interface PaletteSpec {
40
40
  export const PALETTE_SPECS: readonly PaletteSpec[] = [
41
41
  { label: 'Brand', cssNamespace: 'brand', initialColor: { l: 0.5573, c: 0.1841, h: 25.49 } },
42
42
  { label: 'Accent', cssNamespace: 'accent', initialColor: { l: 0.7674, c: 0.164, h: 70.44 } },
43
- { label: 'Background', cssNamespace: 'canvas', emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
43
+ { label: 'Canvas', cssNamespace: 'canvas', emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
44
44
  { label: 'Neutral', cssNamespace: 'neutral', neutral: true, initialColor: { l: 0.5679, c: 0.0134, h: 240.07 } },
45
45
  { label: 'Alternate', cssNamespace: 'alternate', neutral: true, displayLabel: 'Alternate (neutral)', initialColor: { l: 0.5873, c: 0.0087, h: 48.57 } },
46
46
  { label: 'Special', cssNamespace: 'special', initialColor: { l: 0.6056, c: 0.2189, h: 292.72 } },
@@ -20,7 +20,12 @@ import { store } from './editorCore';
20
20
  import { quietGet, quietSet } from '../storage/storage';
21
21
  import { makeDefaultGradients } from '../themes/slices/gradients';
22
22
  import { seedShadowsFromDom } from '../themes/slices/shadows';
23
- import { sanitizeHarmonyAxes, axesFromLegacyOrder } from '../palettes/colorHarmony';
23
+ import { sanitizeHarmonyAxes } from '../palettes/colorHarmony';
24
+ import {
25
+ renameBackgroundPaletteKey,
26
+ renameBackgroundHarmonyFamily,
27
+ } from '../themes/migrations/2026-07-29-background-palette-to-canvas';
28
+ import { placeUnplacedBaseAnchors } from '../themes/migrations/2026-07-29-place-base-anchors';
24
29
 
25
30
  // Resolve the persist key lazily (per-call) so library consumers that invoke
26
31
  // `configureEditor({storagePrefix})` before the first store write get the
@@ -88,20 +93,25 @@ export function normalizeComponents(state: EditorState): EditorState {
88
93
  return { ...state, components };
89
94
  }
90
95
 
91
- // `hydrate` shallow-merges persisted state over `emptyState()`, which always
92
- // seeds a default `harmonyAxes`. So a pre-axes session (persisted `harmonyOrder`,
93
- // no `harmonyAxes`) surfaces here with the injected default axes over its real
94
- // palettes. The new store never persists `harmonyOrder`, so its presence is the
95
- // definitive pre-axes signal: migrate from it, seeding hues off the palettes.
96
- // Otherwise reconcile the persisted axes. Either way, drop the stale legacy key.
96
+ // A session persisted before the rename keys its palette by the old label and
97
+ // binds an axis to it. Both have to move before the axes are sanitized, which
98
+ // would otherwise drop the now-ineligible family and unbind the axis.
99
+ export function normalizePaletteLabels(state: EditorState): EditorState {
100
+ return { ...state, palettes: renameBackgroundPaletteKey(state.palettes) };
101
+ }
102
+
103
+ // `hydrate` shallow-merges persisted state over `emptyState()`, so the axes that
104
+ // arrive here may be the injected defaults sitting over the session's real
105
+ // palettes. Reconcile them against those palettes.
97
106
  export function normalizeHarmonyAxes(state: EditorState): EditorState {
98
- const legacy = (state as { harmonyOrder?: unknown }).harmonyOrder;
99
- const axes = legacy !== undefined
100
- ? axesFromLegacyOrder(legacy, state.palettes)
101
- : sanitizeHarmonyAxes(state.harmonyAxes, state.palettes);
102
- const next = { ...state, harmonyAxes: axes };
103
- delete (next as { harmonyOrder?: unknown }).harmonyOrder;
104
- return next;
107
+ const axes = renameBackgroundHarmonyFamily(state.harmonyAxes);
108
+ return { ...state, harmonyAxes: sanitizeHarmonyAxes(axes, state.palettes) };
109
+ }
110
+
111
+ // A session persisted before placements existed has anchored palettes with no
112
+ // `anchorPlacement`, so their ramps never show the anchored slot.
113
+ export function normalizeBaseAnchors(state: EditorState): EditorState {
114
+ return { ...state, palettes: placeUnplacedBaseAnchors(state.palettes) };
105
115
  }
106
116
 
107
117
  export function hydrate(): void {
@@ -112,7 +122,7 @@ export function hydrate(): void {
112
122
  // Shallow-merge onto default shape so older persisted state missing
113
123
  // newly-added domain fields still loads.
114
124
  const merged = { ...emptyStateFactory(), ...(parsed as object) } as EditorState;
115
- store.set(normalizeHarmonyAxes(normalizeComponents(migrateGradients(merged))));
125
+ store.set(normalizeHarmonyAxes(normalizeBaseAnchors(normalizePaletteLabels(normalizeComponents(migrateGradients(merged))))));
116
126
  }
117
127
  // m13 fix: seed shadows from the DOM at hydrate time so the editor
118
128
  // captures the tokens.css baseline regardless of whether the user opens
@@ -28,11 +28,16 @@ import {
28
28
  runMigrations,
29
29
  } from '../themes/migrations';
30
30
  import { renamePrimaryPaletteKey } from '../themes/migrations/2026-05-13-primary-to-brand';
31
+ import {
32
+ renameBackgroundPaletteKey,
33
+ renameBackgroundHarmonyFamily,
34
+ } from '../themes/migrations/2026-07-29-background-palette-to-canvas';
31
35
  import { unifyGrayPalettes } from '../themes/migrations/2026-06-05-palette-unification';
32
36
  import { migratePaletteColorsToOklch, type PreOklchPaletteConfig } from '../themes/migrations/2026-07-21-palette-oklch-basis';
33
37
  import { adoptLegacyBaseAnchor } from '../themes/migrations/2026-07-24-base-anchor-placement';
38
+ import { placeUnplacedBaseAnchors } from '../themes/migrations/2026-07-29-place-base-anchors';
34
39
  import { adoptBackgroundSpotAsBase } from '../themes/migrations/2026-07-25-background-spot-to-base';
35
- import { defaultHarmonyAxes, sanitizeHarmonyAxes, axesFromLegacyOrder } from '../palettes/colorHarmony';
40
+ import { defaultHarmonyAxes, sanitizeHarmonyAxes } from '../palettes/colorHarmony';
36
41
  import { __resetRendererCacheForTests, installRenderer } from './editorRenderer';
37
42
  import {
38
43
  store,
@@ -517,13 +522,15 @@ const domainLoaders: Record<string, DomainLoader> = {
517
522
  */
518
523
  export function loadFromFile(theme: Theme): void {
519
524
  const next = emptyState();
520
- // Structural palette migrations, innermost → outermost: rename Primary→Brand,
521
- // drop the gray "mode", convert every color channel to the numeric OKLCH
522
- // basis, adopt any legacy locked-500 anchor as a persisted placement, then
523
- // fold a legacy background spot into the base color. The clone is still
524
- // pre-basis (hex or already-numeric) on disk.
525
+ // Structural palette migrations, innermost → outermost: rename Primary→Brand
526
+ // and Background→Canvas (both key renames, so they precede anything that looks
527
+ // a palette up by label), drop the gray "mode", convert every color channel to
528
+ // the numeric OKLCH basis, adopt any legacy locked-500 anchor as a persisted
529
+ // placement, fold a legacy background spot into the base color, then place any
530
+ // still-unplaced anchor by base luminance. The clone is still pre-basis (hex
531
+ // or already-numeric) on disk.
525
532
  const raw = structuredClone(theme.editorConfigs ?? {}) as Record<string, PreOklchPaletteConfig>;
526
- next.palettes = adoptBackgroundSpotAsBase(adoptLegacyBaseAnchor(migratePaletteColorsToOklch(unifyGrayPalettes(renamePrimaryPaletteKey(raw)))));
533
+ next.palettes = placeUnplacedBaseAnchors(adoptBackgroundSpotAsBase(adoptLegacyBaseAnchor(migratePaletteColorsToOklch(unifyGrayPalettes(renameBackgroundPaletteKey(renamePrimaryPaletteKey(raw)))))));
527
534
  next.fonts.sources = structuredClone(theme.fontSources ?? []);
528
535
  next.fonts.stacks = structuredClone(theme.fontStacks ?? []);
529
536
  const rawVars = runMigrations(
@@ -536,9 +543,7 @@ export function loadFromFile(theme: Theme): void {
536
543
  // (or in components' case, vars that wouldn't have been in the theme to
537
544
  // begin with).
538
545
  for (const load of Object.values(domainLoaders)) load(next, rawVars);
539
- next.harmonyAxes = theme.harmonyAxes
540
- ? sanitizeHarmonyAxes(theme.harmonyAxes, next.palettes)
541
- : axesFromLegacyOrder(theme.harmonyOrder, next.palettes);
546
+ next.harmonyAxes = sanitizeHarmonyAxes(renameBackgroundHarmonyFamily(theme.harmonyAxes), next.palettes);
542
547
  next.cssVars = rawVars;
543
548
  resetHistoryForLoad();
544
549
  store.set(next);
@@ -564,9 +569,6 @@ export function toTheme(state: EditorState, meta: { name: string }): Theme {
564
569
  if (state.shadows.tokens.length > 0) {
565
570
  Object.assign(cssVariables, shadowsToVars(state.shadows));
566
571
  }
567
- // Compat: bound families in axis order for older builds. Omitted when nothing
568
- // is bound — an empty legacy list would resurrect the default trio on load.
569
- const boundOrder = state.harmonyAxes.filter((a) => a.family !== null).map((a) => a.family!);
570
572
  return {
571
573
  name: meta.name,
572
574
  createdAt: now,
@@ -576,7 +578,6 @@ export function toTheme(state: EditorState, meta: { name: string }): Theme {
576
578
  fontSources: state.fonts.sources,
577
579
  fontStacks: state.fonts.stacks,
578
580
  harmonyAxes: state.harmonyAxes,
579
- ...(boundOrder.length > 0 ? { harmonyOrder: boundOrder } : {}),
580
581
  schemaVersion: CURRENT_THEME_SCHEMA_VERSION,
581
582
  };
582
583
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Palette family "Background" → "Canvas".
3
+ *
4
+ * The family's CSS namespace was always `canvas` (`--color-canvas-*`,
5
+ * `--surface-canvas-*`), and every other surface that names it — the token
6
+ * selector, SectionDivider's variant families, the shadows backdrop picker —
7
+ * said Canvas too. Only the palette label said Background, so the label joins
8
+ * the namespace rather than the other way round. No token names change.
9
+ *
10
+ * The label doubles as a storage key in two places, so this renames both: the
11
+ * `editorConfigs` map key and any `harmonyAxes` family binding. It has to run
12
+ * before `sanitizeHarmonyAxes`, which drops families outside `HARMONY_ELIGIBLE`
13
+ * to `null` and would silently unbind the axis.
14
+ *
15
+ * Lives outside the `runMigrations` framework (whose contract is the flat
16
+ * cssVariables bag) because it transforms structured state, exactly like
17
+ * `renamePrimaryPaletteKey`, which it composes with in `loadFromFile`. Applied
18
+ * unconditionally; idempotent (a `Canvas` key already present wins, so a second
19
+ * run is a no-op) and self-sunsetting once every saved theme has been resaved.
20
+ */
21
+
22
+ import type { HarmonyAxis } from '../../palettes/colorHarmony';
23
+
24
+ const OLD = 'Background';
25
+ const NEW = 'Canvas';
26
+
27
+ export function renameBackgroundPaletteKey<T>(editorConfigs: Record<string, T>): Record<string, T> {
28
+ if (!(OLD in editorConfigs)) return editorConfigs;
29
+ const { [OLD]: legacy, ...rest } = editorConfigs;
30
+ // A hand-merged file could carry both; the already-renamed key is the newer intent.
31
+ return NEW in rest ? rest : { ...rest, [NEW]: legacy };
32
+ }
33
+
34
+ export function renameBackgroundHarmonyFamily(axes: unknown): unknown {
35
+ if (!Array.isArray(axes)) return axes;
36
+ return axes.map((axis) =>
37
+ axis && typeof axis === 'object' && (axis as HarmonyAxis).family === OLD
38
+ ? { ...(axis as HarmonyAxis), family: NEW }
39
+ : axis,
40
+ );
41
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Base-anchor placement for never-edited palettes.
3
+ *
4
+ * `anchorToBase` defaults on, but configs saved before placements existed
5
+ * carry no `anchorPlacement` until the next base-color edit — so their ramps
6
+ * never show the anchored slot. Place them up front: `syncBaseAnchor` pins the
7
+ * base color at the step whose curve lightness is nearest the base L.
8
+ *
9
+ * Composes outermost in `loadFromFile` — it must run after
10
+ * `adoptBackgroundSpotAsBase`, which derives the legacy spot color from the
11
+ * not-yet-pinned curves — and over hydrated session state
12
+ * (`normalizeBaseAnchors`). Idempotent (skips placed configs) and
13
+ * self-sunsetting once every saved theme has been resaved with a placement.
14
+ */
15
+
16
+ import { syncBaseAnchor } from '../../palettes/paletteDerivation';
17
+ import type { PaletteConfig } from '../themeTypes';
18
+
19
+ export function placeUnplacedBaseAnchors(
20
+ editorConfigs: Record<string, PaletteConfig>,
21
+ ): Record<string, PaletteConfig> {
22
+ for (const cfg of Object.values(editorConfigs)) {
23
+ if (cfg.anchorPlacement !== undefined || cfg.anchorToBase === false) continue;
24
+ // Disk/storage data: a config missing its basis can't be placed.
25
+ if (!cfg.baseColor || !cfg.lightnessCurve?.length || !cfg.saturationCurve?.length) continue;
26
+ syncBaseAnchor(cfg);
27
+ }
28
+ return editorConfigs;
29
+ }
@@ -91,11 +91,6 @@ export interface Theme {
91
91
  cssVariables: Record<string, string>;
92
92
  fontSources?: FontSource[];
93
93
  fontStacks?: FontStack[];
94
- /**
95
- * Deprecated legacy field. Read when `harmonyAxes` is absent; written on save
96
- * for older builds.
97
- */
98
- harmonyOrder?: string[];
99
94
  /** Four fixed harmony axes, each owning a hue with an optional bound family. */
100
95
  harmonyAxes?: HarmonyAxis[];
101
96
  /**
@@ -57,21 +57,21 @@
57
57
  let partialCoverage = $derived(rec.suggestions.filter((s) => !s.current.coverage.full));
58
58
 
59
59
  // Shadow the consumed text variables locally with the store-derived values;
60
- // the page field is the Background family's SEED, not its derived page step.
60
+ // the page field is the Canvas family's SEED, not its derived page step.
61
61
  let storyVars = $derived(
62
62
  ['--text-primary', '--text-secondary', '--text-tertiary', '--text-inverted']
63
63
  .concat([...TONES, ...FUNCTIONAL].map((t) => `--text-${t.ns}`))
64
64
  .filter((name) => vars[name] !== undefined)
65
65
  .map((name) => `${name}: ${vars[name]}`)
66
- .concat(`--page-bg: ${seedHex('Background')}`)
66
+ .concat(`--page-bg: ${seedHex('Canvas')}`)
67
67
  .join('; '),
68
68
  );
69
69
 
70
- // Ratios are measured against the DISPLAYED field — the Background seed —
70
+ // Ratios are measured against the DISPLAYED field — the Canvas seed —
71
71
  // so the numbers always describe what the story shows.
72
72
  function ratioOnBg(textVar: string): number | null {
73
73
  const text = vars[textVar];
74
- return typeof text === 'string' ? contrastRatio(text, seedHex('Background')) : null;
74
+ return typeof text === 'string' ? contrastRatio(text, seedHex('Canvas')) : null;
75
75
  }
76
76
 
77
77
  // Floor, don't round: the readout must never overstate a contrast ratio.
@@ -97,7 +97,7 @@
97
97
  <div class="story" style={storyVars}>
98
98
  <div class="field">
99
99
  <div class="field-head">
100
- <span class="field-label">Background</span>
100
+ <span class="field-label">Canvas</span>
101
101
  {#if fmt(bgRatio)}<span class="ratio">{fmt(bgRatio)}</span>{/if}
102
102
  </div>
103
103
  <p class="body">The dominant surface. Body text sits here.</p>
@@ -125,7 +125,7 @@
125
125
  <span class="suggest-entry" class:dim={!s.differs}>
126
126
  <span class="fn-dot" style:--dot={s.suggested.hex}></span>
127
127
  <span class="suggest-step">{s.step}</span>
128
- <span class="ratio">{fmt(contrastRatio(s.suggested.hex, seedHex('Background')))}</span>
128
+ <span class="ratio">{fmt(contrastRatio(s.suggested.hex, seedHex('Canvas')))}</span>
129
129
  </span>
130
130
  {/each}
131
131
  <label class="bw-toggle" title="Anchor the suggestion at pure white or black instead of the softened extreme">
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { flip } from 'svelte/animate';
3
+ import { cubicOut } from 'svelte/easing';
2
4
  import { oklchToHexClamped } from '../../core/palettes/oklch';
3
5
  import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
4
6
  import { editorState, beginSliderGesture, transaction } from '../../core/store/editorStore';
@@ -13,6 +15,7 @@
13
15
  import UIPillButton from '../UIPillButton.svelte';
14
16
  import { setBaseColor, setBaseColors, setAxisHues, applySolvedTextCurves } from './paletteBaseColor';
15
17
  import { HARMONY_MODE_BUTTONS } from './harmonyModeIcons';
18
+ import { dockGrow } from '../palette/dockMagnify';
16
19
 
17
20
  let selected = $state('Brand');
18
21
  let activeMode = $state<HarmonyMode>('custom');
@@ -71,14 +74,17 @@
71
74
  label: spec.label,
72
75
  display: spec.displayLabel ?? spec.label,
73
76
  hex: oklchToHexClamped(l, c, h),
77
+ l,
74
78
  onWheel: $editorState.harmonyAxes.some((a, i) => a.family === spec.label && active[i]),
75
79
  };
76
80
  });
77
81
  });
78
82
 
79
83
  const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
80
- let coreSwatches = $derived(swatches.filter((sw) => !FUNCTIONAL_FAMILIES.has(sw.label)));
81
- let functionalSwatches = $derived(swatches.filter((sw) => FUNCTIONAL_FAMILIES.has(sw.label)));
84
+ // Light dark, matching the palette ramps (white bookend on the left).
85
+ const byLuminance = (arr: typeof swatches) => [...arr].sort((a, b) => b.l - a.l);
86
+ let coreSwatches = $derived(byLuminance(swatches.filter((sw) => !FUNCTIONAL_FAMILIES.has(sw.label))));
87
+ let functionalSwatches = $derived(byLuminance(swatches.filter((sw) => FUNCTIONAL_FAMILIES.has(sw.label))));
82
88
 
83
89
  let shownModeLabel = $derived(
84
90
  HARMONY_MODE_BUTTONS.find((b) => b.mode === (hoverMode ?? activeMode))?.label ?? '',
@@ -163,33 +169,33 @@
163
169
 
164
170
  <div class="group">
165
171
  <span class="eyebrow">Swatches</span>
166
- {#snippet swatchButton(sw: (typeof swatches)[number])}
167
- <button
168
- type="button"
169
- class="swatch"
170
- class:active={selected === sw.label}
171
- style="--swatch-fill: {sw.hex}"
172
- title={sw.display}
173
- aria-label={`Select ${sw.display}`}
174
- aria-pressed={selected === sw.label}
175
- onclick={() => select(sw.label)}
176
- >
177
- <span class="chip"></span>
178
- <span class="swatch-label">{sw.label}</span>
179
- {#if sw.onWheel}<span class="wheel-dot" title="On the wheel" aria-hidden="true"></span>{/if}
180
- </button>
181
- {/snippet}
182
- <div class="swatch-rows">
183
- <div class="swatch-row">
184
- {#each coreSwatches as sw (sw.label)}
185
- {@render swatchButton(sw)}
186
- {/each}
187
- </div>
172
+ {#snippet swatchRow(row: typeof swatches)}
173
+ {@const selIdx = row.findIndex((sw) => sw.label === selected)}
188
174
  <div class="swatch-row">
189
- {#each functionalSwatches as sw (sw.label)}
190
- {@render swatchButton(sw)}
175
+ {#each row as sw, i (sw.label)}
176
+ <button
177
+ type="button"
178
+ class="swatch"
179
+ class:active={selected === sw.label}
180
+ style="--swatch-fill: {sw.hex}"
181
+ style:flex-grow={dockGrow(i, selIdx === -1 ? null : selIdx)}
182
+ title={sw.display}
183
+ aria-label={`Select ${sw.display}`}
184
+ aria-pressed={selected === sw.label}
185
+ onclick={() => select(sw.label)}
186
+ animate:flip={{ duration: 200, easing: cubicOut }}
187
+ >
188
+ <span class="chip">
189
+ {#if sw.onWheel}<span class="wheel-dot" title="On the wheel" aria-hidden="true"></span>{/if}
190
+ </span>
191
+ <span class="swatch-label">{sw.label}</span>
192
+ </button>
191
193
  {/each}
192
194
  </div>
195
+ {/snippet}
196
+ <div class="swatch-rows">
197
+ {@render swatchRow(coreSwatches)}
198
+ {@render swatchRow(functionalSwatches)}
193
199
  </div>
194
200
  </div>
195
201
 
@@ -438,14 +444,18 @@
438
444
  min-width: 0;
439
445
  }
440
446
 
447
+ /* Dock magnification, mirroring PaletteStepStrip: the flex-grow transition
448
+ animates the selected swatch opening while flip walks it to its
449
+ luminance-sorted spot as lightness is dialed. */
441
450
  .swatch-row {
442
- display: grid;
443
- grid-template-columns: repeat(auto-fill, minmax(4rem, 1fr));
451
+ display: flex;
452
+ align-items: flex-end;
444
453
  gap: var(--ui-space-8);
445
454
  }
446
455
 
447
456
  .swatch {
448
- position: relative;
457
+ flex: 1 1 0;
458
+ min-width: 0;
449
459
  display: flex;
450
460
  flex-direction: column;
451
461
  align-items: stretch;
@@ -454,15 +464,26 @@
454
464
  background: none;
455
465
  border: none;
456
466
  cursor: pointer;
467
+ transition: flex-grow var(--ui-transition-fast);
457
468
  }
458
469
 
459
470
  .chip {
471
+ position: relative;
460
472
  display: block;
461
473
  height: 2.75rem;
474
+ /* Compensating margin: height + margin-top is constant every frame of the
475
+ magnification, so the row's layout height never dips mid-transition.
476
+ Chip grows upward, label pinned. */
477
+ margin-top: 0.5rem;
462
478
  border-radius: var(--ui-radius-sm);
463
479
  background: var(--swatch-fill);
464
480
  border: 1px solid var(--ui-border-low);
465
- transition: border-color var(--ui-transition-fast);
481
+ transition: border-color var(--ui-transition-fast), height var(--ui-transition-fast), margin-top var(--ui-transition-fast);
482
+ }
483
+
484
+ .swatch.active .chip {
485
+ height: 3.25rem;
486
+ margin-top: 0;
466
487
  }
467
488
 
468
489
  .swatch:hover .chip {
@@ -497,6 +518,7 @@
497
518
  border-radius: var(--ui-radius-full);
498
519
  background: var(--ui-text-primary);
499
520
  box-shadow: 0 0 0 1.5px var(--ui-surface-lowest);
521
+ pointer-events: none;
500
522
  }
501
523
 
502
524
  @media (max-width: 1024px) {
@@ -328,7 +328,7 @@
328
328
  "snappedScales": [],
329
329
  "anchorToBase": true
330
330
  },
331
- "Background": {
331
+ "Canvas": {
332
332
  "baseColor": "#3c3c53",
333
333
  "lightnessCurve": [
334
334
  {