@motion-proto/live-tokens 0.40.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/dist-plugin/{chunk-D77VD4Z6.js → chunk-H4TRUINI.js} +102 -13
  3. package/dist-plugin/index.cjs +288 -67
  4. package/dist-plugin/index.js +171 -55
  5. package/dist-plugin/tokensCssMigrations/index.cjs +102 -13
  6. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  7. package/package.json +3 -2
  8. package/src/app/site.css +97 -20
  9. package/src/editor/core/palettes/colorHarmony.ts +228 -0
  10. package/src/editor/core/palettes/contrast.ts +128 -0
  11. package/src/editor/core/palettes/oklch.ts +8 -0
  12. package/src/editor/core/palettes/paletteDerivation.ts +267 -72
  13. package/src/editor/core/palettes/recommendText.ts +216 -0
  14. package/src/editor/core/palettes/solveTextContrast.ts +251 -0
  15. package/src/editor/core/store/editorCore.ts +2 -1
  16. package/src/editor/core/store/editorPersistence.ts +18 -1
  17. package/src/editor/core/store/editorStore.ts +20 -1
  18. package/src/editor/core/store/editorTypes.ts +4 -0
  19. package/src/editor/core/store/editorViewStore.ts +7 -3
  20. package/src/editor/core/themes/migrations/2026-06-05-palette-unification.ts +11 -9
  21. package/src/editor/core/themes/migrations/2026-07-21-palette-oklch-basis.ts +51 -0
  22. package/src/editor/core/themes/migrations/2026-07-24-base-anchor-placement.ts +37 -0
  23. package/src/editor/core/themes/migrations/2026-07-25-background-spot-to-base.ts +40 -0
  24. package/src/editor/core/themes/themeTypes.ts +23 -3
  25. package/src/editor/docs/CodeBlock.svelte +1 -1
  26. package/src/editor/docs/Docs.svelte +30 -13
  27. package/src/editor/pages/EditorShell.svelte +31 -3
  28. package/src/editor/ui/ColorEditPanel.svelte +22 -16
  29. package/src/editor/ui/EditorViewSwitcher.svelte +43 -9
  30. package/src/editor/ui/FontStackEditor.svelte +0 -1
  31. package/src/editor/ui/PaletteEditor.svelte +117 -155
  32. package/src/editor/ui/ProjectFontsSection.svelte +0 -3
  33. package/src/editor/ui/TextStylesSection.svelte +173 -0
  34. package/src/editor/ui/UILineHeightSelector.svelte +6 -5
  35. package/src/editor/ui/UIPaletteSelector.svelte +6 -1
  36. package/src/editor/ui/UITextTransformSelector.svelte +106 -0
  37. package/src/editor/ui/VariablesTab.svelte +23 -10
  38. package/src/editor/ui/colors/ColorReadouts.svelte +123 -0
  39. package/src/editor/ui/colors/ColorStory.svelte +375 -0
  40. package/src/editor/ui/colors/ColorWheel.svelte +740 -0
  41. package/src/editor/ui/colors/ColorsTab.svelte +508 -0
  42. package/src/editor/ui/colors/HarmonyAxesList.svelte +320 -0
  43. package/src/editor/ui/colors/LightnessBar.svelte +261 -0
  44. package/src/editor/ui/colors/PaletteStepStrip.svelte +142 -0
  45. package/src/editor/ui/colors/colorWheelMath.ts +9 -0
  46. package/src/editor/ui/colors/harmony-icons/analogous.svg +13 -0
  47. package/src/editor/ui/colors/harmony-icons/complementary.svg +10 -0
  48. package/src/editor/ui/colors/harmony-icons/compound.svg +15 -0
  49. package/src/editor/ui/colors/harmony-icons/custom.svg +10 -0
  50. package/src/editor/ui/colors/harmony-icons/monochromatic.svg +11 -0
  51. package/src/editor/ui/colors/harmony-icons/shades.svg +9 -0
  52. package/src/editor/ui/colors/harmony-icons/split-complementary.svg +13 -0
  53. package/src/editor/ui/colors/harmony-icons/square.svg +13 -0
  54. package/src/editor/ui/colors/harmony-icons/tetradic.svg +15 -0
  55. package/src/editor/ui/colors/harmony-icons/triadic.svg +13 -0
  56. package/src/editor/ui/colors/harmonyModeIcons.ts +23 -0
  57. package/src/editor/ui/colors/paletteBaseColor.ts +214 -0
  58. package/src/editor/ui/index.ts +0 -1
  59. package/src/editor/ui/palette/OverridesPanel.svelte +6 -3
  60. package/src/editor/ui/palette/PaletteBase.svelte +16 -8
  61. package/src/editor/ui/palette/dockMagnify.ts +22 -0
  62. package/src/editor/ui/palette/paletteEditorState.ts +5 -4
  63. package/src/editor/ui/palette/paletteMath.ts +93 -181
  64. package/src/editor/ui/sections/textStyles.ts +82 -0
  65. package/src/editor/ui/sections/tokenScales.ts +3 -3
  66. package/src/live-tokens/data/themes/default.json +3 -3
  67. package/src/live-tokens/data/tokens.generated.css +96 -95
  68. package/src/system/components/Badge.svelte +11 -11
  69. package/src/system/components/Button.svelte +7 -7
  70. package/src/system/components/Callout.svelte +8 -8
  71. package/src/system/components/Card.svelte +2 -2
  72. package/src/system/components/CodeSnippet.svelte +1 -1
  73. package/src/system/components/CollapsibleSection.svelte +7 -7
  74. package/src/system/components/CornerBadge.svelte +1 -1
  75. package/src/system/components/Dialog.svelte +2 -2
  76. package/src/system/components/FloatingTokenTags.css +5 -4
  77. package/src/system/components/Input.svelte +6 -6
  78. package/src/system/components/MenuSelect.svelte +4 -4
  79. package/src/system/components/Notification.svelte +8 -8
  80. package/src/system/components/ProgressBar.svelte +2 -2
  81. package/src/system/components/RadioButton.svelte +4 -4
  82. package/src/system/components/SectionDivider.svelte +6 -6
  83. package/src/system/components/SegmentedControl.svelte +5 -5
  84. package/src/system/components/SideNavigation.svelte +12 -12
  85. package/src/system/components/TabBar.svelte +4 -4
  86. package/src/system/components/Table.svelte +2 -2
  87. package/src/system/components/Tooltip.svelte +1 -1
  88. package/src/system/styles/tokens.css +59 -6
  89. package/src/editor/ui/TextTab.svelte +0 -203
