@motion-proto/live-tokens 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
 
4
4
  const bubble = createBubbler();
5
5
  import { onMount, onDestroy, tick, untrack } from 'svelte';
6
- import { hexToOklch } from '../core/palettes/oklch';
6
+ import { hexToOklch, oklchToHex, gamutClamp } from '../core/palettes/oklch';
7
7
  import { type CurveAnchor, lightnessCurveConfig, saturationCurveConfig } from './curveEngine';
8
8
  import ColorEditPanel from './ColorEditPanel.svelte';
9
9
  import OverridesPanel from './palette/OverridesPanel.svelte';
@@ -13,15 +13,14 @@
13
13
  import PaletteBase from './palette/PaletteBase.svelte';
14
14
  import { type EditingState, idleState, BASE_KEY, isEditingBase as isBaseEdit } from './palette/paletteEditorState';
15
15
  import {
16
- type Step, type Scale, type GrayStep, type CurveOffset, type ScaleCurves,
17
- GRAY_FALLBACK, DEFAULT_TINT_CHROMA,
18
- DEFAULT_PALETTE_LIGHTNESS, DEFAULT_PALETTE_SATURATION, DEFAULT_GRAY_LIGHTNESS, DEFAULT_GRAY_SATURATION,
19
- defaultScaleCurves, defaultScaleCurvesObject,
20
- paletteStepLightness, graySteps, scales,
21
- paletteStepKey, grayStepKey, stepKey, scaleCurveKey as getScaleCurveKey,
16
+ type Step, type Scale,
17
+ GRAY_FALLBACK,
18
+ DEFAULT_PALETTE_LIGHTNESS, DEFAULT_PALETTE_SATURATION,
19
+ defaultScaleCurves, defaultScaleCurvesObject, defaultPaletteConfig,
20
+ paletteStepLightness, scales,
21
+ paletteStepKey, stepKey, scaleCurveKey as getScaleCurveKey,
22
22
  stepIndexToX,
23
23
  injectLockedAnchor, removeLockedAnchor,
24
- computeGrayColor as computeGrayColorPure,
25
24
  computePaletteColor as computePaletteColorPure,
26
25
  computeDerivedColor as computeDerivedColorPure,
27
26
  snapScaleToPalette as snapScaleToPalettePure,
@@ -34,7 +33,7 @@
34
33
  label: string;
35
34
  displayLabel?: string | null;
36
35
  initialColor?: string;
37
- mode?: 'chromatic' | 'gray';
36
+ neutral?: boolean;
38
37
  cssNamespace?: string | null;
39
38
  emptySelector?: boolean;
40
39
  }
@@ -43,39 +42,23 @@
43
42
  label,
44
43
  displayLabel = null,
45
44
  initialColor = GRAY_FALLBACK,
46
- mode = 'chromatic',
45
+ neutral = false,
47
46
  cssNamespace = null,
48
47
  emptySelector = false
49
48
  }: Props = $props();
50
49
 
51
-
52
- function defaultPaletteConfig(): PaletteConfig {
53
- return {
54
- baseColor: initialColor,
55
- tintHue: 240,
56
- tintChroma: DEFAULT_TINT_CHROMA,
57
- lightnessCurve: DEFAULT_PALETTE_LIGHTNESS(),
58
- saturationCurve: DEFAULT_PALETTE_SATURATION(),
59
- grayLightnessCurve: DEFAULT_GRAY_LIGHTNESS(),
60
- graySaturationCurve: DEFAULT_GRAY_SATURATION(),
61
- scaleCurves: defaultScaleCurvesObject(),
62
- curveOffset: { lightness: 0, saturation: 0 },
63
- overrides: {},
64
- snappedScales: [],
65
- anchorToBase: true,
66
- };
67
- }
50
+ const seedConfig = () => defaultPaletteConfig({ baseColor: initialColor, neutral });
68
51
 
69
52
  function edit<K extends keyof PaletteConfig>(field: K, value: PaletteConfig[K]): void {
70
53
  mutate(`${label}: ${String(field)}`, (s) => {
71
- if (!s.palettes[label]) s.palettes[label] = defaultPaletteConfig();
54
+ if (!s.palettes[label]) s.palettes[label] = seedConfig();
72
55
  (s.palettes[label] as any)[field] = value;
73
56
  });
74
57
  }
75
58
 
