@motion-proto/live-tokens 0.46.1 → 0.47.1

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,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.47.1 — Straightened curves
4
+
5
+ ### Fixed
6
+
7
+ - **`setCurveAnchor` could insert a handle that overran its neighbour.**
8
+ A fresh anchor's tangent handles were sized from the gap alone, so an
9
+ interior anchor placed past roughly the curve's midpoint could leave the
10
+ new segment non-monotone in x — the one thing `sampleCurve`'s binary
11
+ search cannot survive. Insertion now scales the neighbours' facing
12
+ handles by the share of the gap each keeps, the same rule de Casteljau
13
+ uses, and gives the new anchor only the room left over; `liftCurveAnchor`
14
+ reverses it on removal.
15
+
16
+ - **The default theme's curves are regenerated to their endpoints.** The
17
+ shipped `default.json` carried curves hand-dragged in the editor: two
18
+ palette-lightness curves had handles overrunning an interior anchor
19
+ (the bug above, already on disk), and a text-saturation curve overshot
20
+ its own endpoint. All 80 curves are now straight interpolations between
21
+ their designed endpoint values, with the one base-color placement per
22
+ family per curve kept and pinned to its historical step. Full audit in
23
+ `docs/plans/default-theme-curve-audit.md`.
24
+
25
+ ## 0.47.0 — Colors and Tokens hand palettes to each other
26
+
27
+ ### Added
28
+
29
+ - **Jump buttons link the two palette surfaces.** `PaletteJumpButton` sits in
30
+ the Colors view's Palette header (`Edit`, into Tokens) and on every Tokens
31
+ palette label (`Wheel`, into Colors). The new `paletteFocus` store carries the
32
+ family across: `selectedPalette` is now the Colors view's selection, so the
33
+ wheel is already showing the handed-over family when the switch lands, and the
34
+ one-shot `pendingPaletteFocus` opens the matching Tokens editor and scrolls it
35
+ into view. From the standalone Colors page the jump also navigates, since that
36
+ page has no Tokens surface to flip to.
37
+
38
+ - **`On assign` picks which hue survives an axis assignment.** A segmented
39
+ control above the axes list chooses between `Adopt swatch`, which moves the
40
+ axis to the color's hue and leaves the harmony custom, and `Adopt axis`, which
41
+ repaints the color onto the hue the axis already holds. Adopting the axis
42
+ moves no axis geometry, so an applied harmony mode survives the assignment.
43
+ `bindFamilyToAxis` takes the mode as a third argument and reconciles a traded
44
+ occupant the same way.
45
+
46
+ - **The base color anchor unlocks from the curve.** Double-clicking the locked
47
+ anchor in the lightness or saturation curve raises a confirm notice, and
48
+ accepting clears `anchorToBase`. The anchor carries a `<title>` saying so.
49
+
50
+ ### Changed
51
+
52
+ - **The Tokens base color panel stays open with the curve editors.** Opening a
53
+ palette's controls pins `ColorEditPanel` in live-apply mode (no confirm or
54
+ cancel session) and marks the header swatch active, so base edits and curve
55
+ edits are visible at once.
56
+
57
+ - **`UIMenuButton` portals its menu to the enclosing `.editor-page`.** A dimmed
58
+ ancestor's opacity faded the popup and showed the page through it, which fixed
59
+ positioning alone cannot escape. The `--ui-*` tokens are scoped to
60
+ `.editor-page`, so the menu reparents there rather than to `<body>`.
61
+
62
+ - **The axes list drops its per-row role column.** `Anchor` names the first row
63
+ from a caption above the list, so no row reserves 4rem of dead space for a
64
+ word only one of them carries.
65
+
3
66
  ## 0.46.1 — The Colors view collapses on its own width
4
67
 
5
68
  ### Fixed