@@ -1,9 +1,6 @@
1
1
  <script lang="ts">
2
- import { stopPropagation, createBubbler } from 'svelte/legacy';
3
-
4
- const bubble = createBubbler();
5
- import { onMount, onDestroy, tick, untrack } from 'svelte';
6
- import { hexToOklch, oklchToHex, gamutClamp } from '../core/palettes/oklch';
2
+ import { onMount, onDestroy, tick } from 'svelte';
3
+ import { oklchToHexClamped, type Oklch } from '../core/palettes/oklch';
7
4
  import { type CurveAnchor, lightnessCurveConfig, saturationCurveConfig } from './curveEngine';
8
5
  import ColorEditPanel from './ColorEditPanel.svelte';
9
6
  import OverridesPanel from './palette/OverridesPanel.svelte';
@@ -12,6 +9,7 @@
12
9
  import ScaleCurveEditor from './palette/ScaleCurveEditor.svelte';
13
10
  import PaletteBase from './palette/PaletteBase.svelte';
14
11
  import { type EditingState, idleState, BASE_KEY, isEditingBase as isBaseEdit } from './palette/paletteEditorState';
12
+ import { dockTrackTemplate } from './palette/dockMagnify';
15
13
  import {
16
14
  type Step, type Scale,
17
15
  GRAY_FALLBACK,
@@ -20,19 +18,22 @@
20
18
  paletteStepLightness, scales,
21
19
  paletteStepKey, stepKey, scaleCurveKey as getScaleCurveKey,
22
20
  stepIndexToX,
23
- injectLockedAnchor, removeLockedAnchor,
24
- computePaletteColor as computePaletteColorPure,
25
- computeDerivedColor as computeDerivedColorPure,
21
+ syncBaseAnchor, clearBaseAnchor,
22
+ computePaletteOklch as computePaletteOklchPure,
23
+ computeDerivedOklch as computeDerivedOklchPure,
26
24
  snapScaleToPalette as snapScaleToPalettePure,
27
25
  } from './palette/paletteMath';
28
26
  import type { PaletteConfig, GradientStop } from '../core/themes/themeTypes';
29
27
  import { editorState, mutate, setPaletteConfig, beginSliderGesture, beginScope, commitScope, cancelScope, type Scope } from '../core/store/editorStore';
