@glissade/core 0.12.1 → 0.13.0-pre.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/dist/clips.d.ts CHANGED
@@ -88,7 +88,7 @@ interface DurationOpts {
88
88
  /** Arriving ease of the motion. */
89
89
  ease?: EaseSpec;
90
90
  }
91
- /** Entrance: opacity 0→1 and scale 0.8→1 (a "pop" in). */
91
+ /** Entrance: opacity 0→1 and scale [0.8,0.8][1,1] (a "pop" in). */
92
92
  declare function popIn(opts?: DurationOpts): Clip;
93
93
  type SlideEdge = 'left' | 'right' | 'top' | 'bottom';
94
94
  /**
@@ -111,4 +111,106 @@ declare function driftLoop(opts?: (DurationOpts & {
111
111
  amplitude?: number;
112
112
  })): Clip;
113
113
  //#endregion
114
- export { type ApplyOpts, type ApplyOpts as ClipApplyOpts, type ChannelOverride, type Clip, type ClipChannel, ClipError, type ClipListDelay, type ClipListOpts, type ClipResult, type ClipSpec, type ClipTarget, type DurationOpts, type SlideEdge, clip, clipList, driftLoop, popIn, pulse, slideIn };
114
+ //#region src/morph.d.ts
115
+ /** An axis-aligned box. `x`,`y` are the box CENTER (the Rect `position` convention). */
116
+ interface Box {
117
+ x: number;
118
+ y: number;
119
+ w: number;
120
+ h: number;
121
+ }
122
+ /**
123
+ * The shared-element contract: `morphNode` is the ONE element that travels (its
124
+ * `position`+`scale` are FLIPped); `fromNode`/`toNode` (both optional) cross-fade
125
+ * their `opacity`. The explicit map IS the "shared morphId" — there is no scene
126
+ * query, the caller names the three roles.
127
+ */
128
+ interface MorphTargets {
129
+ morphNode: TweenTarget;
130
+ fromNode?: TweenTarget;
131
+ toNode?: TweenTarget;
132
+ }
133
+ interface MorphOpts {
134
+ /** Wall-clock start second of the morph. */
135
+ at: number;
136
+ /** Total morph length in seconds (> 0). */
137
+ duration: number;
138
+ /** Arriving ease of position/scale and the cross-fade (default linear). */
139
+ ease?: EaseSpec;
140
+ /**
141
+ * The scale BASIS: `[base.w, base.h]` is the size at which `morphNode` renders
142
+ * at scale [1,1]. Defaults to `to` (the TO box), so the end scale is [1,1] when
143
+ * `morphNode` is authored at the document's size. `fromScale = from/base`,
144
+ * `toScale = to/base`.
145
+ */
146
+ base?: {
147
+ w: number;
148
+ h: number;
149
+ };
150
+ /**
151
+ * Cross-fade split in [0,1] (default 0.5): `fromNode` fades out over
152
+ * `duration*crossfade`; `toNode` fades in starting at `duration*(1-crossfade)`.
153
+ */
154
+ crossfade?: number;
155
+ /** v1 default `'transform'` (position+scale FLIP). `'size'` reserved/deferred. */
156
+ metric?: 'transform';
157
+ }
158
+ interface MorphResult {
159
+ tracks: ClipResult['tracks'];
160
+ /** Wall-clock end second (`at + duration`). */
161
+ end: number;
162
+ }
163
+ declare class MorphError extends Error {
164
+ constructor(message: string);
165
+ }
166
+ /**
167
+ * Compile a shared-element box-FLIP morph into hand-authored-equivalent tracks.
168
+ *
169
+ * morph({ x: 80, y: 200, w: 120, h: 36 }, { x: 320, y: 200, w: 480, h: 280 },
170
+ * { morphNode: 'morphFx', fromNode: 'chip', toNode: 'document' },
171
+ * { at: 0.5, duration: 1.2 })
172
+ *
173
+ * Emits (via the clip path):
174
+ * morphNode/position : vec2 [at]→from-center, [at+dur]→to-center
175
+ * morphNode/scale : vec2 [at]→from/base, [at+dur]→to/base
176
+ * fromNode/opacity : number[at]→1, [at+dur*crossfade]→0
177
+ * toNode/opacity : number[at+dur*(1-crossfade)]→0, [at+dur]→1
178
+ */
179
+ declare function morph(from: Box, to: Box, targets: MorphTargets, opts: MorphOpts): MorphResult;
180
+ //#endregion
181
+ //#region src/presence.d.ts
182
+ declare class PresenceError extends Error {
183
+ constructor(message: string);
184
+ }
185
+ interface PresenceOpts {
186
+ /** Wall-clock second the node ENTERS (becomes visible). */
187
+ show: number;
188
+ /** Wall-clock second the node has fully EXITED (the exit LANDS here). */
189
+ hide: number;
190
+ /** Entrance clip (default = a plain opacity fade-in 0→1, ~0.3s). */
191
+ enter?: Clip;
192
+ /** Exit clip (default = a plain opacity fade-out 1→0, ~0.3s). Back-timed to land on `hide`. */
193
+ exit?: Clip;
194
+ /** Forwarded to `enter.apply` (speed / per-channel overrides). */
195
+ enterOpts?: ApplyOpts;
196
+ /** Forwarded to `exit.apply` (speed / per-channel overrides). */
197
+ exitOpts?: ApplyOpts;
198
+ }
199
+ interface PresenceResult {
200
+ /** Reconciled tracks: the opacity guard (fused with enter/exit opacity keys) + any non-opacity channels. */
201
+ tracks: Track[];
202
+ /** The real exit second — siblings anchor here. Equals `hide`. */
203
+ end: number;
204
+ /** When the node became visible (= `show`). */
205
+ shownAt: number;
206
+ /** When the node finished exiting (= `hide`). */
207
+ hiddenAt: number;
208
+ }
209
+ /**
210
+ * Schedule a node's enter/exit presence. Emits keyed `Track[]` only.
211
+ *
212
+ * presence('card', { show: 1, hide: 5 }) // fade in at 1, fade out to land on 5
213
+ */
214
+ declare function presence(nodeId: TweenTarget, opts: PresenceOpts): PresenceResult;
215
+ //#endregion
216
+ export { type ApplyOpts, type ApplyOpts as ClipApplyOpts, type Box, type ChannelOverride, type Clip, type ClipChannel, ClipError, type ClipListDelay, type ClipListOpts, type ClipResult, type ClipSpec, type ClipTarget, type DurationOpts, MorphError, type MorphOpts, type MorphResult, type MorphTargets, PresenceError, type PresenceOpts, type PresenceResult, type SlideEdge, clip, clipList, driftLoop, morph, popIn, presence, pulse, slideIn };
package/dist/clips.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as resolveTweenTarget, p as track, s as key, x as inferValueType } from "./targetRef.js";
1
+ import { i as resolveTweenTarget, p as track, s as key, t as TARGET_PATH, x as inferValueType } from "./targetRef.js";
2
2
  //#region src/clip.ts