@@ -164,6 +164,25 @@ function gamutClamp(l, c, h) {
164
164
  function makeAnchor(x, y, tangentLen = 15) {
165
165
  return { x, y, inDx: -tangentLen, inDy: 0, outDx: tangentLen, outDy: 0 };
166
166
  }
167
+ var HANDLE_SPAN = 1 / 3;
168
+ function tangentSlope(prev, x, y, next) {
169
+ const hPrev = prev ? x - prev.x : 0;
170
+ const hNext = next ? next.x - x : 0;
171
+ const dPrev = hPrev > 0 ? (y - prev.y) / hPrev : null;
172
+ const dNext = hNext > 0 ? (next.y - y) / hNext : null;
173
+ if (dPrev === null) return dNext ?? 0;
174
+ if (dNext === null) return dPrev;
175
+ if (dPrev * dNext <= 0) return 0;
176
+ const wPrev = 2 * hNext + hPrev;
177
+ const wNext = hNext + 2 * hPrev;
178
+ return (wPrev + wNext) / (wPrev / dPrev + wNext / dNext);
179
+ }
180
+ function tangentAnchor(x, y, prev, next) {
181
+ const m = tangentSlope(prev, x, y, next);
182
+ const inDx = prev ? -(x - prev.x) * HANDLE_SPAN : 0;
183
+ const outDx = next ? (next.x - x) * HANDLE_SPAN : 0;
184
+ return { x, y, inDx, inDy: m * inDx, outDx, outDy: m * outDx };
185
+ }
167
186
  function evalBezier(p0x, p0y, c0x, c0y, c1x, c1y, p1x, p1y, t) {
168
187
  const u = 1 - t, u2 = u * u, u3 = u2 * u;
169
188
  const t2 = t * t, t3 = t2 * t;
@@ -338,14 +357,45 @@ function setCurveAnchor(curve, x, y) {
338
357
  }
339
358
  let insertAt = curve.findIndex((a) => a.x > x);
340
359
  if (insertAt < 0) insertAt = curve.length;
341
- return { curve: [...curve.slice(0, insertAt), makeAnchor(x, y, 15), ...curve.slice(insertAt)] };
360
+ const prev = curve[insertAt - 1] ?? null;
361
+ const next = curve[insertAt] ?? null;
362
+ const out = [...curve];
363
+ if (prev && next) {
364
+ const span = next.x - prev.x;
365
+ out[insertAt - 1] = scaleOutHandle(prev, (x - prev.x) / span);
366
+ out[insertAt] = scaleInHandle(next, (next.x - x) / span);
367
+ }
368
+ out.splice(insertAt, 0, fitBetween(tangentAnchor(x, y, prev, next), out[insertAt - 1] ?? null, out[insertAt] ?? null));
369
+ return { curve: out };
370
+ }
371
+ var scaleOutHandle = (a, k) => ({ ...a, outDx: a.outDx * k, outDy: a.outDy * k });
372
+ var scaleInHandle = (a, k) => ({ ...a, inDx: a.inDx * k, inDy: a.inDy * k });
373
+ function fitBetween(a, prev, next) {
374
+ let out = a;
375
+ if (prev) {
376
+ const room = a.x - (prev.x + prev.outDx);
377
+ if (room < -out.inDx) out = scaleInHandle(out, room <= 0 ? 0 : room / -out.inDx);
378
+ }
379
+ if (next) {
380
+ const room = next.x + next.inDx - a.x;
381
+ if (room < out.outDx) out = scaleOutHandle(out, room <= 0 ? 0 : room / out.outDx);
382
+ }
383
+ return out;
342
384
  }
343
385
  function liftCurveAnchor(curve, x, displacedY) {
344
386
  const idx = curve.findIndex((a) => Math.abs(a.x - x) < 0.5);
345
387
  if (idx < 0) return curve;
346
388
  if (displacedY !== void 0) return curve.map((a, i) => i === idx ? { ...a, y: displacedY } : a);
347
389
  if (idx === 0 || idx === curve.length - 1) return curve;
348
- return curve.filter((_, i) => i !== idx);
390
+ const out = curve.filter((_, i) => i !== idx);
391
+ const prev = curve[idx - 1];
392
+ const next = curve[idx + 1];
393
+ if (prev && next) {
394
+ const span = next.x - prev.x;
395
+ out[idx - 1] = scaleOutHandle(prev, span / (curve[idx].x - prev.x));
396
+ out[idx] = scaleInHandle(next, span / (next.x - curve[idx].x));
397
+ }
398
+ return out;
349
399
  }
350
400
  function syncBaseAnchor(cfg) {
351
401
  if (cfg.anchorToBase === false) return;
@@ -123,6 +123,25 @@ function gamutClamp(l, c, h) {
123
123
  function makeAnchor(x, y, tangentLen = 15) {
124
124
  return { x, y, inDx: -tangentLen, inDy: 0, outDx: tangentLen, outDy: 0 };
125
125
  }
126
+ var HANDLE_SPAN = 1 / 3;
127
+ function tangentSlope(prev, x, y, next) {
128
+ const hPrev = prev ? x - prev.x : 0;
129
+ const hNext = next ? next.x - x : 0;
130
+ const dPrev = hPrev > 0 ? (y - prev.y) / hPrev : null;
131
+ const dNext = hNext > 0 ? (next.y - y) / hNext : null;
132
+ if (dPrev === null) return dNext ?? 0;
133
+ if (dNext === null) return dPrev;
134
+ if (dPrev * dNext <= 0) return 0;
135
+ const wPrev = 2 * hNext + hPrev;
136
+ const wNext = hNext + 2 * hPrev;
137
+ return (wPrev + wNext) / (wPrev / dPrev + wNext / dNext);
138
+ }
139
+ function tangentAnchor(x, y, prev, next) {
140
+ const m = tangentSlope(prev, x, y, next);
141
+ const inDx = prev ? -(x - prev.x) * HANDLE_SPAN : 0;
142
+ const outDx = next ? (next.x - x) * HANDLE_SPAN : 0;
143
+ return { x, y, inDx, inDy: m * inDx, outDx, outDy: m * outDx };
144
+ }
126
145
  function evalBezier(p0x, p0y, c0x, c0y, c1x, c1y, p1x, p1y, t) {
127
146
  const u = 1 - t, u2 = u * u, u3 = u2 * u;
128
147
  const t2 = t * t, t3 = t2 * t;
@@ -297,14 +316,45 @@ function setCurveAnchor(curve, x, y) {
297
316
  }
298
317
  let insertAt = curve.findIndex((a) => a.x > x);
299
318
  if (insertAt < 0) insertAt = curve.length;
300
- return { curve: [...curve.slice(0, insertAt), makeAnchor(x, y, 15), ...curve.slice(insertAt)] };
319
+ const prev = curve[insertAt - 1] ?? null;
320
+ const next = curve[insertAt] ?? null;
321
+ const out = [...curve];
322
+ if (prev && next) {
323
+ const span = next.x - prev.x;
324
+ out[insertAt - 1] = scaleOutHandle(prev, (x - prev.x) / span);
325
+ out[insertAt] = scaleInHandle(next, (next.x - x) / span);
326
+ }
327
+ out.splice(insertAt, 0, fitBetween(tangentAnchor(x, y, prev, next), out[insertAt - 1] ?? null, out[insertAt] ?? null));
328
+ return { curve: out };
329
+ }
330
+ var scaleOutHandle = (a, k) => ({ ...a, outDx: a.outDx * k, outDy: a.outDy * k });
331
+ var scaleInHandle = (a, k) => ({ ...a, inDx: a.inDx * k, inDy: a.inDy * k });
332
+ function fitBetween(a, prev, next) {
333
+ let out = a;
334
+ if (prev) {
335
+ const room = a.x - (prev.x + prev.outDx);
336
+ if (room < -out.inDx) out = scaleInHandle(out, room <= 0 ? 0 : room / -out.inDx);
337
+ }
338
+ if (next) {
339
+ const room = next.x + next.inDx - a.x;
340
+ if (room < out.outDx) out = scaleOutHandle(out, room <= 0 ? 0 : room / out.outDx);
341
+ }
342
+ return out;
301
343
  }
302
344
  function liftCurveAnchor(curve, x, displacedY) {
303
345
  const idx = curve.findIndex((a) => Math.abs(a.x - x) < 0.5);
304
346
  if (idx < 0) return curve;
305
347
  if (displacedY !== void 0) return curve.map((a, i) => i === idx ? { ...a, y: displacedY } : a);
306
348
  if (idx === 0 || idx === curve.length - 1) return curve;
307
- return curve.filter((_, i) => i !== idx);
349
+ const out = curve.filter((_, i) => i !== idx);
350
+ const prev = curve[idx - 1];
351
+ const next = curve[idx + 1];
352
+ if (prev && next) {
353
+ const span = next.x - prev.x;
354
+ out[idx - 1] = scaleOutHandle(prev, span / (curve[idx].x - prev.x));
355
+ out[idx] = scaleInHandle(next, span / (next.x - curve[idx].x));
356
+ }
357
+ return out;
308
358
  }
309
359
  function syncBaseAnchor(cfg) {
310
360
  if (cfg.anchorToBase === false) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.46.1",
3
+ "version": "0.47.1",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { hexToOklch, oklchToHexClamped, gamutClamp, type Oklch } from './oklch';
17
- import { type CurveAnchor, sampleCurve, makeAnchor } from '../../ui/curveEngine';
17
+ import { type CurveAnchor, sampleCurve, makeAnchor, tangentAnchor } from '../../ui/curveEngine';
18
18
  import type { PaletteConfig } from '../themes/themeTypes';
19
19
 
20
20
  export interface PaletteSpec {
@@ -235,7 +235,41 @@ export function setCurveAnchor(curve: CurveAnchor[], x: number, y: number): { cu
235
235
  }
236
236
  let insertAt = curve.findIndex((a) => a.x > x);
237
237
  if (insertAt < 0) insertAt = curve.length;
238
- return { curve: [...curve.slice(0, insertAt), makeAnchor(x, y, 15), ...curve.slice(insertAt)] };
238
+ const prev = curve[insertAt - 1] ?? null;
239
+ const next = curve[insertAt] ?? null;
240
+ const out = [...curve];
241
+ // The neighbours aimed their handles across a gap this insert has just split,
242
+ // so a handle that was in bounds can now reach clean past the new anchor and
243
+ // leave the segment non-monotone in x, which is the one thing sampleCurve
244
+ // cannot survive. Scale them by the share of the gap each one keeps: the same
245
+ // rule de Casteljau uses, so the curve holds its shape, and exactly invertible
246
+ // by liftCurveAnchor when the placement moves off again.
247
+ if (prev && next) {
248
+ const span = next.x - prev.x;
249
+ out[insertAt - 1] = scaleOutHandle(prev, (x - prev.x) / span);
250
+ out[insertAt] = scaleInHandle(next, (next.x - x) / span);
251
+ }
252
+ out.splice(insertAt, 0, fitBetween(tangentAnchor(x, y, prev, next), out[insertAt - 1] ?? null, out[insertAt] ?? null));
253
+ return { curve: out };
254
+ }
255
+
256
+ const scaleOutHandle = (a: CurveAnchor, k: number): CurveAnchor => ({ ...a, outDx: a.outDx * k, outDy: a.outDy * k });
257
+ const scaleInHandle = (a: CurveAnchor, k: number): CurveAnchor => ({ ...a, inDx: a.inDx * k, inDy: a.inDy * k });
258
+
259
+ /** Shorten a freshly derived anchor's handles to whatever room its neighbours
260
+ * leave. Only the new anchor gives ground: its handles carry no intent yet,
261
+ * and a stored handle is someone's edit. */
262
+ function fitBetween(a: CurveAnchor, prev: CurveAnchor | null, next: CurveAnchor | null): CurveAnchor {
263
+ let out = a;
264
+ if (prev) {
265
+ const room = a.x - (prev.x + prev.outDx);
266
+ if (room < -out.inDx) out = scaleInHandle(out, room <= 0 ? 0 : room / -out.inDx);
267
+ }
268
+ if (next) {
269
+ const room = next.x + next.inDx - a.x;
270
+ if (room < out.outDx) out = scaleOutHandle(out, room <= 0 ? 0 : room / out.outDx);
271
+ }
272
+ return out;
239
273
  }
240
274
 
241
275
  /** Undo a placement at x: restore the displaced y when one was recorded,
@@ -246,7 +280,15 @@ export function liftCurveAnchor(curve: CurveAnchor[], x: number, displacedY?: nu
246
280
  if (idx < 0) return curve;
247
281
  if (displacedY !== undefined) return curve.map((a, i) => (i === idx ? { ...a, y: displacedY } : a));
248
282
  if (idx === 0 || idx === curve.length - 1) return curve;
249
- return curve.filter((_, i) => i !== idx);
283
+ const out = curve.filter((_, i) => i !== idx);
284
+ const prev = curve[idx - 1];
285
+ const next = curve[idx + 1];
286
+ if (prev && next) {
287
+ const span = next.x - prev.x;
288
+ out[idx - 1] = scaleOutHandle(prev, span / (curve[idx].x - prev.x));
289
+ out[idx] = scaleInHandle(next, span / (next.x - curve[idx].x));
290
+ }
291
+ return out;
250
292
  }
251
293
 
252
294
  /**
@@ -0,0 +1,26 @@
1
+ import { get, writable } from 'svelte/store';
2
+ import { setEditorView } from './editorViewStore';
3
+ import { navigate, route } from '../routing/router';
4
+ import { DEFAULT_COLORS_PATH, DEFAULT_EDITOR_PATH } from '../routing/ownedRoutes';
5
+
6
+ /** The palette family the Colors view has selected. Shared so the Tokens view
7
+ * can hand a family over to the wheel. */
8
+ export const selectedPalette = writable<string>('Brand');
9
+
10
+ /** One-shot: the family whose Tokens-view editor should open and scroll into
11
+ * view. The `PaletteEditor` that matches clears it. */
12
+ export const pendingPaletteFocus = writable<string | null>(null);
13
+
14
+ export function openPaletteInWheel(label: string) {
15
+ selectedPalette.set(label);
16
+ setEditorView('colors');
17
+ }
18
+
19
+ export function openPaletteInTokens(label: string) {
20
+ selectedPalette.set(label);
21
+ pendingPaletteFocus.set(label);
22
+ setEditorView('tokens');
23
+ // The standalone Colors page has no Tokens surface to switch to, so the view
24
+ // flip alone would go nowhere.
25
+ if (get(route) === DEFAULT_COLORS_PATH) navigate(DEFAULT_EDITOR_PATH);
26
+ }
@@ -4,7 +4,7 @@
4
4
  import {
5
5
  type CurveAnchor, type CurveConfig,
6
6
  CURVE_H, CURVE_PAD_Y, CURVE_Y_PAD,
7
- isCornerAnchor, curveXToSvg, curveYToSvg, svgToX, svgToY,
7
+ isCornerAnchor, tangentAnchor, curveXToSvg, curveYToSvg, svgToX, svgToY,
8
8
  evalBezier, buildCurvePath, curveTemplates,
9
9
  serializeCurve, deserializeCurve,
10
10
  } from './curveEngine';
@@ -20,6 +20,7 @@
20
20
  lockedAnchorIndex?: number | null;
21
21
  onAnchorsChange?: (anchors: CurveAnchor[]) => void;
22
22
  onOffsetChange?: (offset: number) => void;
23
+ onLockedAnchorUnlock?: (() => void) | null;
23
24
  }
24
25
 
25
26
  let {
@@ -31,7 +32,8 @@
31
32
  defaultAnchors = null,
32
33
  lockedAnchorIndex = null,
33
34
  onAnchorsChange = () => {},
34
- onOffsetChange = () => {}
35
+ onOffsetChange = () => {},
36
+ onLockedAnchorUnlock = null
35
37
  }: Props = $props();
36
38
 
37
39
  function resetToDefault() {
@@ -189,7 +191,7 @@
189
191
  const a = anchors[index];
190
192
  const updated = [...anchors];
191
193
  if (isCornerAnchor(a)) {
192
- updated[index] = { ...a, inDx: -15, inDy: 0, outDx: 15, outDy: 0 };
194
+ updated[index] = tangentAnchor(a.x, a.y, anchors[index - 1] ?? null, anchors[index + 1] ?? null);
193
195
  } else {
194
196
  updated[index] = { ...a, inDx: 0, inDy: 0, outDx: 0, outDy: 0 };
195
197
  }
@@ -380,7 +382,12 @@
380
382
  <path
381
383
  d="M{curveXToSvg(pt.x, w, padX)},{curveYToSvg(pt.y, cfg) - 6} l5,6 l-5,6 l-5,-6 Z"
382
384
  class="curve-handle locked"
383
- />
385
+ ondblclick={onLockedAnchorUnlock ? stopPropagation(onLockedAnchorUnlock) : undefined}
386
+ >
387
+ {#if onLockedAnchorUnlock}
388
+ <title>Base color anchor. Double-click to unlock.</title>
389
+ {/if}
390
+ </path>
384
391
  {:else if isCornerAnchor(pt)}
385
392
  <rect
386
393
  x={curveXToSvg(pt.x, w, padX) - 4} y={curveYToSvg(pt.y, cfg) - 4}
@@ -340,7 +340,7 @@
340
340
  regardless of row content; the row wrappers dissolve into the grid. */
341
341
  .hsl-sliders {
342
342
  display: grid;
343
- grid-template-columns: 2.5rem minmax(6rem, 1fr) 3.5rem 0.75rem;
343
+ grid-template-columns: 1.25rem minmax(6rem, 1fr) 3.5rem 0.75rem;
344
344
  gap: var(--ui-space-6) var(--ui-space-8);
345
345
  align-items: center;
346
346
  }
@@ -8,6 +8,7 @@
8
8
  import GradientStopEditor from './palette/GradientStopEditor.svelte';
9
9
  import ScaleCurveEditor from './palette/ScaleCurveEditor.svelte';
10
10
  import PaletteBase from './palette/PaletteBase.svelte';
11
+ import PaletteJumpButton from './palette/PaletteJumpButton.svelte';
11
12
  import { type EditingState, idleState, BASE_KEY, isEditingBase as isBaseEdit } from './palette/paletteEditorState';
12
13
  import { dockTrackTemplate } from './palette/dockMagnify';
13
14
  import {
@@ -28,6 +29,7 @@
28
29
  // Base-color edits route through the shared setter so a bound harmony axis
29
30
  // follows the hue (invariant 1); the local `edit` would silently detach it.
30
31
  import { setBaseColor } from './colors/paletteBaseColor';
32
+ import { pendingPaletteFocus } from '../core/store/paletteFocus';
31
33
  import { showCopyPopover } from './copyPopover';
32
34
 
33
35
  interface Props {
@@ -98,6 +100,16 @@
98
100
 
99
101
  let showDerived = $state(false);
100
102
  let paletteEditorOpen = $state(false);
103
+ let rootEl: HTMLElement | undefined = $state();
104
+
105
+ // Arriving from the Colors view's Edit button: open this family's controls and
106
+ // bring it into view, since the Tokens view stacks every family.
107
+ $effect(() => {
108
+ if ($pendingPaletteFocus !== label) return;
109
+ pendingPaletteFocus.set(null);
110
+ paletteEditorOpen = true;
111
+ tick().then(() => rootEl?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
112
+ });
101
113
 
102
114
  function setLightnessCurve(a: CurveAnchor[]) { edit('lightnessCurve', a); }
103
115
  function setSaturationCurve(a: CurveAnchor[]) { edit('saturationCurve', a); }
@@ -133,6 +145,12 @@
133
145
  });
134
146
  }
135
147
 
148
+ let anchorUnlockPrompt = $state(false);
149
+ function confirmBaseAnchorUnlock() {
150
+ setAnchorToBase(false);
151
+ anchorUnlockPrompt = false;
152
+ }
153
+
136
154
 
137
155
  function startBaseEdit() {
138
156
  if (editing.kind === 'editingBase') { confirmEdit(); return; }
@@ -466,7 +484,7 @@
466
484
  });
467
485
  </script>
468
486
 
469
- <div class="palette-editor" style="--editor-base: {toHex(baseColor)}">
487
+ <div class="palette-editor" bind:this={rootEl} style="--editor-base: {toHex(baseColor)}">
470
488
  <PaletteBase
471
489
  {label}
472
490
  {displayLabel}
@@ -476,6 +494,7 @@
476
494
  {anchorStepLabel}
477
495
  {isEditingBase}
478
496
  {panelOpen}
497
+ pinnedOpen={paletteEditorOpen}
479
498
  {editingColor}
480
499
  {editPanelTitle}
481
500
  {copiedKey}
@@ -502,6 +521,7 @@
502
521
  <span>Gradient</span>
503
522
  </label>
504
523
  {/if}
524
+ <PaletteJumpButton family={label} {displayLabel} target="wheel" />
505
525
  <UIPillButton size="compact" variant="outline" onclick={clearPaletteOverrides}>Clear Overrides</UIPillButton>
506
526
  <UIPillButton size="compact" variant="outline" onclick={() => paletteEditorOpen = !paletteEditorOpen}>
507
527
  {paletteEditorOpen ? 'Close' : 'Edit'}
@@ -533,7 +553,9 @@
533
553
  onkeydown={(e) => e.key === 'Enter' && handlePaletteClick({ label: ps.label, lightness: ps.lightness, index: ps.index })}
534
554
  >
535
555
  {#if ps.key in overrides}
536
- <span class="override-dot" title="Palette override"></span>
556
+ <span class="override-lock" title="Palette override: this step is set by hand, not derived from the curve">
557
+ <i class="fas fa-lock" aria-hidden="true"></i>
558
+ </span>
537
559
  {/if}
538
560
  </div>
539
561
  <button
@@ -552,6 +574,16 @@
552
574
  </div>
553
575
  {#if paletteEditorOpen}
554
576
  <div class="curve-grid-span" style="grid-column: 2 / {paletteStepLightness.length + 2}">
577
+ {#if anchorUnlockPrompt && anchorToBase}
578
+ <div class="anchor-unlock-notice" role="alert">
579
+ <i class="fas fa-triangle-exclamation" aria-hidden="true"></i>
580
+ <span>Unlock the base color anchor? The palette will no longer pass through the base color.</span>
581
+ <div class="anchor-unlock-actions">
582
+ <UIPillButton size="compact" onclick={confirmBaseAnchorUnlock}>Unlock</UIPillButton>
583
+ <UIPillButton size="compact" variant="outline" onclick={() => anchorUnlockPrompt = false}>Cancel</UIPillButton>
584
+ </div>
585
+ </div>
586
+ {/if}
555
587
  <ScaleCurveEditor
556
588
  curveKey="lightness"
557
589
  anchors={lightnessCurve}
@@ -560,6 +592,7 @@
560
592
  defaults={DEFAULT_PALETTE_LIGHTNESS()}
561
593
  offset={curveOffset['lightness'] ?? 0}
562
594
  lockedAnchorIndex={lockedLightnessIdx}
595
+ onLockedAnchorUnlock={() => anchorUnlockPrompt = true}
563
596
  onAnchorsChange={setLightnessCurve}
564
597
  onOffsetChange={handleOffset}
565
598
  />
@@ -571,6 +604,7 @@
571
604
  defaults={DEFAULT_PALETTE_SATURATION()}
572
605
  offset={curveOffset['saturation'] ?? 0}
573
606
  lockedAnchorIndex={lockedSaturationIdx}
607
+ onLockedAnchorUnlock={() => anchorUnlockPrompt = true}
574
608
  onAnchorsChange={setSaturationCurve}
575
609
  onOffsetChange={handleOffset}
576
610
  />
@@ -834,7 +868,7 @@
834
868
  align-items: start;
835
869
  justify-content: start;
836
870
  min-width: 0;
837
- max-width: calc(var(--swatch-cols) * 4rem + (var(--swatch-cols) - 1) * var(--swatch-gap, var(--ui-space-4)));
871
+ max-width: calc(var(--swatch-cols) * 4.5rem + (var(--swatch-cols) - 1) * var(--swatch-gap, var(--ui-space-4)));
838
872
  }
839
873
 
840
874
  .curve-grid-span {
@@ -843,9 +877,31 @@
843
877
  gap: var(--ui-space-8);
844
878
  }
845
879
 
880
+ .anchor-unlock-notice {
881
+ display: flex;
882
+ align-items: center;
883
+ gap: var(--ui-space-6);
884
+ padding: var(--ui-space-6) var(--ui-space-12);
885
+ background: var(--ui-surface-high);
886
+ border: 1px solid var(--ui-border-low);
887
+ border-radius: var(--ui-radius-sm);
888
+ color: var(--ui-text-secondary);
889
+ font-size: var(--ui-font-size-sm);
890
+ }
891
+
892
+ .anchor-unlock-notice i {
893
+ color: var(--ui-text-tertiary);
894
+ }
895
+
896
+ .anchor-unlock-actions {
897
+ display: flex;
898
+ gap: var(--ui-space-4);
899
+ margin-left: auto;
900
+ }
901
+
846
902
  .swatch.gray-swatch {
847
903
  width: 100%;
848
- height: calc(4rem + var(--ui-space-2));
904
+ height: calc(4.75rem + var(--ui-space-2));
849
905
  /* Compensating margin: height + margin-bottom is constant every frame of
850
906
  the magnification, so the grid's row height never dips mid-transition
851
907
  (the dip bounced everything below the palette). Also keeps the hex row
@@ -876,19 +932,29 @@
876
932
  change — the swatch itself already IS the picked color. */
877
933
  .swatch.gray-swatch.anchored {
878
934
  border-color: var(--ui-border-higher);
879
- height: calc(5rem + var(--ui-space-2));
935
+ height: calc(5.75rem + var(--ui-space-2));
880
936
  margin-bottom: 0;
881
937
  }
882
938
 
883
- .override-dot {
939
+ /* An overridden step is held by hand and no longer follows the curve, which a
940
+ lock says and a dot could not. The badge carries its own dark disc: the
941
+ ramp runs from near-white to near-black, so no single glyph colour reads
942
+ against every swatch it can land on. */
943
+ .override-lock {
884
944
  position: absolute;
885
- top: 3px;
886
- right: 3px;
887
- width: 6px;
888
- height: 6px;
889
- border-radius: 50%;
890
- background: var(--ui-text-primary);
891
- border: 1px solid rgba(255, 255, 255, 0.6);
945
+ top: var(--ui-space-4);
946
+ right: var(--ui-space-4);
947
+ display: flex;
948
+ align-items: center;
949
+ justify-content: center;
950
+ width: 1.125rem;
951
+ height: 1.125rem;
952
+ border-radius: var(--ui-radius-full);
953
+ background: rgba(0, 0, 0, 0.55);
954
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.45);
955
+ color: #fff;
956
+ font-size: var(--ui-font-size-xs);
957
+ line-height: 1;
892
958
  }
893
959
 
894
960
  .empty-mode-toggle {
@@ -97,6 +97,19 @@
97
97
  document.removeEventListener('mousedown', onDocumentMousedown, true);
98
98
  });
99
99
 
100
+ /* Reparented out of the row: a trigger's surroundings can carry opacity
101
+ (dimmed rows do), which fades every descendant and would show the page
102
+ through the popup. Fixed positioning alone cannot escape that.
103
+ The host is the enclosing .editor-page, not <body>: every --ui-* token is
104
+ scoped to it, so a popup parked outside renders with no background at all.
105
+ Reparenting takes the node out of the range the component tears down, so
106
+ removal is the action's to do: a menu open when its trigger disappears
107
+ (selecting a family swaps the row's trigger) would otherwise be stranded. */
108
+ function portal(node: HTMLElement) {
109
+ (node.closest('.editor-page') ?? document.body).appendChild(node);
110
+ return { destroy: () => node.remove() };
111
+ }
112
+
100
113
  /* Fixed positioning escapes any parent overflow/stacking context. Anchored
101
114
  below the trigger, right edges aligned; flips above near the viewport
102
115
  bottom. Inline visibility rather than a state flag so the item focus that
@@ -179,6 +192,7 @@
179
192
  style="min-width: {menuMinWidth};"
180
193
  bind:this={menuEl}
181
194
  onkeydown={onMenuKeydown}
195
+ use:portal
182
196
  >
183
197
  <div class="menu-header" role="presentation">{header}</div>
184
198
  {@render children({ close })}
@@ -60,6 +60,7 @@
60
60
 
61
61
  <style>
62
62
  .ui-pill {
63
+ box-sizing: border-box;
63
64
  display: inline-flex;
64
65
  align-items: center;
65
66
  gap: var(--ui-space-6, 6px);
@@ -175,9 +176,13 @@
175
176
  border-color: rgba(255, 255, 255, 0.18);
176
177
  }
177
178
 
178
- /* Size: compact — for header bars / chrome rails */
179
+ /* Size: compact — for header bars / chrome rails. The min-height, not the
180
+ padding, sets the height: a pill carrying an icon has a taller line box than
181
+ one with only a label, and equal padding would leave them different heights
182
+ in the same row. The slack also clears glyphs that draw past their em box. */
179
183
  .ui-pill-compact {
180
184
  font-size: var(--ui-font-size-sm, 14px);
181
- padding: var(--ui-space-2, 2px) var(--ui-space-12, 12px);
185
+ padding: var(--ui-space-4, 4px) var(--ui-space-12, 12px);
186
+ min-height: 1.75rem;
182
187
  }
183
188
  </style>