28
+ // Base-color edits route through the shared setter so a bound harmony axis
29
+ // follows the hue (invariant 1); the local `edit` would silently detach it.
30
+ import { setBaseColor } from './colors/paletteBaseColor';
30
31
  import { showCopyPopover } from './copyPopover';
31
32
 
32
33
  interface Props {
33
34
  label: string;
34
35
  displayLabel?: string | null;
35
- initialColor?: string;
36
+ initialColor?: Oklch;
36
37
  neutral?: boolean;
37
38
  cssNamespace?: string | null;
38
39
  emptySelector?: boolean;
@@ -47,6 +48,8 @@
47
48
  emptySelector = false
48
49
  }: Props = $props();
49
50
 
51
+ const toHex = (o: Oklch): string => oklchToHexClamped(o.l, o.c, o.h);
52
+
50
53
  const seedConfig = () => defaultPaletteConfig({ baseColor: initialColor, neutral });
51
54
 
52
55
  function edit<K extends keyof PaletteConfig>(field: K, value: PaletteConfig[K]): void {
@@ -64,15 +67,15 @@
64
67
  }
65
68
 
66
69
  let lockedLightnessIdx: number | null = $derived.by(() => {
67
- if (!anchorToBase) return null;
68
- const x500 = stepIndexToX(4);
69
- const idx = lightnessCurve.findIndex(a => Math.abs(a.x - x500) < 0.5);
70
+ if (!anchorToBase || anchorPlacement === undefined) return null;
71
+ const x = stepIndexToX(anchorPlacement.step);
72
+ const idx = lightnessCurve.findIndex(a => Math.abs(a.x - x) < 0.5);
70
73
  return idx >= 0 ? idx : null;
71
74
  });
72
75
  let lockedSaturationIdx: number | null = $derived.by(() => {
73
- if (!anchorToBase) return null;
74
- const x500 = stepIndexToX(4);
75
- const idx = saturationCurve.findIndex(a => Math.abs(a.x - x500) < 0.5);
76
+ if (!anchorToBase || anchorPlacement === undefined) return null;
77
+ const x = stepIndexToX(anchorPlacement.step);
78
+ const idx = saturationCurve.findIndex(a => Math.abs(a.x - x) < 0.5);
76
79
  return idx >= 0 ? idx : null;
77
80
  });
78
81
 
@@ -105,55 +108,32 @@
105
108
 
106
109
  let editing: EditingState = $state(idleState);
107
110
 