3
3
  /**
4
4
  * Motion clips (DESIGN.md §2 build-time sugar): `clip()` captures a relative-time
@@ -66,11 +66,19 @@ function compileChannel(target, ch, startSec, speed, override) {
66
66
  if (ch.keys.length === 0) throw new ClipError(`clip channel '${target}' has no keys`);
67
67
  const type = ch.type ?? inferValueType(ch.keys[0].value);
68
68
  const lastIdx = ch.keys.length - 1;
69
+ if (override && lastIdx === 0 && override.from !== void 0) throw new ClipError(override.to !== void 0 ? `clip override for '${target}' supplies both 'from' and 'to' on a single-key channel — ambiguous (both target the only key)` : `clip override for '${target}' supplies 'from' on a single-key channel — ambiguous (the only key already is the 'to' value)`);
69
70
  return track(target, type, ch.keys.map((k, i) => {
70
71
  let value = k.value;
72
+ let overridden = false;
71
73
  if (override) {
72
- if (i === 0 && override.from !== void 0) value = assertOverrideType(target, type, override.from);
73
- if (i === lastIdx && override.to !== void 0) value = assertOverrideType(target, type, override.to);
74
+ if (i === 0 && override.from !== void 0) {
75
+ value = assertOverrideType(target, type, override.from);
76
+ overridden = true;
77
+ }
78
+ if (i === lastIdx && override.to !== void 0) {
79
+ value = assertOverrideType(target, type, override.to);
80
+ overridden = true;
81
+ }
74
82
  }
75
83
  const out = {
76
84
  t: startSec + k.t / speed,
@@ -80,7 +88,8 @@ function compileChannel(target, ch, startSec, speed, override) {
80
88
  if (ease !== void 0) out.ease = ease;
81
89
  if (k.interp !== void 0) out.interp = k.interp;
82
90
  if (k.id !== void 0) out.id = k.id;
83
- if (k.derived !== void 0) out.derived = k.derived;
91
+ if (k.derived !== void 0 && !overridden) out.derived = k.derived;
92
+ if (k.from !== void 0) out.from = k.from;
84
93
  return out;
85
94
  }));
86
95
  }
@@ -159,7 +168,7 @@ function clipList(c, targets, startSec, opts) {
159
168
  * Loop clips (`pulse`, `driftLoop`) author first-key-value == last-key-value, so
160
169
  * tiling them under `clipList` (or repeating the clip) reads seamlessly.
161
170
  */