76
59
  function patchPalette(patch: Partial<PaletteConfig>, historyLabel: string): void {
77
60
  mutate(`${label}: ${historyLabel}`, (s) => {
78
- if (!s.palettes[label]) s.palettes[label] = defaultPaletteConfig();
61
+ if (!s.palettes[label]) s.palettes[label] = seedConfig();
79
62
  Object.assign(s.palettes[label], patch);
80
63
  });
81
64
  }
@@ -110,14 +93,11 @@
110
93
  edit('emptyMode', (e.currentTarget as HTMLInputElement).checked ? 'gradient' : 'solid');
111
94
  }
112
95
 
113
- let grayEditorOpen = $state(false);
114
96
  let showDerived = $state(false);
115
97
  let paletteEditorOpen = $state(false);
116
98
 
117
99
  function setLightnessCurve(a: CurveAnchor[]) { edit('lightnessCurve', a); }
118
100
  function setSaturationCurve(a: CurveAnchor[]) { edit('saturationCurve', a); }
119
- function setGrayLightnessCurve(a: CurveAnchor[]) { edit('grayLightnessCurve', a); }
120
- function setGraySaturationCurve(a: CurveAnchor[]) { edit('graySaturationCurve', a); }
121
101
 
122
102
  function handleOffset(key: string, value: number) {
123
103
  edit('curveOffset', { ...curveOffset, [key]: value });
@@ -128,10 +108,6 @@
128
108
  let injectedLightness = false;
129
109
  let injectedSaturation = false;
130
110
 
131
- function computeGrayColor(index: number, hue: number, chroma: number = tintChroma): string {
132
- return computeGrayColorPure(index, hue, chroma, grayLightnessCurve, graySaturationCurve, curveOffset);
133
- }
134
-
135
111
  function computePaletteColor(index: number, base: string): string {
136
112
  return computePaletteColorPure(index, base, lightnessCurve, saturationCurve, curveOffset);
137
113
  }
@@ -180,9 +156,7 @@
180
156
 
181
157
  function startBaseEdit() {
182
158
  if (editing.kind === 'editingBase') { confirmEdit(); return; }
183
- editing = mode === 'gray'
184
- ? { kind: 'editingBase', snapshotHex: gray500Hex, snapshotTintHue: tintHue, snapshotTintChroma: tintChroma }
185
- : { kind: 'editingBase', snapshotHex: baseColor, snapshotTintHue: null, snapshotTintChroma: null };
159
+ editing = { kind: 'editingBase' };
186
160
  openSession();
187
161
  }
188
162
 
@@ -210,19 +184,22 @@
210
184
  }
211
185
 
212
186
  function handleColorChange(hex: string) {
213
- if (isEditingBase) {
214
- // Gray mode's base is derived from tintHue/tintChroma; raw hex only applies to chromatic.
215
- if (mode === 'chromatic') edit('baseColor', hex);
216
- return;
217
- }
218
187
  if (editing.kind === 'editingStep') {
219
188
  editing = { ...editing, draft: hex };
220
- if (editing.stepKey in overrides) {
221
- edit('overrides', { ...overrides, [editing.stepKey]: hex });
222
- }
189
+ // Write every drag tick to the store (not just pre-existing overrides) so
190
+ // the live page and the derived-scale preview swatches update in realtime.
191
+ // The open session collapses these to one undo entry; cancel restores the
192
+ // snapshot, and confirmEdit drops the override if the draft equals the
193
+ // computed value, so a no-op edit leaves nothing behind.
194
+ edit('overrides', { ...overrides, [editing.stepKey]: hex });
223
195
  }
224
196
  }
225
197
 
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
+
226
203
  function resetOverride(k: string) {
227
204
  if (!(k in overrides)) return;
228
205
  const { [k]: _, ...rest } = overrides;
@@ -234,18 +211,7 @@
234
211
  confirmEdit();
235
212
  return;
236
213
  }
237
- const current = (k in overrides) ? overrides[k] : computeDerivedColor(step, gray500Hex, scaleTitle);
238
- editing = { kind: 'editingStep', stepKey: k, snapshot: current, draft: current };
239
- openSession();
240
- }
241
-
242
- function handleGrayClick(gStep: GrayStep, index: number) {
243
- const k = grayStepKey(gStep.label);
244
- if (editingKey === k) {
245
- confirmEdit();
246
- return;
247
- }
248
- const current = (k in overrides) ? overrides[k] : computeGrayColor(index, tintHue, tintChroma);
214
+ const current = (k in overrides) ? overrides[k] : computeDerivedColor(step, baseColor, scaleTitle);
249
215
  editing = { kind: 'editingStep', stepKey: k, snapshot: current, draft: current };
250
216
  openSession();
251
217
  }