108
- let injectedLightness = false;
109
- let injectedSaturation = false;
110
-
111
- function computePaletteColor(index: number, base: string): string {
112
- return computePaletteColorPure(index, base, lightnessCurve, saturationCurve, curveOffset);
111
+ function computePaletteOklch(index: number, base: Oklch): Oklch {
112
+ return computePaletteOklchPure(index, base, lightnessCurve, saturationCurve, curveOffset);
113
113
  }
114
114
 
115
- function computeDerivedColor(step: Step, base: string, scaleTitle: string): string {
116
- return computeDerivedColorPure(step, base, scaleTitle, scaleCurves, curveOffset);
115
+ function computeDerivedOklch(step: Step, base: Oklch, scaleTitle: string): Oklch {
116
+ return computeDerivedOklchPure(step, base, scaleTitle, scaleCurves, curveOffset);
117
117
  }
118
118
 
119
- /**
120
- * Toggle anchorToBase: inject (or remove) the locked 500 anchor in both
121
- * curves atomically with the flag flip, so one undo reverses the whole
122
- * thing. Transient `injectedLightness` / `injectedSaturation` remember
123
- * whether we created the anchor (vs it already existed) so toggle-off
124
- * doesn't destroy a user-authored anchor. Not persisted — acceptable drift
125
- * on reload is that a pre-existing anchor would be preserved on toggle-off.
126
- */
119
+ // Toggle placement atomically with the flag flip so one undo reverses the
120
+ // whole thing. The anchor's lifecycle (placement, displaced-y restore)
121
+ // lives in syncBaseAnchor/clearBaseAnchor, shared with the store setters.
127
122
  function setAnchorToBase(next: boolean) {
128
123
  if (next === anchorToBase) return;
129
- if (next) {
130
- const x500 = stepIndexToX(4);
131
- const lResult = injectLockedAnchor(lightnessCurve, x500, hexToOklch(baseColor).l * 100);
132
- const sResult = injectLockedAnchor(saturationCurve, x500, 100);
133
- injectedLightness = lResult.injected;
134
- injectedSaturation = sResult.injected;
135
- patchPalette({
136
- anchorToBase: true,
137
- lightnessCurve: lResult.curve,
138
- saturationCurve: sResult.curve,
139
- }, 'anchor on');
140
- } else {
141
- const lCurr = lightnessCurve;
142
- const sCurr = saturationCurve;
143
- const nextL = injectedLightness ? removeLockedAnchor(lCurr, lockedLightnessIdx) : lCurr;
144
- const nextS = injectedSaturation ? removeLockedAnchor(sCurr, lockedSaturationIdx) : sCurr;
145
- injectedLightness = false;
146
- injectedSaturation = false;
147
- patchPalette({
148
- anchorToBase: false,
149
- lightnessCurve: nextL,
150
- saturationCurve: nextS,
151
- }, 'anchor off');
152
- }
124
+ mutate(`${label}: ${next ? 'anchor on' : 'anchor off'}`, (s) => {
125
+ if (!s.palettes[label]) s.palettes[label] = seedConfig();
126
+ const cfg = s.palettes[label];
127
+ if (next) {
128
+ cfg.anchorToBase = true;
129
+ syncBaseAnchor(cfg);
130
+ } else {
131
+ clearBaseAnchor(cfg);
132
+ }
133
+ });
153
134
  }
154
135
 
155
136
 
156
-
157
137
  function startBaseEdit() {
158
138
  if (editing.kind === 'editingBase') { confirmEdit(); return; }
159
139
  editing = { kind: 'editingBase' };
@@ -166,7 +146,7 @@
166
146
  confirmEdit();
167
147
  return;
168
148
  }
169
- const current = (k in overrides) ? overrides[k] : computePaletteColor(ps.index, baseColor);
149
+ const current = (k in overrides) ? overrides[k] : computePaletteOklch(ps.index, baseColor);
170
150
  editing = { kind: 'editingStep', stepKey: k, snapshot: current, draft: current };
171
151
  openSession();
172
152
  }
@@ -183,23 +163,18 @@
183
163
  edit('scaleCurves', { ...scaleCurves, [title]: { ...cur, [channel]: a } });
184
164
  }
185
165
 
186
- function handleColorChange(hex: string) {
166
+ function handleColorChange(color: Oklch) {
187
167
  if (editing.kind === 'editingStep') {
188
- editing = { ...editing, draft: hex };
168
+ editing = { ...editing, draft: color };
189
169
  // Write every drag tick to the store (not just pre-existing overrides) so
190
170
  // the live page and the derived-scale preview swatches update in realtime.
191
171
  // The open session collapses these to one undo entry; cancel restores the
192
172
  // snapshot, and confirmEdit drops the override if the draft equals the
193
173
  // computed value, so a no-op edit leaves nothing behind.
194
- edit('overrides', { ...overrides, [editing.stepKey]: hex });
174
+ edit('overrides', { ...overrides, [editing.stepKey]: color });
195
175
  }
196
176
  }
197
177
 
198
- function oklchHex(hue: number, chroma: number, lightness: number): string {
199
- const c = gamutClamp(lightness / 100, chroma, hue);
200
- return oklchToHex(c.l, c.c, c.h);
201
- }
202
-
203
178
  function resetOverride(k: string) {
204
179
  if (!(k in overrides)) return;
205
180
  const { [k]: _, ...rest } = overrides;
@@ -211,7 +186,7 @@
211
186
  confirmEdit();
212
187
  return;
213
188
  }
214
- const current = (k in overrides) ? overrides[k] : computeDerivedColor(step, baseColor, scaleTitle);
189
+ const current = (k in overrides) ? overrides[k] : computeDerivedOklch(step, baseColor, scaleTitle);
215
190
  editing = { kind: 'editingStep', stepKey: k, snapshot: current, draft: current };
216
191
  openSession();
217
192
  }
@@ -223,9 +198,13 @@
223
198
  let nextOverrides = { ...overrides };