162
- /** Entrance: opacity 0→1 and scale 0.8→1 (a "pop" in). */
171
+ /** Entrance: opacity 0→1 and scale [0.8,0.8][1,1] (a "pop" in). */
163
172
  function popIn(opts) {
164
173
  const d = opts?.duration ?? .3;
165
174
  const ease = opts?.ease ?? "easeOutCubic";
@@ -170,7 +179,7 @@ function popIn(opts) {
170
179
  },
171
180
  scale: {
172
181
  path: "scale",
173
- keys: [key(0, .8), key(d, 1, ease)]
182
+ keys: [key(0, [.8, .8]), key(d, [1, 1], ease)]
174
183
  }
175
184
  } });
176
185
  }
@@ -203,9 +212,9 @@ function pulse(opts) {
203
212
  return clip({ channels: { scale: {
204
213
  path: "scale",
205
214
  keys: [
206
- key(0, 1),
207
- key(d / 2, peak, ease),
208
- key(d, 1, ease)
215
+ key(0, [1, 1]),
216
+ key(d / 2, [peak, peak], ease),
217
+ key(d, [1, 1], ease)
209
218
  ]
210
219
  } } });
211
220
  }
@@ -227,4 +236,247 @@ function driftLoop(opts) {
227
236
  } } });
228
237
  }
229
238
  //#endregion