@@ -282,7 +248,7 @@
282
248
 
283
249
  function cancelEdit() {
284
250
  editing = idleState;
285
- // Restoring the session snapshot pulls baseColor/tintHue/overrides/… back to pre-open.
251
+ // Restoring the session snapshot pulls baseColor/overrides/… back to pre-open.
286
252
  if (paletteEditScope) { cancelScope(paletteEditScope); paletteEditScope = null; }
287
253
  }
288
254
 
@@ -298,12 +264,10 @@
298
264
  const idx = paletteStepLightness.indexOf(ps);
299
265
  return computePaletteColor(idx, baseColor);
300
266
  }
301
- const gi = graySteps.findIndex(g => grayStepKey(g.label) === key);
302
- if (gi >= 0) return computeGrayColor(gi, tintHue, tintChroma);
303
267
  for (const scale of scales) {
304
268
  for (const step of scale.steps) {
305
269
  if (stepKey(scale.title, step.name) === key) {
306
- return computeDerivedColor(step, gray500Hex, scale.title);
270
+ return computeDerivedColor(step, baseColor, scale.title);
307
271
  }
308
272
  }
309
273
  }
@@ -312,7 +276,7 @@
312
276
 
313
277
  function effectiveColor(k: string, step: Step, scaleTitle: string, _version?: string): string {
314
278
  if (editingKey === k && editingDraft !== null) return editingDraft;
315
- return (k in overrides) ? overrides[k] : computeDerivedColor(step, gray500Hex, scaleTitle);
279
+ return (k in overrides) ? overrides[k] : computeDerivedColor(step, baseColor, scaleTitle);
316
280
  }
317
281
 
318
282
  let copiedKey: string | null = $state(null);
@@ -356,11 +320,7 @@
356
320
 
357
321
  function clearPaletteOverrides() {
358
322
  const next = { ...overrides };
359
- if (mode === 'gray') {
360
- for (const step of graySteps) delete next[grayStepKey(step.label)];
361
- } else {
362
- for (const ps of paletteStepLightness) delete next[paletteStepKey(ps.label)];
363
- }
323
+ for (const ps of paletteStepLightness) delete next[paletteStepKey(ps.label)];
364
324
  edit('overrides', next);
365
325
  }
366
326
 
@@ -427,12 +387,8 @@
427
387
  // every mutate-in-place store update (Svelte 5 uses `===` equality) and
428
388
  // short-circuit the downstream chain — swatches would freeze.
429
389
  let baseColor = $derived($editorState.palettes[label]?.baseColor ?? initialColor);
430
- let tintHue = $derived($editorState.palettes[label]?.tintHue ?? 240);
431
- let tintChroma = $derived($editorState.palettes[label]?.tintChroma ?? DEFAULT_TINT_CHROMA);
432
390
  let lightnessCurve = $derived($editorState.palettes[label]?.lightnessCurve ?? DEFAULT_PALETTE_LIGHTNESS());
433
391
  let saturationCurve = $derived($editorState.palettes[label]?.saturationCurve ?? DEFAULT_PALETTE_SATURATION());
434
- let grayLightnessCurve = $derived($editorState.palettes[label]?.grayLightnessCurve ?? DEFAULT_GRAY_LIGHTNESS());
435
- let graySaturationCurve = $derived($editorState.palettes[label]?.graySaturationCurve ?? DEFAULT_GRAY_SATURATION());
436
392
  let scaleCurves = $derived($editorState.palettes[label]?.scaleCurves ?? defaultScaleCurvesObject());
437
393
  let curveOffset = $derived($editorState.palettes[label]?.curveOffset ?? { lightness: 0, saturation: 0 });
438
394
  let overrides = $derived($editorState.palettes[label]?.overrides ?? {});
@@ -450,27 +406,6 @@
450
406
  let gradientSize = $derived($editorState.palettes[label]?.gradientSize ?? 'page');
451
407
  let editingKey = $derived(editing.kind === 'idle' ? null : editing.kind === 'editingBase' ? BASE_KEY : editing.stepKey);
452
408
  let editingDraft = $derived(editing.kind === 'editingStep' ? editing.draft : null);