224
199
  if (editingDraft !== null) {
225
200
  const computed = computedValueForKey(editingKey);
226
- if (computed !== null && editingDraft !== computed) {
201
+ // Compare via the sRGB projection: an override that renders identically
202
+ // to the computed default is dropped (same rule as the pre-Wave-2 hex
203
+ // compare); the stored value stays the lossless numeric draft.
204
+ const draftHex = toHex(editingDraft);
205
+ if (computed !== null && draftHex !== toHex(computed)) {
227
206
  nextOverrides[editingKey] = editingDraft;
228
- } else if (computed !== null && editingKey in nextOverrides && editingDraft === computed) {
207
+ } else if (computed !== null && editingKey in nextOverrides && draftHex === toHex(computed)) {
229
208
  delete nextOverrides[editingKey];
230
209
  }
231
210
  }
@@ -258,16 +237,16 @@
258
237
  if (editingKey === k) { editing = idleState; }
259
238
  }
260
239
 
261
- function computedValueForKey(key: string): string | null {
240
+ function computedValueForKey(key: string): Oklch | null {
262
241
  const ps = paletteStepLightness.find(p => paletteStepKey(p.label) === key);
263
242
  if (ps) {
264
243
  const idx = paletteStepLightness.indexOf(ps);
265
- return computePaletteColor(idx, baseColor);
244
+ return computePaletteOklch(idx, baseColor);
266
245
  }
267
246
  for (const scale of scales) {
268
247
  for (const step of scale.steps) {
269
248
  if (stepKey(scale.title, step.name) === key) {
270
- return computeDerivedColor(step, baseColor, scale.title);
249
+ return computeDerivedOklch(step, baseColor, scale.title);
271
250
  }
272
251
  }
273
252
  }
@@ -275,8 +254,8 @@
275
254
  }
276
255
 
277
256
  function effectiveColor(k: string, step: Step, scaleTitle: string, _version?: string): string {
278
- if (editingKey === k && editingDraft !== null) return editingDraft;
279
- return (k in overrides) ? overrides[k] : computeDerivedColor(step, baseColor, scaleTitle);
257
+ if (editingKey === k && editingDraft !== null) return toHex(editingDraft);
258
+ return (k in overrides) ? toHex(overrides[k]) : toHex(computeDerivedOklch(step, baseColor, scaleTitle));
280
259
  }
281
260
 
282
261
  let copiedKey: string | null = $state(null);
@@ -295,7 +274,7 @@
295
274
  setTimeout(() => { copiedLabelKey = null; }, 1500);
296
275
  }
297
276
 
