@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 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
@@ -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.47.0",
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
  /**
@@ -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] = { ...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);
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: 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 {
@@ -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-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>
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) * 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)));
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(4rem + var(--ui-space-2));
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(5rem + var(--ui-space-2));
935
+ height: calc(5.75rem + var(--ui-space-2));
932
936
  margin-bottom: 0;
933
937
  }
934
938
 
935
- .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 {
936
944
  position: absolute;
937
- top: 3px;
938
- right: 3px;
939
- width: 6px;
940
- height: 6px;
941
- border-radius: 50%;
942
- background: var(--ui-text-primary);
943
- 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;
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-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>
@@ -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
- let visibleRender = $derived(axisRender.filter(isVisible));
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
  }