@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 +63 -0
- package/dist-plugin/index.cjs +52 -2
- package/dist-plugin/index.js +52 -2
- package/package.json +1 -1
- package/src/editor/core/palettes/paletteDerivation.ts +45 -3
- package/src/editor/core/store/paletteFocus.ts +26 -0
- package/src/editor/ui/BezierCurveEditor.svelte +11 -4
- package/src/editor/ui/ColorEditPanel.svelte +1 -1
- package/src/editor/ui/PaletteEditor.svelte +79 -13
- package/src/editor/ui/UIMenuButton.svelte +14 -0
- package/src/editor/ui/UIPillButton.svelte +7 -2
- package/src/editor/ui/colors/ColorWheel.svelte +34 -1
- package/src/editor/ui/colors/ColorsTab.svelte +16 -4
- package/src/editor/ui/colors/HarmonyAxesList.svelte +44 -9
- package/src/editor/ui/colors/paletteBaseColor.ts +30 -10
- package/src/editor/ui/curveEngine.ts +48 -0
- package/src/editor/ui/palette/PaletteBase.svelte +81 -42
- package/src/editor/ui/palette/PaletteJumpButton.svelte +44 -0
- package/src/editor/ui/palette/ScaleCurveEditor.svelte +4 -1
- package/src/live-tokens/data/themes/default.json +571 -461
- package/src/live-tokens/data/tokens.generated.css +118 -118
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
|
package/dist-plugin/index.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist-plugin/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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] =
|
|
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:
|
|
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-
|
|
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) *
|
|
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(
|
|
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(
|
|
935
|
+
height: calc(5.75rem + var(--ui-space-2));
|
|
880
936
|
margin-bottom: 0;
|
|
881
937
|
}
|
|
882
938
|
|
|
883
|
-
|
|
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:
|
|
886
|
-
right:
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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-
|
|
185
|
+
padding: var(--ui-space-4, 4px) var(--ui-space-12, 12px);
|
|
186
|
+
min-height: 1.75rem;
|
|
182
187
|
}
|
|
183
188
|
</style>
|