298
- function snapScaleToPalette(scale: Scale): Record<string, string> {
277
+ function snapScaleToPalette(scale: Scale): Record<string, Oklch> {
299
278
  return snapScaleToPalettePure(scale, baseColor, scaleCurves, curveOffset, paletteComputed);
300
279
  }
301
280
 
@@ -348,8 +327,8 @@
348
327
  onMount(() => document.addEventListener('click', handleDocClick, true));
349
328
  onDestroy(() => document.removeEventListener('click', handleDocClick, true));
350
329
 
351
- function selectSnapValue(k: string, paletteHex: string, _scaleTitle: string) {
352
- edit('overrides', { ...overrides, [k]: paletteHex });
330
+ function selectSnapValue(k: string, value: Oklch, _scaleTitle: string) {
331
+ edit('overrides', { ...overrides, [k]: value });
353
332
  snapPickerKey = null;
354
333
  }
355
334
 
@@ -368,9 +347,12 @@
368
347
  for (const scale of scales.filter(s => !s.isText)) {
369
348
  if (!snappedScales.has(scale.title)) continue;
370
349
  const assigned = snapScaleToPalette(scale);
371
- for (const [k, hex] of Object.entries(assigned)) {
372
- if (next[k] !== hex) {
373
- next[k] = hex;
350
+ for (const [k, color] of Object.entries(assigned)) {
351
+ // Value compare, not reference: the recompute yields fresh objects, so a
352
+ // reference test would always fire and loop the resnap effect.
353
+ const prev = next[k];
354
+ if (!prev || prev.l !== color.l || prev.c !== color.c || prev.h !== color.h) {
355
+ next[k] = color;
374
356
  changed = true;
375
357
  }
376
358
  }
@@ -394,8 +376,18 @@
394
376
  let overrides = $derived($editorState.palettes[label]?.overrides ?? {});
395
377
  let snappedScales = $derived(new Set($editorState.palettes[label]?.snappedScales ?? []));
396
378
  let anchorToBase = $derived($editorState.palettes[label]?.anchorToBase ?? true);
379
+ let anchorPlacement = $derived($editorState.palettes[label]?.anchorPlacement);
380
+ let anchorStepLabel = $derived(anchorToBase && anchorPlacement !== undefined
381
+ ? paletteStepLightness[anchorPlacement.step]?.label ?? null
382
+ : null);
383
+ // Dock magnification: the grid transitions between track lists, so dialing
384
+ // lightness reads as swatches opening/closing while the anchor walks the
385
+ // ramp. Column 0 is the white bookend, so step i is column i+1.
386
+ let swatchTemplate = $derived(dockTrackTemplate(
387
+ paletteStepLightness.length + 2,
388
+ anchorToBase && anchorPlacement !== undefined ? anchorPlacement.step + 1 : null,
389
+ ));
397
390
  let emptyMode = $derived($editorState.palettes[label]?.emptyMode ?? 'solid');
398
- let emptyStep = $derived($editorState.palettes[label]?.emptyStep ?? '850');
399
391
  let gradientStyle = $derived($editorState.palettes[label]?.gradientStyle ?? 'linear');
400
392
  let gradientAngle = $derived($editorState.palettes[label]?.gradientAngle ?? 180);
401
393
  let gradientReverse = $derived($editorState.palettes[label]?.gradientReverse ?? false);
@@ -406,35 +398,23 @@
406
398
  let gradientSize = $derived($editorState.palettes[label]?.gradientSize ?? 'page');
407
399
  let editingKey = $derived(editing.kind === 'idle' ? null : editing.kind === 'editingBase' ? BASE_KEY : editing.stepKey);
408
400
  let editingDraft = $derived(editing.kind === 'editingStep' ? editing.draft : null);
409
- // Keep the locked lightness anchor y in sync with baseColor. Idempotent.
410
- // `lightnessCurve` is read via `untrack` so writing it back via `edit` does
411
- // not retrigger this effect (Svelte 5 flags the read+write pattern as
412
- // recursive).
413
- $effect(() => {
414
- if (!anchorToBase || lockedLightnessIdx === null || !baseColor) return;
415
- const targetY = hexToOklch(baseColor).l * 100;
416
- untrack(() => {
417
- const idx = lockedLightnessIdx;
418
- if (idx === null) return;
419
- const curve = lightnessCurve;
420
- if (curve[idx] && Math.abs(curve[idx].y - targetY) > 0.01) {
421
- edit('lightnessCurve', curve.map((a, i) => i === idx ? { ...a, y: targetY } : a));
422
- }
423
- });
424
- });
425
401
  let paletteComputed = $derived((() => {
426
- const _bc = baseColor, _lc = lightnessCurve, _sc = saturationCurve, _co = curveOffset, _ed = editingDraft, _ek = editingKey, _ov = overrides, _ab = anchorToBase;
402
+ const _bc = baseColor, _lc = lightnessCurve, _sc = saturationCurve, _co = curveOffset, _ed = editingDraft, _ek = editingKey, _ov = overrides, _ab = anchorToBase, _ap = anchorPlacement;
427
403
  return paletteStepLightness.map((ps, index) => {
428
404
  const k = paletteStepKey(ps.label);
429
- const hex = computePaletteColor(index, baseColor);
430
- const effective = (_ek === k && _ed !== null) ? _ed : (k in _ov) ? _ov[k] : hex;
405
+ const oklch = computePaletteOklch(index, baseColor);
406
+ const effOklch = (_ek === k && _ed !== null) ? _ed : (k in _ov) ? _ov[k] : oklch;
431
407
  return {
432
408
  label: ps.label,
433
409
  lightness: ps.lightness,
434
410
  index,
435
411
  key: k,
436
- hex,
437
- effective,
412
+ anchored: _ab && _ap?.step === index,
413
+ // `oklch` is the numeric step (feeds snap); `hex`/`effective` are the
414
+ // sRGB projections that paint swatches and copy to the clipboard.
415
+ oklch,
416
+ hex: toHex(oklch),
417
+ effective: toHex(effOklch),
438
418
  };
439
419
  });
440
420
  })());
@@ -447,9 +427,11 @@
447
427
  // `editingDraft` is folded in so the effective-hex closure passed to
448
428
  // OverridesPanel re-derives on every drag tick (the per-step hex text would
449
429
  // otherwise lag the live swatch during an override edit).
450
- let curveVersion = $derived(JSON.stringify(scaleCurves) + JSON.stringify(curveOffset) + baseColor + (editingDraft ?? ''));
451
- let derivedHexForBase = $derived((step: Step, scaleTitle: string) => computeDerivedColor(step, baseColor, scaleTitle));
430
+ let curveVersion = $derived(JSON.stringify(scaleCurves) + JSON.stringify(curveOffset) + JSON.stringify(baseColor) + JSON.stringify(editingDraft));
431
+ let derivedHexForBase = $derived((step: Step, scaleTitle: string) => toHex(computeDerivedOklch(step, baseColor, scaleTitle)));
452
432
  let effectiveHexAny = $derived((k: string, step: Step, scaleTitle: string) => effectiveColor(k, step, scaleTitle, curveVersion));
433
+ // Projected hex view of the numeric overrides for the display-only panels.
434
+ let overridesHex = $derived(Object.fromEntries(Object.entries(overrides).map(([k, v]) => [k, toHex(v)])));
453
435
 
454
436
  let isEditingBase = $derived(isBaseEdit(editing));
455
437
  let editingColor = $derived(isEditingBase ? baseColor : editingDraft);
@@ -484,13 +466,14 @@
484
466
  });
485
467
  </script>
486
468
 
487
- <div class="palette-editor" style="--editor-base: {baseColor}">
469
+ <div class="palette-editor" style="--editor-base: {toHex(baseColor)}">
488
470
  <PaletteBase
489
471
  {label}
490
472
  {displayLabel}
491
473
  {neutral}
492
474
  {baseColor}
493
475
  {anchorToBase}
476
+ {anchorStepLabel}
494
477
  {isEditingBase}
495
478
  {panelOpen}
496
479
  {editingColor}
@@ -499,7 +482,7 @@
499
482
  onStartEdit={startBaseEdit}
500
483
  onConfirm={confirmEdit}
501
484
  onCancel={cancelEdit}
502
- onBaseChange={(h, c, l) => edit('baseColor', oklchHex(h, c, l))}
485
+ onBaseChange={(h, c, l) => setBaseColor(label, { l: l / 100, c, h })}
503
486
  onAnchorToBaseChange={setAnchorToBase}
504
487
  onCopyBaseHex={copyHex}
505
488
  />
@@ -524,7 +507,7 @@
524
507
  {paletteEditorOpen ? 'Close' : 'Edit'}
525
508
  </UIPillButton>
526
509
  </div>
527
- <div class="swatch-grid" style="--swatch-cols: {paletteStepLightness.length + 2}">
510
+ <div class="swatch-grid" style="--swatch-cols: {paletteStepLightness.length + 2}; grid-template-columns: {swatchTemplate}">
528
511
  <div class="step-column">
529
512
  <button class="step-label copyable-label" class:copied={copiedLabelKey === 'palette-white'} type="button" onclick={(e) => copyVarName('palette-white', `--color-${cssNamespace}-white`, e)}>
530
513
  {copiedLabelKey === 'palette-white' ? 'copied!' : 'white'}
@@ -541,6 +524,8 @@
541
524
  class="swatch gray-swatch"
542
525
  class:active={editingKey === ps.key}
543
526
  class:overridden={ps.key in overrides}
527
+ class:anchored={ps.anchored}
528
+ title={ps.anchored ? 'Base color' : undefined}
544
529
  style="background: {ps.effective}"
545
530
  onclick={() => handlePaletteClick({ label: ps.label, lightness: ps.lightness, index: ps.index })}
546
531
  role="button"
@@ -550,16 +535,6 @@
550
535
  {#if ps.key in overrides}
551
536
  <span class="override-dot" title="Palette override"></span>
552
537
  {/if}
553
- {#if emptySelector && emptyMode === 'solid'}
554
- <input
555
- type="checkbox"
556
- class="empty-check"
557
- checked={emptyStep === ps.label}
558
- onclick={stopPropagation(() => edit('emptyStep', ps.label))}
559
- onkeydown={stopPropagation(bubble('keydown'))}
560
- title="Page background"
561
- />
562
- {/if}
563
538
  </div>
564
539
  <button
565
540
  class="step-hex"
@@ -633,7 +608,7 @@
633
608
  {scaleCurves}
634
609
  {curveOffset}
635
610
  {defaultScaleCurves}
636
- {overrides}
611
+ overrides={overridesHex}
637
612
  {editingKey}
638
613
  {snapPickerKey}
639
614
  {copiedKey}
@@ -677,14 +652,13 @@
677
652
 
678
653
  <!-- Color Edit Panel (non-base edits) -->
679
654
  {#if !isEditingBase && panelOpen && editingColor}
680
- {@const oc = hexToOklch(editingColor)}
681
655
  <ColorEditPanel
682
656
  title={editPanelTitle}
683
657
  showRemoveOverride={!!editingKey}
684
- hue={oc.h}
685
- chroma={oc.c}
686
- lightness={oc.l * 100}
687
- onHueChromaChange={(h, c, l) => handleColorChange(oklchHex(h, c, l))}
658
+ hue={editingColor.h}
659
+ chroma={editingColor.c}
660
+ lightness={editingColor.l * 100}
661
+ onHueChromaChange={(h, c, l) => handleColorChange({ l: l / 100, c, h })}
688
662
  onConfirm={confirmEdit}
689
663
  onCancel={cancelEdit}
690
664
  onRemoveOverride={() => editingKey && removeOverride(editingKey)}
@@ -855,6 +829,7 @@
855
829
  .swatch-grid {
856
830
  display: grid;
857
831
  grid-template-columns: repeat(var(--swatch-cols), minmax(0, 1fr));
832
+ transition: grid-template-columns var(--ui-transition-fast);
858
833
  gap: var(--ui-space-4) var(--swatch-gap, var(--ui-space-4));
859
834
  align-items: start;
860
835
  justify-content: start;
@@ -871,11 +846,22 @@
871
846
  .swatch.gray-swatch {
872
847
  width: 100%;
873
848
  height: calc(4rem + var(--ui-space-2));
849
+ /* Compensating margin: height + margin-bottom is constant every frame of
850
+ the magnification, so the grid's row height never dips mid-transition
851
+ (the dip bounced everything below the palette). Also keeps the hex row
852
+ aligned across anchored and plain columns. */
853
+ margin-bottom: 1rem;
854
+ transition: height var(--ui-transition-fast), margin-bottom var(--ui-transition-fast);
874
855
  cursor: pointer;
875
856
  position: relative;
876
857
  }
877
858
 
878
- .swatch.gray-swatch:hover {
859
+ /* Bookends (white/black endpoints) are display-only, not clickable. */
860
+ .swatch.gray-swatch.bookend {
861
+ cursor: default;
862
+ }
863
+
864
+ .swatch.gray-swatch:not(.bookend):hover {
879
865
  border-color: var(--ui-border-high);
880
866
  }
881
867
 
@@ -885,6 +871,15 @@
885
871
  outline-offset: 1px;
886
872
  }
887
873
 
874
+ /* The step the base color is placed at: dock-magnified (its grid column is
875
+ also widened via the track template) with an emphasized border. No fill
876
+ change — the swatch itself already IS the picked color. */
877
+ .swatch.gray-swatch.anchored {
878
+ border-color: var(--ui-border-higher);
879
+ height: calc(5rem + var(--ui-space-2));
880
+ margin-bottom: 0;
881
+ }
882
+
888
883
  .override-dot {
889
884
  position: absolute;
890
885
  top: 3px;
@@ -911,39 +906,6 @@
911
906
  cursor: pointer;
912
907
  }
913
908
 
914
- .empty-check {
915
- -webkit-appearance: none;
916
- appearance: none;
917
- position: absolute;
918
- bottom: 3px;
919
- right: 3px;
920
- margin: 0;
921
- cursor: pointer;
922
- width: 14px;
923
- height: 14px;
924
- background: white;
925
- border: 1px solid rgba(0, 0, 0, 0.3);
926
- border-radius: 2px;
927
- opacity: 0.5;
928
- }
929
-
930
- .empty-check:checked {
931
- opacity: 1;
932
- }
933
-
934
- .empty-check:checked::after {
935
- content: '\2713';
936
- display: flex;
937
- align-items: center;
938
- justify-content: center;
939
- width: 100%;
940
- height: 100%;
941
- font-size: var(--ui-font-size-md);
942
- font-weight: bold;
943
- color: black;
944
- line-height: 1;
945
- }
946
-
947
909
  /* Narrow desktop: tighten palette editor spacing */
948
910
  @media (max-width: 1280px) {
949
911
  .palette-editor {
@@ -481,9 +481,6 @@
481
481
  background: var(--ui-surface-subtle, rgba(255,255,255,0.02));
482
482
  min-height: 36px;
483
483
  }
484
- .pf-family:hover {
485
- background: var(--ui-surface-hover, rgba(255,255,255,0.04));
486
- }
487
484
 
488
485
  .pf-family-remove {
489
486
  display: inline-flex;