@motion-proto/live-tokens 0.47.0 → 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 +22 -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/ui/BezierCurveEditor.svelte +2 -2
- package/src/editor/ui/ColorEditPanel.svelte +1 -1
- package/src/editor/ui/PaletteEditor.svelte +26 -12
- package/src/editor/ui/UIPillButton.svelte +7 -2
- package/src/editor/ui/colors/ColorWheel.svelte +34 -1
- package/src/editor/ui/curveEngine.ts +48 -0
- package/src/editor/ui/palette/PaletteBase.svelte +75 -51
- package/src/editor/ui/palette/PaletteJumpButton.svelte +3 -8
- 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,27 @@
|
|
|
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
|
+
|
|
3
25
|
## 0.47.0 — Colors and Tokens hand palettes to each other
|
|
4
26
|
|
|
5
27
|
### Added
|
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
|
/**
|
|
@@ -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';
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
const a = anchors[index];
|
|
192
192
|
const updated = [...anchors];
|
|
193
193
|
if (isCornerAnchor(a)) {
|
|
194
|
-
updated[index] =
|
|
194
|
+
updated[index] = tangentAnchor(a.x, a.y, anchors[index - 1] ?? null, anchors[index + 1] ?? null);
|
|
195
195
|
} else {
|
|
196
196
|
updated[index] = { ...a, inDx: 0, inDy: 0, outDx: 0, outDy: 0 };
|
|
197
197
|
}
|
|
@@ -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 {
|
|
@@ -520,6 +521,7 @@
|
|
|
520
521
|
<span>Gradient</span>
|
|
521
522
|
</label>
|
|
522
523
|
{/if}
|
|
524
|
+
<PaletteJumpButton family={label} {displayLabel} target="wheel" />
|
|
523
525
|
<UIPillButton size="compact" variant="outline" onclick={clearPaletteOverrides}>Clear Overrides</UIPillButton>
|
|
524
526
|
<UIPillButton size="compact" variant="outline" onclick={() => paletteEditorOpen = !paletteEditorOpen}>
|
|
525
527
|
{paletteEditorOpen ? 'Close' : 'Edit'}
|
|
@@ -551,7 +553,9 @@
|
|
|
551
553
|
onkeydown={(e) => e.key === 'Enter' && handlePaletteClick({ label: ps.label, lightness: ps.lightness, index: ps.index })}
|
|
552
554
|
>
|
|
553
555
|
{#if ps.key in overrides}
|
|
554
|
-
<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>
|
|
555
559
|
{/if}
|
|
556
560
|
</div>
|
|
557
561
|
<button
|
|
@@ -864,7 +868,7 @@
|
|
|
864
868
|
align-items: start;
|
|
865
869
|
justify-content: start;
|
|
866
870
|
min-width: 0;
|
|
867
|
-
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)));
|
|
868
872
|
}
|
|
869
873
|
|
|
870
874
|
.curve-grid-span {
|
|
@@ -897,7 +901,7 @@
|
|
|
897
901
|
|
|
898
902
|
.swatch.gray-swatch {
|
|
899
903
|
width: 100%;
|
|
900
|
-
height: calc(
|
|
904
|
+
height: calc(4.75rem + var(--ui-space-2));
|
|
901
905
|
/* Compensating margin: height + margin-bottom is constant every frame of
|
|
902
906
|
the magnification, so the grid's row height never dips mid-transition
|
|
903
907
|
(the dip bounced everything below the palette). Also keeps the hex row
|
|
@@ -928,19 +932,29 @@
|
|
|
928
932
|
change — the swatch itself already IS the picked color. */
|
|
929
933
|
.swatch.gray-swatch.anchored {
|
|
930
934
|
border-color: var(--ui-border-higher);
|
|
931
|
-
height: calc(
|
|
935
|
+
height: calc(5.75rem + var(--ui-space-2));
|
|
932
936
|
margin-bottom: 0;
|
|
933
937
|
}
|
|
934
938
|
|
|
935
|
-
|
|
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 {
|
|
936
944
|
position: absolute;
|
|
937
|
-
top:
|
|
938
|
-
right:
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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;
|
|
944
958
|
}
|
|
945
959
|
|
|
946
960
|
.empty-mode-toggle {
|
|
@@ -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>
|
|
@@ -62,6 +62,8 @@
|
|
|
62
62
|
const EXT_OFFSET = 20; // external handle radius beyond the disc rim (room for the dotted tether)
|
|
63
63
|
const NUM_OFFSET = 24; // numeral radius beyond the external handles
|
|
64
64
|
const FREE_NUM_OFFSET = 15; // free-dot numeral, diagonal clearance past the enlarged dot
|
|
65
|
+
const NUM_PERP = 20; // sideways clearance for a numeral moved beside its own marker
|
|
66
|
+
const NUM_STACK = 20; // radial gap between the numerals of co-hued unbound axes
|
|
65
67
|
const MIN_SIZE = 240;
|
|
66
68
|
const MAX_SIZE = 560;
|
|
67
69
|
|
|
@@ -128,6 +130,7 @@
|
|
|
128
130
|
return {
|
|
129
131
|
...t,
|
|
130
132
|
hue,
|
|
133
|
+
dotR,
|
|
131
134
|
selected: t.bound && selected === t.family,
|
|
132
135
|
dot: { x: center + dotR * Math.cos(rad(hue)), y: center - dotR * Math.sin(rad(hue)) },
|
|
133
136
|
ext: { x: center + extRadius * Math.cos(rad(hue)), y: center - extRadius * Math.sin(rad(hue)) },
|
|
@@ -141,7 +144,37 @@
|
|
|
141
144
|
}),
|
|
142
145
|
);
|
|
143
146
|
|
|
144
|
-
|
|
147
|
+
// Monochromatic stacks every axis on one hue (custom does too, once the hues
|
|
148
|
+
// are dragged together), so the outer numeral slot is the same point for all
|
|
149
|
+
// of them. Each numeral then moves beside the marker it names — its own dot,
|
|
150
|
+
// or the external handle when the axis carries no color — which is the only
|
|
151
|
+
// thing that still tells the axes apart on a shared rail.
|
|
152
|
+
let visibleRender = $derived.by(() => {
|
|
153
|
+
const vis = axisRender.filter(isVisible);
|
|
154
|
+
const hueKey = (hue: number) => Math.round(normDeg(hue)) % 360;
|
|
155
|
+
const byHue = new Map<number, typeof vis>();
|
|
156
|
+
for (const t of vis) {
|
|
157
|
+
const group = byHue.get(hueKey(t.hue));
|
|
158
|
+
if (group) group.push(t);
|
|
159
|
+
else byHue.set(hueKey(t.hue), [t]);
|
|
160
|
+
}
|
|
161
|
+
return vis.map((t) => {
|
|
162
|
+
const group = byHue.get(hueKey(t.hue))!;
|
|
163
|
+
if (group.length < 2) return t;
|
|
164
|
+
// Bound axes separate by chroma radius on their own; unbound ones share the
|
|
165
|
+
// handle track, so they step outward past it instead.
|
|
166
|
+
const unboundBefore = group.slice(0, group.indexOf(t)).filter((g) => !g.bound).length;
|
|
167
|
+
const r = t.bound ? t.dotR : extRadius + unboundBefore * NUM_STACK;
|
|
168
|
+
const a = rad(t.hue);
|
|
169
|
+
return {
|
|
170
|
+
...t,
|
|
171
|
+
num: {
|
|
172
|
+
x: center + r * Math.cos(a) - NUM_PERP * Math.sin(a),
|
|
173
|
+
y: center - r * Math.sin(a) - NUM_PERP * Math.cos(a),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
});
|
|
145
178
|
|
|
146
179
|
// Free dot for a selected family on no ACTIVE axis (Neutral, Alternate, …,
|
|
147
180
|
// plus families whose axis the applied geometry deals no distinct slot): a 2D
|
|
@@ -97,6 +97,54 @@ export function makeAnchor(x: number, y: number, tangentLen = 15): CurveAnchor {
|
|
|
97
97
|
return { x, y, inDx: -tangentLen, inDy: 0, outDx: tangentLen, outDy: 0 };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** Share of a segment's x-span a tangent handle reaches across. At a third the
|
|
101
|
+
* cubic is the exact Hermite form of the slope, so the handle length carries no
|
|
102
|
+
* shape of its own, and the two facing handles can never cross. */
|
|
103
|
+
const HANDLE_SPAN = 1 / 3;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Slope to carry through (x, y), given whichever neighbours it has.
|
|
107
|
+
*
|
|
108
|
+
* The weighted harmonic mean of the two secants (Fritsch–Carlson): it always
|
|
109
|
+
* lands between them, so the segment cannot overshoot its own endpoints. The
|
|
110
|
+
* plain average (Catmull-Rom) can overshoot when the gaps differ, and on a
|
|
111
|
+
* lightness ramp an overshoot puts a step out of order with its neighbours,
|
|
112
|
+
* which is the one thing a ramp must not do.
|
|
113
|
+
*/
|
|
114
|
+
function tangentSlope(
|
|
115
|
+
prev: CurveAnchor | null,
|
|
116
|
+
x: number,
|
|
117
|
+
y: number,
|
|
118
|
+
next: CurveAnchor | null,
|
|
119
|
+
): number {
|
|
120
|
+
const hPrev = prev ? x - prev.x : 0;
|
|
121
|
+
const hNext = next ? next.x - x : 0;
|
|
122
|
+
const dPrev = hPrev > 0 ? (y - prev!.y) / hPrev : null;
|
|
123
|
+
const dNext = hNext > 0 ? (next!.y - y) / hNext : null;
|
|
124
|
+
if (dPrev === null) return dNext ?? 0;
|
|
125
|
+
if (dNext === null) return dPrev;
|
|
126
|
+
// Secants disagreeing in sign make this a turning point, where flat is the
|
|
127
|
+
// true slope rather than a failure to find one.
|
|
128
|
+
if (dPrev * dNext <= 0) return 0;
|
|
129
|
+
const wPrev = 2 * hNext + hPrev;
|
|
130
|
+
const wNext = hNext + 2 * hPrev;
|
|
131
|
+
return (wPrev + wNext) / (wPrev / dPrev + wNext / dNext);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** An anchor at (x, y) whose handles lie along the local slope, so dropping it
|
|
135
|
+
* onto a run bends the line rather than flattening it. */
|
|
136
|
+
export function tangentAnchor(
|
|
137
|
+
x: number,
|
|
138
|
+
y: number,
|
|
139
|
+
prev: CurveAnchor | null,
|
|
140
|
+
next: CurveAnchor | null,
|
|
141
|
+
): CurveAnchor {
|
|
142
|
+
const m = tangentSlope(prev, x, y, next);
|
|
143
|
+
const inDx = prev ? -(x - prev.x) * HANDLE_SPAN : 0;
|
|
144
|
+
const outDx = next ? (next.x - x) * HANDLE_SPAN : 0;
|
|
145
|
+
return { x, y, inDx, inDy: m * inDx, outDx, outDy: m * outDx };
|
|
146
|
+
}
|
|
147
|
+
|
|
100
148
|
export function isCornerAnchor(a: CurveAnchor): boolean {
|
|
101
149
|
return a.inDx === 0 && a.inDy === 0 && a.outDx === 0 && a.outDy === 0;
|
|
102
150
|
}
|