230
- export { ClipError, clip, clipList, driftLoop, popIn, pulse, slideIn };
239
+ //#region src/morph.ts
240
+ /**
241
+ * Shared-element box-FLIP morph (0.13 build-time sugar). `morph()` takes two
242
+ * caller-supplied `Box` literals (a FROM rect and a TO rect, both with the Rect
243
+ * CENTER convention for x,y) and a map of target nodes, and compiles a FLIP
244
+ * (First-Last-Invert-Play) position+scale tween on ONE shared element plus an
245
+ * optional opacity cross-fade between the from/to nodes.
246
+ *
247
+ * It is PURE CORE: the FLIP delta is plain arithmetic over the two boxes — no
248
+ * Yoga, no worldMatrix, no signal evaluation, no scene query (a scene-side
249
+ * `worldBoxOf(node)` convenience is a deferred 0.14 fast-follow). The emission
250
+ * delegates to the validated `clip()` path (one track-emission codepath), so the
251
+ * output is BYTE-INDISTINGUISHABLE from hand-authored `track(...)` — same
252
+ * determinism contract as `clip`/`stagger`/`springTo`.
253
+ */
254
+ var MorphError = class extends Error {
255
+ constructor(message) {
256
+ super(message);
257
+ this.name = "MorphError";
258
+ }
259
+ };
260
+ function assertFiniteBox(label, b) {
261
+ for (const [k, v] of [
262
+ ["x", b.x],
263
+ ["y", b.y],
264
+ ["w", b.w],
265
+ ["h", b.h]
266
+ ]) if (!Number.isFinite(v)) throw new MorphError(`morph ${label}.${k} must be finite (got ${v})`);
267
+ if (!(b.w > 0) || !(b.h > 0)) throw new MorphError(`morph ${label} must have w>0 and h>0 (got w=${b.w}, h=${b.h}); a zero/negative box yields a NaN/Infinity scale`);
268
+ }
269
+ /**
270
+ * Resolve a node-level `TweenTarget` and append a prop suffix, producing the
271
+ * `'<nodeId>/<prop>'` string the clip map form resolves (which re-runs the
272
+ * structural/anonymous-id rejection via resolveTweenTarget). A string target is
273
+ * a bare node id; a property-signal carrier exposes its node via TARGET_PATH.
274
+ *
275
+ * The node id may itself carry slashes (an each() clone like 'card/3'); DO NOT
276
+ * re-split it on the FIRST slash — APPEND the prop and trust the caller. The
277
+ * scene's longest-registered-prefix resolver disambiguates node id vs prop path
278
+ * at bind time, so 'card/3' targets the clone, not the wrapping 'card' Group.
279
+ */
280
+ function nodeTarget(target, prop) {
281
+ const id = typeof target === "string" ? target : target[TARGET_PATH];
282
+ if (typeof id !== "string" || id.length === 0) return `${String(id)}/${prop}`;
283
+ return `${typeof target === "string" ? id : id.slice(0, Math.max(0, id.lastIndexOf("/")))}/${prop}`;
284
+ }
285
+ /**
286
+ * Compile a shared-element box-FLIP morph into hand-authored-equivalent tracks.
287
+ *
288
+ * morph({ x: 80, y: 200, w: 120, h: 36 }, { x: 320, y: 200, w: 480, h: 280 },
289
+ * { morphNode: 'morphFx', fromNode: 'chip', toNode: 'document' },
290
+ * { at: 0.5, duration: 1.2 })
291
+ *
292
+ * Emits (via the clip path):
293
+ * morphNode/position : vec2 [at]→from-center, [at+dur]→to-center
294
+ * morphNode/scale : vec2 [at]→from/base, [at+dur]→to/base
295
+ * fromNode/opacity : number[at]→1, [at+dur*crossfade]→0
296
+ * toNode/opacity : number[at+dur*(1-crossfade)]→0, [at+dur]→1
297
+ */
298
+ function morph(from, to, targets, opts) {
299
+ const { at, duration } = opts;
300
+ const ease = opts.ease;
301
+ const crossfade = opts.crossfade ?? .5;
302
+ const metric = opts.metric ?? "transform";
303
+ if (metric !== "transform") throw new MorphError(`morph metric '${metric}' is not supported in v1 (only 'transform')`);
304
+ assertFiniteBox("from", from);
305
+ assertFiniteBox("to", to);
306
+ if (!(duration > 0)) throw new MorphError(`morph duration must be > 0 (got ${duration})`);
307
+ if (!(crossfade >= 0 && crossfade <= 1)) throw new MorphError(`morph crossfade must be in [0,1] (got ${crossfade})`);
308
+ const base = opts.base ?? {
309
+ w: to.w,
310
+ h: to.h
311
+ };
312
+ assertFiniteBox("to", {
313
+ x: 0,
314
+ y: 0,
315
+ w: base.w,
316
+ h: base.h
317
+ });
318
+ const fromScale = [from.w / base.w, from.h / base.h];
319
+ const toScale = [to.w / base.w, to.h / base.h];
320
+ const fromCenter = [from.x, from.y];
321
+ const toCenter = [to.x, to.y];
322
+ const channels = {
323
+ position: {
324
+ path: "position",
325
+ type: "vec2",
326
+ keys: [key(0, fromCenter), key(duration, toCenter, ease)]
327
+ },
328
+ scale: {
329
+ path: "scale",
330
+ type: "vec2",
331
+ keys: [key(0, fromScale), key(duration, toScale, ease)]
332
+ }
333
+ };
334
+ const targetMap = {
335
+ position: nodeTarget(targets.morphNode, "position"),
336
+ scale: nodeTarget(targets.morphNode, "scale")
337
+ };
338
+ if (targets.fromNode !== void 0 && duration * crossfade > 0) {
339
+ channels["fromOpacity"] = {
340
+ path: "opacity",
341
+ type: "number",
342
+ keys: [key(0, 1), key(duration * crossfade, 0, ease)]
343
+ };
344
+ targetMap["fromOpacity"] = nodeTarget(targets.fromNode, "opacity");
345
+ }
346
+ if (targets.toNode !== void 0 && duration * (1 - crossfade) < duration) {
347
+ channels["toOpacity"] = {
348
+ path: "opacity",
349
+ type: "number",
350
+ keys: [key(duration * (1 - crossfade), 0), key(duration, 1, ease)]
351
+ };
352
+ targetMap["toOpacity"] = nodeTarget(targets.toNode, "opacity");
353
+ }
354
+ const { tracks } = clip({ channels }).apply(targetMap, at);
355
+ return {
356
+ tracks,
357
+ end: at + duration
358
+ };
359
+ }
360
+ //#endregion
361
+ //#region src/presence.ts
362
+ /**
363
+ * Presence scheduling (the 0.13 enter/exit sugar): build-time sugar over `clip`
364
+ * that schedules a node's ENTER on `show`, its EXIT to land exactly on `hide`,
365
+ * and authors a real `<nodeId>/opacity` WINDOW GUARD so the node is culled
366
+ * (opacity<=0, see scene/node.ts §3) outside the live window.
367
+ *
368
+ * Like `clip`/`springTo`/`stagger`, this is authoring SUGAR — it compiles to
369
+ * ordinary keyed `Track[]` via `track()`, byte-INDISTINGUISHABLE from the
370
+ * hand-authored form. There is NO runtime visibility flag and no non-track
371
+ * document state: a presence is a pure function of its arguments. Back-timing
372
+ * the exit is pure arithmetic; reconciling the enter/exit opacity keys with the
373
+ * guard uses the builder's deterministic later-wins coincident-`t` dedup with a
374
+ * fixed merge order, so goldens stay byte-identical.
375
+ *
376
+ * The canonical case is a "send-line agency moment": a node enters, lives, and
377
+ * exits on a beat. Siblings anchor to the real exit via the returned `end`.
378
+ */
379
+ var PresenceError = class extends Error {
380
+ constructor(message) {
381
+ super(message);
382
+ this.name = "PresenceError";
383
+ }
384
+ };
385
+ /** The default enter/exit: a plain opacity fade over `DEFAULT_FADE` seconds. */
386
+ const DEFAULT_FADE = .3;
387
+ /** Plain opacity fade-in 0→1 (the default enter). */
388
+ function defaultEnter() {
389
+ return clip({ channels: { opacity: {
390
+ path: "opacity",
391
+ keys: [key(0, 0), key(DEFAULT_FADE, 1)]
392
+ } } });
393
+ }
394
+ /** Plain opacity fade-out 1→0 (the default exit). */
395
+ function defaultExit() {
396
+ return clip({ channels: { opacity: {
397
+ path: "opacity",
398
+ keys: [key(0, 1), key(DEFAULT_FADE, 0)]
399
+ } } });
400
+ }
401
+ /** Effective duration of a clip after a speed multiplier (`apply` divides every t by speed). */
402
+ function effectiveDuration(c, opts) {
403
+ return c.duration / (opts?.speed ?? 1);
404
+ }
405
+ /**
406
+ * Sort keys by `t`, breaking ties by original index (a STABLE sort, so that at a
407
+ * coincident t the LATER-emitted key survives the later-wins dedup regardless of
408
+ * the engine's `Array.sort` stability). Pure: returns a new array.
409
+ */
410
+ function stableSortByT(keys) {
411
+ return keys.map((k, i) => [k, i]).sort((a, b) => a[0].t - b[0].t || a[1] - b[1]).map(([k]) => k);
412
+ }
413
+ /**
414
+ * Pull the keys of the clip's OWN `<nodeId>/opacity` track out of an applied
415
+ * result (if it authored one). Non-opacity tracks pass straight through.
416
+ */
417
+ function partitionOpacity(tracks, opacityTarget) {
418
+ const rest = [];
419
+ let opacityKeys = [];
420
+ for (const tr of tracks) if (tr.target === opacityTarget) opacityKeys = tr.keys;
421
+ else rest.push(tr);
422
+ return {
423
+ opacityKeys,
424
+ rest
425
+ };
426
+ }
427
+ /**
428
+ * Schedule a node's enter/exit presence. Emits keyed `Track[]` only.
429
+ *
430
+ * presence('card', { show: 1, hide: 5 }) // fade in at 1, fade out to land on 5
431
+ */
432
+ function presence(nodeId, opts) {
433
+ const { show, hide } = opts;
434
+ const opacityTarget = resolveTweenTarget(typeof nodeId === "string" ? `${nodeId}/opacity` : nodeId);
435
+ const lastSlash = opacityTarget.lastIndexOf("/");
436
+ const nodeIdStr = lastSlash < 0 ? opacityTarget : opacityTarget.slice(0, lastSlash);
437
+ const enter = opts.enter ?? defaultEnter();
438
+ const exit = opts.exit ?? defaultExit();
439
+ const enterRes = enter.apply(nodeIdStr, show, opts.enterOpts);
440
+ const enterEnd = show + effectiveDuration(enter, opts.enterOpts);
441
+ const exitDur = effectiveDuration(exit, opts.exitOpts);
442
+ const exitStart = hide - exitDur;
443
+ if (exitStart <= show) throw new PresenceError(`presence('${nodeIdStr}') exit needs ${exitDur.toFixed(3)}s but only ${(hide - show).toFixed(3)}s between show (${show}) and hide (${hide}) — widen the window or shorten/speed up the exit`);
444
+ const exitRes = exit.apply(nodeIdStr, exitStart, opts.exitOpts);
445
+ const { opacityKeys: enterOpacity, rest: enterRest } = partitionOpacity(enterRes.tracks, opacityTarget);
446
+ const { opacityKeys: exitOpacity, rest: exitRest } = partitionOpacity(exitRes.tracks, opacityTarget);
447
+ const bareGuard = [
448
+ key(0, 0, { interp: "hold" }),
449
+ key(enterEnd, 1, { interp: "hold" }),
450
+ key(hide, 0, { interp: "hold" })
451
+ ];
452
+ const overlay = [];
453
+ if (enterOpacity.length > 0) enterOpacity.forEach((k, i) => {
454
+ if (i === 0 && k.value !== 0 && k.interp !== "hold") overlay.push({
455
+ ...k,
456
+ interp: "hold"
457
+ });
458
+ else overlay.push(k);
459
+ });
460
+ else overlay.push(key(show, 0), key(enterEnd, 1));
461
+ if (exitOpacity.length > 0) for (const k of exitOpacity) overlay.push(k);
462
+ else overlay.push(key(exitStart, 1), key(hide, 0));
463
+ const sorted = stableSortByT([...bareGuard, ...overlay]);
464
+ const deduped = [];
465
+ for (const k of sorted) {
466
+ const last = deduped[deduped.length - 1];
467
+ if (last && last.t === k.t) deduped[deduped.length - 1] = k;
468
+ else deduped.push(k);
469
+ }
470
+ return {
471
+ tracks: [
472
+ track(opacityTarget, "number", deduped),
473
+ ...enterRest,
474
+ ...exitRest
475
+ ],
476
+ end: hide,
477
+ shownAt: show,
478
+ hiddenAt: hide
479
+ };
480
+ }
481
+ //#endregion
482
+ export { ClipError, MorphError, PresenceError, clip, clipList, driftLoop, morph, popIn, presence, pulse, slideIn };
package/dist/targetRef.js CHANGED
@@ -602,6 +602,33 @@ const pathType = {
602
602
  };