453
- let grayComputed = $derived((() => {
454
- const _gl = grayLightnessCurve, _gs = graySaturationCurve, _co = curveOffset, _tc = tintChroma, _th = tintHue;
455
- return graySteps.map((step, index) => ({
456
- step,
457
- index,
458
- key: grayStepKey(step.label),
459
- hex: computeGrayColor(index, _th, _tc),
460
- }));
461
- })());
462
- let grayEffective = $derived((() => {
463
- const _ed = editingDraft, _ek = editingKey, _ov = overrides;
464
- return grayComputed.map(g => ({
465
- ...g,
466
- effective: (_ek === g.key && _ed !== null) ? _ed : (g.key in _ov) ? _ov[g.key] : g.hex,
467
- }));
468
- })());
469
- // Always use the computed (curve-derived) value so derived scales update in
470
- // realtime when tint changes.
471
- let gray500Hex = $derived(mode === 'gray'
472
- ? (grayComputed.find(g => g.step.label === '500')?.hex ?? GRAY_FALLBACK)
473
- : baseColor);
474
409
  // Keep the locked lightness anchor y in sync with baseColor. Idempotent.
475
410
  // `lightnessCurve` is read via `untrack` so writing it back via `edit` does
476
411
  // not retrigger this effect (Svelte 5 flags the read+write pattern as
@@ -509,24 +444,17 @@
509
444
  const stops = sorted.map(s => `${stopColor(s, pc)} ${s.position}%`).join(', ');
510
445
  return `linear-gradient(to right, ${stops})`;
511
446
  });
512
- let grayScales = $derived(mode === 'gray' ? scales : []);
513
- let curveVersion = $derived(JSON.stringify(scaleCurves) + JSON.stringify(curveOffset) + gray500Hex);
514
- // Chromatic vs gray distinction is only for the `derivedHex` ("Ag" preview /
515
- // border-color base); `effectiveColor` always uses `gray500Hex` for non-override derivation.
447
+ // `editingDraft` is folded in so the effective-hex closure passed to
448
+ // OverridesPanel re-derives on every drag tick (the per-step hex text would
449
+ // otherwise lag the live swatch during an override edit).
450
+ let curveVersion = $derived(JSON.stringify(scaleCurves) + JSON.stringify(curveOffset) + baseColor + (editingDraft ?? ''));
516
451
  let derivedHexForBase = $derived((step: Step, scaleTitle: string) => computeDerivedColor(step, baseColor, scaleTitle));
517
- let derivedHexForGray = $derived((step: Step, scaleTitle: string) => computeDerivedColor(step, gray500Hex, scaleTitle));
518
452
  let effectiveHexAny = $derived((k: string, step: Step, scaleTitle: string) => effectiveColor(k, step, scaleTitle, curveVersion));
519
453
 
520
454
  let isEditingBase = $derived(isBaseEdit(editing));