603
603
  const lerpN = (a, b, t) => a + (b - a) * t;
604
604
  const lerpPt = (a, b, t) => [lerpN(a[0], b[0], t), lerpN(a[1], b[1], t)];
605
+ /** Same hue at alpha 0 — a transparent stand-in so an appearing/disappearing
606
+ * `bg` (mesh baseline) fades through alpha instead of popping at the boundary.
607
+ * `parseColor` THROWS on a non-hex/non-canonical CSS color (hsl/named/oklch);
608
+ * since this runs inside `lerp` during evaluate() it must NEVER throw — fall
609
+ * back to holding the PRESENT color so the bg snaps in/out instead of crashing
610
+ * the whole frame. */
611
+ function transparentOf(color) {
612
+ try {
613
+ return formatColor({
614
+ ...parseColor(color),
615
+ a: 0
616
+ });
617
+ } catch {
618
+ return color;
619
+ }
620
+ }
621
+ /** `lerpColor` that NEVER throws inside `lerp` (evaluate()): on an unparseable
622
+ * color (hsl/named/oklch — `parseColor` only knows hex/rgb) it SNAPS (holds `a`,
623
+ * then `b` at t≥1) instead of crashing the frame. Byte-identical to `lerpColor`
624
+ * for parseable colors, so it does not move any golden. */
625
+ function safeLerpColor(a, b, t) {
626
+ try {
627
+ return lerpColor(a, b, t);
628
+ } catch {
629
+ return t >= 1 ? b : a;
630
+ }
631
+ }
605
632
  /** Lift a solid color to a uniform gradient matching `shape` (every stop = color),
606
633
  * so a color↔gradient tween fades smoothly instead of snapping. */