521
- let editingColor = $derived(isEditingBase
522
- ? (mode === 'gray' ? gray500Hex : baseColor)
523
- : editingDraft);
455
+ let editingColor = $derived(isEditingBase ? baseColor : editingDraft);
524
456
  let editingStepInfo = $derived((() => {
525
457
  if (!editingKey || isEditingBase) return null;
526
- if (mode === 'gray') {
527
- const gs = graySteps.find(s => grayStepKey(s.label) === editingKey);
528
- if (gs) return { scale: 'Gray', step: gs.label };
529
- }
530
458
  const ps = paletteStepLightness.find(p => paletteStepKey(p.label) === editingKey);
531
459
  if (ps) return { scale: 'Palette', step: ps.label };
532
460
  for (const scale of scales) {
@@ -556,15 +484,12 @@
556
484
  });
557
485
  </script>
558
486
 
559
- <div class="palette-editor" style="--editor-base: {mode === 'gray' ? gray500Hex : baseColor}">
487
+ <div class="palette-editor" style="--editor-base: {baseColor}">
560
488
  <PaletteBase
561
489
  {label}
562
490
  {displayLabel}
563
- {mode}
491
+ {neutral}
564
492
  {baseColor}
565
- {gray500Hex}
566
- {tintHue}
567
- {tintChroma}
568
493
  {anchorToBase}
569
494
  {isEditingBase}
570
495
  {panelOpen}
@@ -574,13 +499,11 @@
574
499
  onStartEdit={startBaseEdit}
575
500
  onConfirm={confirmEdit}
576
501
  onCancel={cancelEdit}
577
- onColorChange={handleColorChange}
578
- onTintChange={(h, c) => patchPalette({ tintHue: h, tintChroma: c }, 'tint hue/chroma')}
502
+ onBaseChange={(h, c, l) => edit('baseColor', oklchHex(h, c, l))}
579
503
  onAnchorToBaseChange={setAnchorToBase}
580
504
  onCopyBaseHex={copyHex}
581
505
  />
582
506
 
583
- {#if mode === 'chromatic'}
584
507
  <!-- Palette + Text row -->
585
508
  <div class="scales-row">
586
509
  <div class="scale-section">
@@ -700,87 +623,6 @@
700
623
 
701
624
  </div>
702
625
 
703
- {:else}
704
- <!-- Gray mode: palette + text row -->
705
- <div class="scales-row">
706
- <div class="scale-section">
707
- <div class="scale-header">
708
- <h4 class="scale-title">{displayLabel ?? label}</h4>
709
- <UIPillButton size="compact" variant="outline" onclick={clearPaletteOverrides}>Clear Overrides</UIPillButton>
710
- <UIPillButton size="compact" variant="outline" onclick={() => grayEditorOpen = !grayEditorOpen}>
711
- {grayEditorOpen ? 'Close' : 'Edit'}
712
- </UIPillButton>
713
- </div>
714
- <div class="swatch-grid" style="--swatch-cols: {graySteps.length + 2}">
715
- <div class="step-column">
716
- <button class="step-label copyable-label" class:copied={copiedLabelKey === 'gray-white'} type="button" onclick={(e) => copyVarName('gray-white', `--color-${cssNamespace}-white`, e)}>
717
- {copiedLabelKey === 'gray-white' ? 'copied!' : 'white'}
718
- </button>
719
- <div class="swatch gray-swatch bookend" style="background: #ffffff"></div>
720
- </div>
721
- {#each grayEffective as g}
722
- <div class="step-column">
723
- <button class="step-label copyable-label" class:copied={copiedLabelKey === g.key} type="button" onclick={(e) => copyVarName(g.key, `--color-${cssNamespace}-${g.step.label}`, e)}>
724
- {copiedLabelKey === g.key ? 'copied!' : g.step.label}
725
- </button>
726
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
727
- <div
728
- class="swatch gray-swatch"
729
- class:active={editingKey === g.key}
730
- class:overridden={g.key in overrides}
731
- style="background: {g.effective}"
732
- onclick={() => handleGrayClick(g.step, g.index)}
733
- role="button"
734
- tabindex="0"
735
- onkeydown={(e) => e.key === 'Enter' && handleGrayClick(g.step, g.index)}
736
- >
737
- {#if g.key in overrides}
738
- <span class="override-dot" title="Palette override"></span>
739
- {/if}
740
- </div>
741
- <button
742
- class="step-hex"
743
- class:copied={copiedKey === g.key}
744
- type="button"
745
- onclick={(e) => copyHex(g.key, g.effective, e)}
746
- >{copiedKey === g.key ? 'copied!' : g.effective}</button>
747
- </div>
748
- {/each}
749
- <div class="step-column">
750
- <button class="step-label copyable-label" class:copied={copiedLabelKey === 'gray-black'} type="button" onclick={(e) => copyVarName('gray-black', `--color-${cssNamespace}-black`, e)}>
751
- {copiedLabelKey === 'gray-black' ? 'copied!' : 'black'}
752
- </button>
753
- <div class="swatch gray-swatch bookend" style="background: #000000"></div>
754
- </div>
755
- {#if grayEditorOpen}
756
- <div class="curve-grid-span" style="grid-column: 2 / {graySteps.length + 2}">
757
- <ScaleCurveEditor
758
- curveKey="gray-lightness"
759
- anchors={grayLightnessCurve}
760
- cfg={lightnessCurveConfig}
761
- stepCount={graySteps.length}
762
- defaults={DEFAULT_GRAY_LIGHTNESS()}
763
- offset={curveOffset['gray-lightness'] ?? 0}
764
- onAnchorsChange={setGrayLightnessCurve}
765
- onOffsetChange={handleOffset}
766
- />
767
- <ScaleCurveEditor
768
- curveKey="gray-saturation"
769
- anchors={graySaturationCurve}
770
- cfg={saturationCurveConfig}
771
- stepCount={graySteps.length}
772
- defaults={DEFAULT_GRAY_SATURATION()}
773
- offset={curveOffset['gray-saturation'] ?? 0}
774
- onAnchorsChange={setGraySaturationCurve}
775
- onOffsetChange={handleOffset}
776
- />
777
- </div>
778
- {/if}
779
- </div>
780
- </div>
781
- </div>
782
- {/if}
783
-
784
626
  {#snippet overridesPanel(scale: Scale, canSnap: boolean, derivedHexFor: (step: Step, scaleTitle: string) => string)}
785
627
  <OverridesPanel
786
628
  {scale}
@@ -821,31 +663,28 @@
821
663
  </button>
822
664
 
823
665
  {#if showDerived}
824
- {@const activeScales = mode === 'gray' ? grayScales : scales}
825
- {@const derivedHexFor = mode === 'gray' ? derivedHexForGray : derivedHexForBase}
826
666
  <div class="scales-row">
827
- {#each activeScales.filter(s => s.isText) as scale}
828
- {@render overridesPanel(scale, true, derivedHexFor)}
667
+ {#each scales.filter(s => s.isText) as scale}
668
+ {@render overridesPanel(scale, true, derivedHexForBase)}
829
669
  {/each}
830
670
  </div>
831
671
  <div class="scales-row">
832
- {#each activeScales.filter(s => !s.isText) as scale}
833
- {@render overridesPanel(scale, mode !== 'gray', derivedHexFor)}
672
+ {#each scales.filter(s => !s.isText) as scale}
673
+ {@render overridesPanel(scale, true, derivedHexForBase)}
834
674
  {/each}
835
675
  </div>
836
676
  {/if}
837
677
 
838
678
  <!-- Color Edit Panel (non-base edits) -->
839
679
  {#if !isEditingBase && panelOpen && editingColor}
680
+ {@const oc = hexToOklch(editingColor)}
840
681
  <ColorEditPanel
841
- color={editingColor}
842
682
  title={editPanelTitle}
843
683
  showRemoveOverride={!!editingKey}
844
- mode={'hsl'}
845
- hue={tintHue}
846
- chroma={tintChroma}
847
- onHueChromaChange={(h, c) => patchPalette({ tintHue: h, tintChroma: c }, 'tint hue/chroma')}
848
- onColorChange={handleColorChange}
684
+ hue={oc.h}
685
+ chroma={oc.c}
686
+ lightness={oc.l * 100}
687
+ onHueChromaChange={(h, c, l) => handleColorChange(oklchHex(h, c, l))}
849
688
  onConfirm={confirmEdit}
850
689
  onCancel={cancelEdit}
851
690
  onRemoveOverride={() => editingKey && removeOverride(editingKey)}
@@ -52,8 +52,8 @@
52
52
  <PaletteEditor label="Brand" initialColor="#c93636" cssNamespace="brand" />
53
53
  <PaletteEditor label="Accent" initialColor="#f49e0b" cssNamespace="accent" />
54
54
  <PaletteEditor label="Background" initialColor="#1a1a2e" cssNamespace="canvas" emptySelector />
55
- <PaletteEditor mode="gray" label="Neutral" cssNamespace="neutral"/>
56
- <PaletteEditor mode="gray" label="Alternate" displayLabel="Alternate (neutral)" cssNamespace="alternate" />
55
+ <PaletteEditor neutral label="Neutral" initialColor="#70787e" cssNamespace="neutral"/>
56
+ <PaletteEditor neutral label="Alternate" displayLabel="Alternate (neutral)" initialColor="#817b78" cssNamespace="alternate" />
57
57
  <PaletteEditor label="Special" initialColor="#8b5cf6" cssNamespace="special" />
58
58
  <PaletteEditor label="Info" initialColor="#3077e8" cssNamespace="info" />
59
59
  <PaletteEditor label="Success" initialColor="#21c45d" cssNamespace="success" />
@@ -2,28 +2,31 @@
2
2
  import ColorEditPanel from '../ColorEditPanel.svelte';
3
3
  import Toggle from '../Toggle.svelte';
4
4
  import { beginSliderGesture } from '../../core/store/editorStore';
5
+ import { hexToOklch } from '../../core/palettes/oklch';
6
+
7
+ // Full sRGB chroma range (gamutClamp trims per hue/lightness). Neutrals default
8
+ // low but are not capped; their calm character comes from defaults, not a ceiling.
9
+ const CHROMA_MAX = 0.4;
10
+ // Where a typical neutral's chroma sits, marked on the slider as a soft nudge.
11
+ const NEUTRAL_CALM_CHROMA = 0.05;
5
12
 
6
13
 
7
14
 
8
15
 
9
16
  interface Props {
10
17
  /**
11
- * The header swatch + label + base-hex + (when active) the ColorEditPanel
12
- * for editing the palette's base colour. In chromatic mode the user picks
13
- * an arbitrary hex; in gray mode the user picks tint hue + chroma and the
14
- * hex is derived.
18
+ * The header swatch + label + base-hex + (when active) the OKLCH ColorEditPanel
19
+ * for editing the palette's base colour. The picker edits hue/chroma/lightness
20
+ * and maps them straight to the base hex. `neutral` only nudges the picker
21
+ * (a calm-chroma hint on the slider); it does not change behaviour.
15
22
  *
16
23
  * State (`editing`, scope handle) is owned by the parent — this component
17
- * fires callbacks (`onStartEdit`, `onConfirm`, `onCancel`, etc.). The
18
- * parent decides whether to apply the chromatic-vs-gray snapshot dance.
24
+ * fires callbacks (`onStartEdit`, `onConfirm`, `onCancel`, etc.).
19
25
  */
20
26
  label: string;
21
27
  displayLabel?: string | null;
22
- mode: 'chromatic' | 'gray';
28
+ neutral?: boolean;
23
29
  baseColor: string;
24
- gray500Hex: string;
25
- tintHue: number;
26
- tintChroma: number;
27
30
  anchorToBase: boolean;
28
31
  isEditingBase: boolean;
29
32
  panelOpen: boolean;
@@ -33,8 +36,7 @@
33
36
  onStartEdit: () => void;
34
37
  onConfirm: () => void;
35
38
  onCancel: () => void;
36
- onColorChange: (hex: string) => void;
37
- onTintChange: (hue: number, chroma: number) => void;
39
+ onBaseChange: (hue: number, chroma: number, lightness: number) => void;
38
40
  onAnchorToBaseChange: (next: boolean) => void;
39
41
  onCopyBaseHex: (key: string, hex: string, event?: MouseEvent) => void;
40
42
  }
@@ -42,11 +44,8 @@
42
44
  let {
43
45
  label,
44
46
  displayLabel = null,
45
- mode,
47
+ neutral = false,
46
48
  baseColor,
47
- gray500Hex,
48
- tintHue,
49
- tintChroma,
50
49
  anchorToBase,
51
50
  isEditingBase,
52
51
  panelOpen,
@@ -56,14 +55,13 @@
56
55
  onStartEdit,
57
56
  onConfirm,
58
57
  onCancel,
59
- onColorChange,
60
- onTintChange,
58
+ onBaseChange,
61
59
  onAnchorToBaseChange,
62
60
  onCopyBaseHex
63
61
  }: Props = $props();
64
62
 
65
- let displayHex = $derived(mode === 'gray' ? gray500Hex : baseColor);
66
- let copyKey = $derived(mode === 'gray' ? 'gray-500' : '__base__');
63
+ let baseOklch = $derived(hexToOklch(baseColor));
64
+ let pickerChromaHint = $derived(neutral ? NEUTRAL_CALM_CHROMA : undefined);
67
65
  </script>
68
66
 
69
67
  <div class="editor-top">
@@ -72,7 +70,7 @@
72
70
  <div
73
71
  class="header-swatch"
74
72
  class:active={isEditingBase}
75
- style="background: {displayHex}"
73
+ style="background: {baseColor}"
76
74
  onclick={onStartEdit}
77
75
  role="button"
78
76
  tabindex="0"
@@ -83,32 +81,30 @@
83
81
  <button
84
82
  class="base-hex clickable-hex"
85
83
  type="button"
86
- onclick={(e) => onCopyBaseHex(copyKey, displayHex, e)}
87
- >{copiedKey === copyKey ? 'copied!' : displayHex}</button>
84
+ onclick={(e) => onCopyBaseHex('__base__', baseColor, e)}
85
+ >{copiedKey === '__base__' ? 'copied!' : baseColor}</button>
88
86
  </div>
89
87
  </div>
90
88
  </div>
91
89
 
92
90
  {#if isEditingBase && panelOpen && editingColor}
93
91
  <ColorEditPanel
94
- color={editingColor}
95
92
  title={editPanelTitle}
96
93
  showRemoveOverride={false}
97
- mode={mode === 'gray' ? 'hue-chroma' : 'hsl'}
98
- hue={tintHue}
99
- chroma={tintChroma}
100
- onHueChromaChange={onTintChange}
101
- onColorChange={onColorChange}
94
+ hue={baseOklch.h}
95
+ chroma={baseOklch.c}
96
+ lightness={baseOklch.l * 100}
97
+ chromaMax={CHROMA_MAX}
98
+ chromaHint={pickerChromaHint}
99
+ onHueChromaChange={onBaseChange}
102
100
  onConfirm={onConfirm}
103
101
  onCancel={onCancel}
104
102
  onRemoveOverride={() => {}}
105
103
  onSliderStart={() => beginSliderGesture(`edit ${label} base`)}
106
104
  >
107
105
  {#snippet actions()}
108
- <span class:hidden={mode !== 'chromatic'}>
109
- <Toggle checked={anchorToBase} onchange={(v) => onAnchorToBaseChange(v ?? !anchorToBase)} label="Lock base color to position 500" />
110
- </span>
111
- {/snippet}
106
+ <Toggle checked={anchorToBase} onchange={(v) => onAnchorToBaseChange(v ?? !anchorToBase)} label="Lock base color to position 500" />
107
+ {/snippet}
112
108
  </ColorEditPanel>
113
109
  {/if}
114
110
 
@@ -183,10 +179,6 @@
183
179
  color: var(--ui-text-primary);
184
180
  }
185
181
 
186
- .hidden {
187
- display: none;
188
- }
189
-
190
182
  @media (max-width: 1280px) {
191
183
  .header-swatch {
192
184
  width: 3rem;
@@ -1,33 +1,25 @@
1
1
  /**
2
2
  * Editing-state machine for the PaletteEditor.
3
3
  *
4
- * Replaces five independent `let` decls (`editingKey`, `editingSnapshot`,
5
- * `editingDraft`, `snapshotTintHue`, `snapshotTintChroma`) with a single
6
- * discriminated union. The type-checker now enforces field validity per
7
- * mode — e.g. `snapshotTintHue` only exists when `kind === 'editingBase'`,
8
- * making it impossible to leak the gray-mode tint snapshot into a
9
- * step-edit cancel path.
4
+ * A single discriminated union replaces several independent `let` decls so
5
+ * the type-checker enforces field validity per mode.
10
6
  *
11
7
  * Modes:
12
8
  * - `idle` — no edit panel open.
13
- * - `editingBase` — header swatch is active; the user is dragging the
14
- * base hex (chromatic) or tint hue/chroma (gray). Snapshot is the
15
- * original hex used to render the swatch; the tint snapshots are only
16
- * relevant in gray mode and are nullable.
17
- * - `editingStep` a non-base swatch is active (palette / gray-step /
18
- * derived-scale step / override slot). `stepKey` is the dot-keyed
19
- * identifier; `snapshot` is the value at panel-open; `draft` is the
20
- * live working colour (drives the swatch preview before commit).
9
+ * - `editingBase` — header swatch is active; the user is dragging the base
10
+ * hex. No payload: the swatch renders from `baseColor` and cancel restores
11
+ * via the session scope.
12
+ * - `editingStep` a non-base swatch is active (palette step / derived-scale
13
+ * step / override slot). `stepKey` is the dot-keyed identifier; `snapshot`
14
+ * is the value at panel-open; `draft` is the live working colour (drives
15
+ * the swatch preview before commit).
21
16
  *
22
17
  * Locked-anchor state (`lockedLightnessIdx`, `lockedSaturationIdx`) and
23
18
  * the per-toggle injected flags (`injectedLightness`, `injectedSaturation`)
24
19
  * remain separate `let` decls in the parent because they are anchor-
25
20
  * management state, not edit-session state — they persist across
26
21
  * idle/edit transitions and have their own lifecycle tied to
27
- * `setAnchorToBase()`. Folding them into this union (per the audit's
28
- * `editingScale` sketch) would conflate two orthogonal axes; the audit's
29
- * "scale" mode does not correspond to a user-visible state since curve
30
- * edits don't open a panel.
22
+ * `setAnchorToBase()`.
31
23
  */
32
24
 
33
25
  import type { Scope } from '../../core/store/editorStore';
@@ -37,12 +29,7 @@ export const BASE_KEY = '__base__';
37
29
 
38
30
  export type EditingState =
39
31
  | { kind: 'idle' }
40
- | {
41
- kind: 'editingBase';
42
- snapshotHex: string;
43
- snapshotTintHue: number | null;
44
- snapshotTintChroma: number | null;
45
- }
32
+ | { kind: 'editingBase' }
46
33
  | {
47
34
  kind: 'editingStep';
48
35
  stepKey: string;
@@ -75,7 +62,7 @@ export function activeKey(s: EditingState): string | null {
75
62
  return s.stepKey;
76
63
  }
77
64
 
78
- /** The current draft hex (for editingStep) or null. Editingbase draws from baseColor/gray500Hex directly. */
65
+ /** The current draft hex (for editingStep) or null. editingBase draws from baseColor directly. */
79
66
  export function activeDraft(s: EditingState): string | null {
80
67
  return s.kind === 'editingStep' ? s.draft : null;
81
68
  }