607
634
  function liftColor(color, shape) {
@@ -625,10 +652,10 @@ function liftColor(color, shape) {
625
652
  };
626
653
  }
627
654
  let warnedPaintShape = false;
628
- function paintSnap(t, a, b) {
655
+ function paintSnap(t, a, b, message = "paint lerp across mismatched gradient shapes (different kind or stop count): snapping instead of interpolating — match the kind + stop count on both keyframes (§2.2)") {
629
656
  if (!warnedPaintShape) {
630
657
  warnedPaintShape = true;
631
- emitDevWarning("paint lerp across mismatched gradient shapes (different kind or stop count): snapping instead of interpolating — match the kind + stop count on both keyframes (§2.2)");
658
+ emitDevWarning(message);
632
659
  }
633
660
  return t >= 1 ? b : a;
634
661
  }
@@ -649,17 +676,20 @@ const paintType = {
649
676
  };
650
677
  if (a.kind === "mesh" && b.kind === "mesh") {
651
678
  if (a.points.length !== b.points.length) return paintSnap(t, a, b);
679
+ if (a.interpolation !== b.interpolation) return paintSnap(t, a, b, `paint lerp across mismatched mesh interpolation modes ('${a.interpolation ?? "smooth"}' → '${b.interpolation ?? "smooth"}'): snapping instead of interpolating — the blend kernel is discrete, so it switches once at the boundary; match \`interpolation\` on both keyframes to morph the points (§3 Paint)`);
680
+ const points = a.points.map((pa, i) => {
681
+ const pb = b.points[i];
682
+ return {
683
+ pos: lerpPt(pa.pos, pb.pos, t),
684
+ color: lerpColor(pa.color, pb.color, t)
685
+ };
686
+ });
687
+ const bg = a.bg !== void 0 || b.bg !== void 0 ? { bg: safeLerpColor(a.bg ?? transparentOf(b.bg), b.bg ?? transparentOf(a.bg), t) } : {};
652
688
  return {
653
689
  kind: "mesh",
654
- points: a.points.map((pa, i) => {
655
- const pb = b.points[i];
656
- return {
657
- pos: lerpPt(pa.pos, pb.pos, t),
658
- color: lerpColor(pa.color, pb.color, t)
659
- };
660
- }),
690
+ points,
661
691
  ...a.interpolation ? { interpolation: a.interpolation } : {},
662
- ...a.bg !== void 0 && b.bg !== void 0 ? { bg: lerpColor(a.bg, b.bg, t) } : a.bg !== void 0 ? { bg: a.bg } : {}
692
+ ...bg
663
693
  };
664
694
  }
665
695
  if (a.kind === "mesh" || b.kind === "mesh") return paintSnap(t, a, b);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/core",
3
- "version": "0.12.1",
3
+ "version": "0.13.0-pre.1",
4
4
  "description": "glissade core: signals, tracks, timeline document, evaluation, easing, springs, seeded RNG. Zero DOM/Node dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {