paper-motion 0.1.0 → 0.2.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.
Files changed (75) hide show
  1. package/README.md +14 -0
  2. package/bin/pamo.mjs +134 -4
  3. package/examples/halo-explainer/audio/script.txt +8 -0
  4. package/examples/halo-explainer/audio/words.json +554 -0
  5. package/examples/halo-explainer/brand/IDENTITY.md +24 -0
  6. package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
  7. package/examples/halo-explainer/brand/motion.json +9 -0
  8. package/examples/halo-explainer/brand/shader-set/set.json +104 -0
  9. package/examples/halo-explainer/brand/sound.json +33 -0
  10. package/examples/halo-explainer/brand/tokens.json +97 -0
  11. package/examples/halo-explainer/brief.md +34 -0
  12. package/examples/halo-explainer/poster.jpg +0 -0
  13. package/examples/halo-explainer/scene.html +372 -0
  14. package/examples/orbit-keynote/audio/script.txt +7 -0
  15. package/examples/orbit-keynote/audio/words.json +494 -0
  16. package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
  17. package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
  18. package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
  19. package/examples/orbit-keynote/brand/motion.json +9 -0
  20. package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
  21. package/examples/orbit-keynote/brand/sound.json +45 -0
  22. package/examples/orbit-keynote/brand/tokens.json +106 -0
  23. package/examples/orbit-keynote/brief.md +25 -0
  24. package/examples/orbit-keynote/poster.jpg +0 -0
  25. package/examples/orbit-keynote/scene.html +323 -0
  26. package/lib/customize.mjs +162 -0
  27. package/lib/live-board.js +128 -0
  28. package/lib/registry.mjs +363 -0
  29. package/lib/render.mjs +3 -3
  30. package/lib/server.mjs +1 -0
  31. package/lib/sfx.mjs +5 -3
  32. package/package.json +1 -1
  33. package/runtime/core.js +269 -0
  34. package/runtime/index.js +3 -260
  35. package/runtime/kit.js +98 -0
  36. package/runtime/moves/bar-reveal.js +19 -0
  37. package/runtime/moves/blur-in.js +7 -0
  38. package/runtime/moves/cells.js +17 -0
  39. package/runtime/moves/crossfade.js +6 -0
  40. package/runtime/moves/cut.js +3 -0
  41. package/runtime/moves/dive-in.js +21 -0
  42. package/runtime/moves/draw-on.js +12 -0
  43. package/runtime/moves/drift.js +7 -0
  44. package/runtime/moves/flash.js +15 -0
  45. package/runtime/moves/flip-3d.js +9 -0
  46. package/runtime/moves/flip-in.js +12 -0
  47. package/runtime/moves/flip-lift.js +24 -0
  48. package/runtime/moves/glitch.js +16 -0
  49. package/runtime/moves/iris.js +12 -0
  50. package/runtime/moves/magic-move.js +49 -0
  51. package/runtime/moves/mask-reveal.js +10 -0
  52. package/runtime/moves/odometer.js +32 -0
  53. package/runtime/moves/orbit.js +8 -0
  54. package/runtime/moves/pop-in.js +12 -0
  55. package/runtime/moves/punch.js +9 -0
  56. package/runtime/moves/push.js +14 -0
  57. package/runtime/moves/rgb-split.js +7 -0
  58. package/runtime/moves/roll.js +20 -0
  59. package/runtime/moves/scan-reveal.js +10 -0
  60. package/runtime/moves/scramble.js +21 -0
  61. package/runtime/moves/shake.js +15 -0
  62. package/runtime/moves/slices.js +14 -0
  63. package/runtime/moves/snap-scroll.js +23 -0
  64. package/runtime/moves/squash-drop.js +21 -0
  65. package/runtime/moves/tile-swirl.js +29 -0
  66. package/runtime/moves/track-in.js +7 -0
  67. package/runtime/moves/type-on.js +41 -0
  68. package/runtime/moves/unfold.js +20 -0
  69. package/runtime/moves/whip-pan.js +21 -0
  70. package/runtime/moves/whip.js +14 -0
  71. package/runtime/moves/wipe.js +9 -0
  72. package/runtime/moves/word-rise.js +10 -0
  73. package/runtime/moves/zoom-through.js +13 -0
  74. package/runtime/moves.js +66 -146
  75. package/templates/move-demo/scene.html +4 -3
@@ -0,0 +1,17 @@
1
+ import { ease, clamp } from '../core.js';
2
+ import { rects, clipTo, size } from '../kit.js';
3
+
4
+ /** B shows through a grid of squares that grow from their centres in a wave from `origin` (0..1, 0..1). */
5
+ export function cells(a, b, p, { cols = 16, rows = 9, origin = [0, 0], spread = 0.6, curve = ease.outCubic } = {}) {
6
+ if (p >= 1) { b.style.clipPath = 'none'; b.style.opacity = 1; return; }
7
+ const [w, h] = size(a, b), cw = w / cols, ch = h / rows, max = Math.hypot(Math.max(origin[0], 1 - origin[0]), Math.max(origin[1], 1 - origin[1]));
8
+ const list = [];
9
+ for (let j = 0; j < rows; j++) for (let i = 0; i < cols; i++) {
10
+ const d = (spread * Math.hypot((i + 0.5) / cols - origin[0], (j + 0.5) / rows - origin[1])) / max;
11
+ const q = curve(clamp((p - d) / (1 - spread)));
12
+ if (q <= 0) continue;
13
+ const k = Math.min(1, q * 1.08), sw = cw * k, sh = ch * k; // close the last hairline before the curve settles
14
+ list.push([i * cw + (cw - sw) / 2, j * ch + (ch - sh) / 2, sw, sh]);
15
+ }
16
+ clipTo(b, rects(list)); b.style.opacity = 1;
17
+ }
@@ -0,0 +1,6 @@
1
+ import { E } from '../kit.js';
2
+
3
+ export function crossfade(a, b, p, { curve } = {}) {
4
+ const q = E(p, curve);
5
+ a.style.opacity = 1 - q; b.style.opacity = q;
6
+ }
@@ -0,0 +1,3 @@
1
+
2
+
3
+ export function cut(a, b, p) { a.style.opacity = p < 1 ? 1 : 0; b.style.opacity = p < 1 ? 0 : 1; }
@@ -0,0 +1,21 @@
1
+ import { ease, lerp, progress } from '../core.js';
2
+ import { dims, items, show, blur, rectIn, layer, hide, resetAll } from '../kit.js';
3
+
4
+ /** The camera dives into a detail of A (`target`); B opens inside it as it fills the slot, a ring breaks outward, B's parts rise into focus. */
5
+ export function diveIn(a, b, p, { duration = 1.1, target = '[data-part="media"]', depth = 16 } = {}) {
6
+ const slot = a.parentElement, t = a.querySelector(target) ?? a, R = rectIn(t, a), cx = R.x + R.w / 2, cy = R.y + R.h / 2;
7
+ if (p <= 0 || p >= 1) { hide(slot, 'diveRing'); resetAll(a, b, ...items(b)); show(a, b, p); return; }
8
+ const [w, h] = dims(a), full = Math.hypot(Math.max(cx, w - cx), Math.max(cy, h - cy));
9
+ const z = ease.inExpo(progress(p, 0.02, 0.5)), sa = 1 + (depth - 1) * z;
10
+ a.style.transformOrigin = `${cx}px ${cy}px`; a.style.transform = `scale(${sa})`;
11
+ a.style.filter = blur(10 * ease.inQuad(progress(p, 0.25, 0.5))); a.style.opacity = 1 - progress(p, 0.46, 0.56);
12
+ const rad = Math.max((Math.min(R.w, R.h) / 2) * 0.8 * sa, lerp(0, full * 1.05, ease.inOutCubic(progress(p, 0.42, 0.78))));
13
+ const bz = ease.outExpo(progress(p, 0.3, 1)), sb = 1.6 - 0.6 * bz;
14
+ b.style.transformOrigin = `${cx}px ${cy}px`; b.style.transform = `scale(${sb})`; b.style.filter = blur(12 * (1 - bz));
15
+ b.style.clipPath = rad >= full ? 'none' : `circle(${rad / sb}px at ${cx}px ${cy}px)`;
16
+ b.style.opacity = p > 0.1 ? 1 : 0; b.style.zIndex = 3; a.style.zIndex = 2;
17
+ items(b).forEach((c, i) => { const q = ease.outExpo(progress(p, 0.55 + i * 0.035, 0.85 + i * 0.035)); c.style.transform = `translateY(${40 * (1 - q)}px)`; c.style.opacity = q; c.style.filter = blur(8 * (1 - q)); });
18
+ const ring = progress(p, 0.4, 0.82), rr = rad + 60 * ease.outCubic(ring);
19
+ layer(slot, 'diveRing', `display:${ring > 0 && ring < 1 ? 'block' : 'none'};left:${cx - rr}px;top:${cy - rr}px;width:${2 * rr}px;height:${2 * rr}px;border-radius:50%;z-index:4;`
20
+ + `border:${2 + 6 * (1 - ring)}px solid rgba(255,255,255,${0.75 * (1 - ring)})`);
21
+ }
@@ -0,0 +1,12 @@
1
+ import { ease, progress } from '../core.js';
2
+
3
+ /** Every stroke in `svg` (paths, lines, circles, rects, polylines) draws itself, one after another. */
4
+ export function drawOn(svg, t, { start = 0, each = 0.12, duration = 1.2, curve = ease.inOutCubic } = {}) {
5
+ svg._labStrokes ??= [...svg.querySelectorAll('path, line, circle, rect, polyline, polygon')].map((el) => ({ el, len: el.getTotalLength() }));
6
+ svg._labStrokes.forEach(({ el, len }, i) => {
7
+ const q = curve(progress(t, start + i * each, start + i * each + duration));
8
+ el.style.strokeDasharray = `${len} ${len}`;
9
+ el.style.strokeDashoffset = `${len * (1 - q)}`;
10
+ el.style.opacity = q > 0 ? 1 : 0;
11
+ });
12
+ }
@@ -0,0 +1,7 @@
1
+ import { clamp } from '../core.js';
2
+
3
+ /** A slow constant push-in over `duration` seconds (default: the whole scene). */
4
+ export function drift(el, t, { amount = 0.04, duration = 10, origin = '50% 50%' } = {}) {
5
+ el.style.transformOrigin = origin;
6
+ el.style.transform = `scale(${1 + amount * clamp(t / duration)})`;
7
+ }
@@ -0,0 +1,15 @@
1
+ import { ease, clamp } from '../core.js';
2
+
3
+ /** An overexposed white flash: A burns out, the cut happens at the peak, B comes back from the white. */
4
+ export function flash(a, b, p, { color = '#fff', bloom = 18, peak = 0.4 } = {}) {
5
+ const parent = a.parentElement;
6
+ let o = parent.querySelector(':scope > .lab-flash');
7
+ if (!o) { o = document.createElement('div'); o.className = 'lab-flash'; Object.assign(o.style, { position: 'absolute', inset: 0, background: color, pointerEvents: 'none', zIndex: 50 }); parent.append(o); }
8
+ const up = ease.inCubic(clamp(p / peak)), down = 1 - ease.outCubic(clamp((p - peak) / (1 - peak)));
9
+ const k = p < peak ? up : down;
10
+ o.style.opacity = p <= 0 || p >= 1 ? 0 : k;
11
+ const cur = p < peak ? a : b;
12
+ a.style.opacity = p < peak ? 1 : 0; b.style.opacity = p < peak ? 0 : 1;
13
+ cur.style.filter = k > 0.02 ? `blur(${bloom * k}px) brightness(${1 + 1.5 * k})` : 'none';
14
+ cur.style.transform = `scale(${1 + 0.04 * k})`;
15
+ }
@@ -0,0 +1,9 @@
1
+ import { ease, clamp } from '../core.js';
2
+
3
+ /** The frame flips over like a card (around Y) and B is on the back; it pulls back mid-flip. */
4
+ export function flip3D(a, b, p, { depth = 0.82, perspective = 2400, curve = ease.inOutCubic } = {}) {
5
+ const q = curve(clamp(p)), pull = 1 - (1 - depth) * Math.sin(Math.PI * q);
6
+ a.parentElement.style.perspective = `${perspective}px`;
7
+ if (q < 0.5) { a.style.transform = `scale(${pull}) rotateY(${-180 * q}deg)`; a.style.opacity = 1; b.style.opacity = 0; }
8
+ else { b.style.transform = `scale(${pull}) rotateY(${180 * (1 - q)}deg)`; b.style.opacity = 1; a.style.opacity = 0; }
9
+ }
@@ -0,0 +1,12 @@
1
+ import { ease, clamp, progress } from '../core.js';
2
+ import { chars } from '../kit.js';
3
+
4
+ /** Letters flip up into place around their baseline, in 3D. */
5
+ export function flipIn(el, t, { start = 0, each = 0.045, duration = 0.8, angle = -100, curve = ease.outBack } = {}) {
6
+ chars(el).forEach((c, i) => {
7
+ const p = progress(t, start + i * each, start + i * each + duration), q = curve(p);
8
+ c.style.transformOrigin = '50% 100% -0.2em';
9
+ c.style.transform = `perspective(700px) rotateX(${angle * (1 - q)}deg)`;
10
+ c.style.opacity = clamp(p * 3);
11
+ });
12
+ }
@@ -0,0 +1,24 @@
1
+ import { ease, clamp, progress } from '../core.js';
2
+ import { items, show, sp, settle, layer, hide, resetAll } from '../kit.js';
3
+
4
+ /** A lifts toward the camera, flips with a wobble and a sheen across its face, and lands as B with an echo; B's parts pop in after. */
5
+ export function flipLift(a, b, p, { duration = 1.2, lift = 150, perspective = 1800 } = {}) {
6
+ const slot = a.parentElement;
7
+ if (p <= 0 || p >= 1) { hide(slot, 'flEcho'); hide(a, 'sheen'); hide(b, 'sheen'); resetAll(a, b, ...items(b)); a.style.backfaceVisibility = b.style.backfaceVisibility = ''; show(a, b, p); return; }
8
+ slot.style.perspective = `${perspective}px`;
9
+ const L = ease.outCubic(progress(p, 0, 0.22)) * (1 - ease.inOutCubic(progress(p, 0.6, 0.8)));
10
+ const ang = 180 * settle(sp(p, 0.14, duration, { stiffness: 110, damping: 11 }), p);
11
+ const dt = Math.max(0, (p - 0.8) * duration), imp = p > 0.8 ? Math.exp(-dt * 9) * Math.sin(dt * 30) * (1 - progress(p, 0.92, 1)) : 0;
12
+ const tf = (deg) => `translateZ(${lift * L}px) rotateX(${-8 * L}deg) rotateY(${deg}deg) scale(${1 - 0.035 * imp})`;
13
+ a.style.transform = tf(ang); b.style.transform = tf(ang - 180);
14
+ a.style.backfaceVisibility = 'hidden'; b.style.backfaceVisibility = 'hidden'; a.style.opacity = 1; b.style.opacity = 1;
15
+ const turn = Math.abs(Math.sin((ang * Math.PI) / 180));
16
+ for (const [el, off] of [[a, 0], [b, 180]]) {
17
+ const pos = 120 - ((ang - off + 180) / 360) * 140;
18
+ layer(el, 'sheen', `display:block;inset:0;z-index:9;border-radius:inherit;background:linear-gradient(105deg,transparent 38%,rgba(255,255,255,.28) 50%,transparent 62%);background-size:300% 100%;background-position:${pos}% 0;opacity:${0.25 + 0.75 * turn}`);
19
+ }
20
+ a.style.filter = `brightness(${1 - 0.45 * turn})`; b.style.filter = `brightness(${1 - 0.45 * turn})`;
21
+ const e = progress(p, 0.8, 1);
22
+ layer(slot, 'flEcho', `display:${e > 0 && e < 1 ? 'block' : 'none'};inset:0;z-index:0;border-radius:14px;border:1.5px solid rgba(255,255,255,${0.55 * (1 - e)});transform:scale(${1 + 0.14 * ease.outCubic(e)})`);
23
+ items(b).forEach((c, i) => { const q = settle(sp(p, 0.3 + i * 0.035, duration, { stiffness: 240, damping: 14 }), p); c.style.transform = `translateY(${26 * (1 - q)}px) scale(${0.92 + 0.08 * q})`; c.style.opacity = clamp(0.25 + q); });
24
+ }
@@ -0,0 +1,16 @@
1
+ import { clamp, rng } from '../core.js';
2
+ import { rects, clipTo, size } from '../kit.js';
3
+
4
+ /** A few frames of torn bands, jitter and RGB split; the bands show more of B until B holds. */
5
+ export function glitch(a, b, p, { seed = 1, steps = 22, rgb = 16, shift = 140 } = {}) {
6
+ const fringe = (el, s) => { for (const c of el.children) c.style.filter = s > 0.3 ? `drop-shadow(${s}px 0 0 rgba(255,30,70,.9)) drop-shadow(${-s}px 0 0 rgba(0,220,255,.9))` : 'none'; };
7
+ if (p <= 0) { clipTo(b, ''); return; }
8
+ if (p >= 1) { b.style.clipPath = 'none'; b.style.opacity = 1; fringe(a, 0); fringe(b, 0); return; }
9
+ const r = rng(seed * 997 + Math.floor(p * steps)).next, amt = Math.sin(Math.PI * p), cover = clamp((p - 0.1) / 0.75);
10
+ const [w, h] = size(a, b), list = [];
11
+ for (let y = 0; y < h;) { const bh = h * (0.015 + 0.11 * r()); if (r() < cover) list.push([0, y, w, bh + 1]); y += bh; }
12
+ clipTo(b, rects(list)); b.style.opacity = 1;
13
+ b.style.transform = `translateX(${(r() - 0.5) * shift * amt}px)`;
14
+ a.style.transform = `translateX(${(r() - 0.5) * shift * 0.6 * amt}px)`;
15
+ fringe(a, rgb * amt * r()); fringe(b, rgb * amt);
16
+ }
@@ -0,0 +1,12 @@
1
+ import { ease, clamp } from '../core.js';
2
+ import { size } from '../kit.js';
3
+
4
+ /** B opens from a circle growing out of (x, y); A sinks back a little. */
5
+ export function iris(a, b, p, { x = '50%', y = '50%', curve = ease.inOutCubic } = {}) {
6
+ const q = curve(clamp(p)), [w, h] = size(a, b);
7
+ const r = q * Math.hypot(w, h) * 0.55;
8
+ const v = q >= 1 ? 'none' : `circle(${r}px at ${x} ${y})`;
9
+ b.style.clipPath = v; b.style.webkitClipPath = v; b.style.opacity = 1;
10
+ b.style.transform = `scale(${1.1 - 0.1 * q})`;
11
+ a.style.transform = `scale(${1 - 0.06 * q})`;
12
+ }
@@ -0,0 +1,49 @@
1
+ import { ease, clamp, lerp, progress } from '../core.js';
2
+ import { show, sp, settle, blur, orig, mixRgba, rectIn, lerpRect, layer, hide, resetAll, partsOf } from '../kit.js';
3
+
4
+ /**
5
+ * Magic move: parts with the same `data-part` in A and B fly from A's place and size to B's, each on its own spring,
6
+ * a few frames apart; text parts scale evenly and cross-blur; parts only in A leave, parts only in B pop in.
7
+ * The box behind (background, border, corners) reshapes from A's to B's.
8
+ */
9
+ export function magicMove(a, b, p, { duration = 1.1, stagger = 0.045, stiffness = 190, damping = 17 } = {}) {
10
+ const slot = a.parentElement, PA = partsOf(a), PB = partsOf(b);
11
+ const bgA = orig(a, 'backgroundColor'), bgB = orig(b, 'backgroundColor'), bcA = orig(a, 'borderTopColor'), bcB = orig(b, 'borderTopColor');
12
+ const parts = [...PA.values(), ...PB.values()];
13
+ if (p <= 0 || p >= 1) {
14
+ hide(slot, 'mmBox'); resetAll(...parts);
15
+ a.style.backgroundColor = bgA.inline; b.style.backgroundColor = bgB.inline; a.style.borderColor = bcA.inline; b.style.borderColor = bcB.inline;
16
+ a.style.overflow = ''; b.style.overflow = ''; show(a, b, p); return;
17
+ }
18
+ const RA = rectIn(a, slot), RB = rectIn(b, slot), cq = settle(sp(p, 0, duration, { stiffness: stiffness * 0.65, damping: damping * 0.9 }), p), R = lerpRect(RA, RB, cq);
19
+ const k = progress(p, 0.14, 0.5, ease.inOutCubic); // colour, border and corners change while the content swaps
20
+ const bw = lerp(parseFloat(orig(a, 'borderTopWidth').computed) || 0, parseFloat(orig(b, 'borderTopWidth').computed) || 0, k);
21
+ const rad = lerp(parseFloat(orig(a, 'borderTopLeftRadius').computed) || 0, parseFloat(orig(b, 'borderTopLeftRadius').computed) || 0, k);
22
+ layer(slot, 'mmBox', `left:${R.x}px;top:${R.y}px;width:${R.w}px;height:${R.h}px;box-sizing:border-box;z-index:1;background:${mixRgba(bgA.computed, bgB.computed, k)};border:${bw}px solid ${mixRgba(bcA.computed, bcB.computed, k)};border-radius:${rad}px`);
23
+ a.style.backgroundColor = 'transparent'; b.style.backgroundColor = 'transparent'; a.style.borderColor = 'transparent'; b.style.borderColor = 'transparent';
24
+ a.style.overflow = 'visible'; b.style.overflow = 'visible';
25
+ a.style.opacity = 1; b.style.opacity = 1; a.style.zIndex = 2; b.style.zIndex = 3;
26
+ [...new Set([...PA.keys(), ...PB.keys()])].forEach((key, i) => {
27
+ const ea = PA.get(key), eb = PB.get(key), t0 = 0.04 + i * stagger;
28
+ if (ea && eb) {
29
+ const q = settle(sp(p, t0, duration, { stiffness, damping }), p), ra = rectIn(ea, slot), rb = rectIn(eb, slot), r = lerpRect(ra, rb, q);
30
+ const text = !ea.children.length && !eb.children.length;
31
+ const place = (e, e0) => {
32
+ const sx = r.w / e0.w, sy = r.h / e0.h;
33
+ e.style.transformOrigin = '50% 50%';
34
+ e.style.transform = `translate(${r.x + r.w / 2 - (e0.x + e0.w / 2)}px, ${r.y + r.h / 2 - (e0.y + e0.h / 2)}px) scale(${text ? sy : sx}, ${sy})`;
35
+ };
36
+ place(ea, ra); place(eb, rb);
37
+ // boxes stretch when their shapes differ, so they hand over sooner than text does
38
+ const fa = text ? progress(p, t0 + 0.08, t0 + 0.36) : progress(p, t0 + 0.02, t0 + 0.18), fb = text ? progress(p, t0 + 0.16, t0 + 0.46) : progress(p, t0 + 0.08, t0 + 0.34);
39
+ ea.style.opacity = 1 - fa; ea.style.filter = blur(fa < 1 ? 9 * fa : 0);
40
+ eb.style.opacity = fb; eb.style.filter = blur(9 * (1 - fb));
41
+ } else if (ea) {
42
+ const l = progress(p, t0, t0 + 0.3, ease.inCubic);
43
+ ea.style.transform = `translateY(${-26 * l}px) scale(${1 - 0.1 * l})`; ea.style.opacity = 1 - l; ea.style.filter = blur(10 * l);
44
+ } else {
45
+ const q = settle(sp(p, 0.35 + t0, duration, { stiffness: 260, damping: 15 }), p);
46
+ eb.style.transform = `scale(${0.7 + 0.3 * q})`; eb.style.opacity = clamp(q * 1.5); eb.style.filter = blur(8 * clamp(1 - q));
47
+ }
48
+ });
49
+ }
@@ -0,0 +1,10 @@
1
+ import { ease } from '../core.js';
2
+ import { E } from '../kit.js';
3
+
4
+ /** B is revealed from an edge (`from`: bottom, top, left, right) behind a hard mask. */
5
+ export function maskReveal(a, b, p, { from = 'bottom', curve = ease.outExpo } = {}) {
6
+ const r = (1 - E(p, curve)) * 100;
7
+ const inset = { bottom: `${r}% 0 0 0`, top: `0 0 ${r}% 0`, left: `0 ${r}% 0 0`, right: `0 0 0 ${r}%` }[from];
8
+ b.style.clipPath = `inset(${inset})`; b.style.opacity = 1;
9
+ if (a) a.style.opacity = p >= 1 ? 0 : 1;
10
+ }
@@ -0,0 +1,32 @@
1
+ import { ease, clamp, lerp, progress } from '../core.js';
2
+
3
+ /** Counts from `from` to `to` on rolling digit wheels; a wheel only turns when the one to its right rolls over. */
4
+ export function odometer(el, t, { from = 0, to = 1000, start = 0, duration = 2, curve = ease.outExpo, separator = ',' } = {}) {
5
+ const v = lerp(from, to, curve(progress(t, start, start + duration)));
6
+ if (!el._labOdo) {
7
+ const nd = String(Math.round(Math.max(from, to))).length;
8
+ el.textContent = '';
9
+ const probe = document.createElement('span'); el.append(probe);
10
+ const w = Math.max(...'0123456789'.split('').map((d) => { probe.textContent = d; return probe.getBoundingClientRect().width; }));
11
+ probe.remove();
12
+ el._labOdo = [];
13
+ for (let i = 0; i < nd; i++) {
14
+ const k = nd - 1 - i;
15
+ const col = document.createElement('span');
16
+ Object.assign(col.style, { display: 'inline-block', overflow: 'hidden', height: '1em', lineHeight: '1em', width: `${w}px`, textAlign: 'center', verticalAlign: 'bottom' });
17
+ const strip = document.createElement('span');
18
+ strip.style.display = 'block';
19
+ strip.innerHTML = '01234567890'.split('').map((d) => `<span style="display:block">${d}</span>`).join('');
20
+ col.append(strip); el.append(col);
21
+ el._labOdo.push({ k, col, strip });
22
+ if (separator && k > 0 && k % 3 === 0) { const s = document.createElement('span'); s.textContent = separator; s.style.verticalAlign = 'bottom'; s.style.lineHeight = '1em'; s.dataset.k = k; el.append(s); el._labOdo.push({ k, sep: s }); }
23
+ }
24
+ }
25
+ for (const d of el._labOdo) {
26
+ const on = d.k === 0 || v >= 10 ** d.k ? 1 : 0.16;
27
+ if (d.sep) { d.sep.style.opacity = on; continue; }
28
+ const pos = d.k === 0 ? v % 10 : (Math.floor(v / 10 ** d.k) % 10) + clamp(((v / 10 ** (d.k - 1)) % 10) - 9);
29
+ d.strip.style.transform = `translateY(${-pos}em)`;
30
+ d.col.style.opacity = on;
31
+ }
32
+ }
@@ -0,0 +1,8 @@
1
+ import { ease, lerp, progress } from '../core.js';
2
+
3
+ /** A 3D camera around a world whose children sit at different translateZ. Ranges are [from, to]. */
4
+ export function orbit(world, t, { start = 0, duration = 5, yaw = [-20, 20], pitch = [8, -6], dolly = [-200, 200], curve = ease.inOutSine } = {}) {
5
+ const q = curve(progress(t, start, start + duration));
6
+ world.style.transformStyle = 'preserve-3d';
7
+ world.style.transform = `translateZ(${lerp(...dolly, q)}px) rotateX(${lerp(...pitch, q)}deg) rotateY(${lerp(...yaw, q)}deg)`;
8
+ }
@@ -0,0 +1,12 @@
1
+ import { clamp, spring } from '../core.js';
2
+ import { chars } from '../kit.js';
3
+
4
+ /** Letters pop up from the baseline on a spring. */
5
+ export function popIn(el, t, { start = 0, each = 0.04, stiffness = 260, damping = 13, rise = 0.45 } = {}) {
6
+ chars(el).forEach((c, i) => {
7
+ const dt = t - (start + i * each), q = spring(dt, { stiffness, damping });
8
+ c.style.transformOrigin = '50% 90%';
9
+ c.style.transform = `translateY(${(1 - q) * rise}em) scale(${q})`;
10
+ c.style.opacity = clamp(dt / 0.06);
11
+ });
12
+ }
@@ -0,0 +1,9 @@
1
+ import { spring } from '../core.js';
2
+
3
+ /** The frame jumps in by `amount` on each hit and springs back. */
4
+ export function punch(el, t, { hits = [0], amount = 0.09, stiffness = 420, damping = 16, origin = '50% 50%' } = {}) {
5
+ let s = 1;
6
+ for (const at of hits) { const dt = t - at; if (dt >= 0) s += amount * (1 - spring(dt, { stiffness, damping })); }
7
+ el.style.transformOrigin = origin;
8
+ el.style.transform = `scale(${s})`;
9
+ }
@@ -0,0 +1,14 @@
1
+ import { ease } from '../core.js';
2
+ import { E } from '../kit.js';
3
+
4
+ /** B pushes A out; `dir` is where A leaves to. */
5
+ export function push(a, b, p, { dir = 'left', curve = ease.inOutQuart } = {}) {
6
+ const q = E(p, curve);
7
+ const horiz = dir === 'left' || dir === 'right';
8
+ const size = horiz ? (a.offsetWidth || b.offsetWidth) : (a.offsetHeight || b.offsetHeight);
9
+ const s = dir === 'left' || dir === 'up' ? -1 : 1;
10
+ const ax = q * size * s, bx = (q - 1) * size * s;
11
+ a.style.transform = horiz ? `translateX(${ax}px)` : `translateY(${ax}px)`;
12
+ b.style.transform = horiz ? `translateX(${bx}px)` : `translateY(${bx}px)`;
13
+ a.style.opacity = 1; b.style.opacity = 1;
14
+ }
@@ -0,0 +1,7 @@
1
+
2
+ /** Red and cyan fringes burst out on each hit and fade. */
3
+ export function rgbSplit(el, t, { hits = [0], amount = 22, decay = 6, base = 0 } = {}) {
4
+ let d = base;
5
+ for (const at of hits) { const dt = t - at; if (dt >= 0) d += amount * Math.exp(-decay * dt); }
6
+ el.style.filter = d > 0.3 ? `drop-shadow(${d}px 0 0 rgba(255,30,70,.95)) drop-shadow(${-d}px 0 0 rgba(0,220,255,.95))` : 'none';
7
+ }
@@ -0,0 +1,20 @@
1
+ import { ease, clamp, lerp } from '../core.js';
2
+
3
+ /** The element rolls through `list` like a slot machine, one word every `every` seconds; its width follows. */
4
+ export function roll(el, list, t, { start = 0, every = 0.9, duration = 0.55, curve = ease.inOutQuart, line = 1.3 } = {}) {
5
+ if (!el._labRoll) {
6
+ el.textContent = '';
7
+ Object.assign(el.style, { display: 'inline-block', overflow: 'hidden', verticalAlign: 'bottom', height: `${line}em`, lineHeight: `${line}em` });
8
+ const strip = document.createElement('span');
9
+ strip.style.display = 'block';
10
+ const items = list.map((w) => { const s = document.createElement('span'); s.textContent = w; Object.assign(s.style, { display: 'block', whiteSpace: 'nowrap', width: 'max-content' }); strip.append(s); return s; });
11
+ el.append(strip);
12
+ el._labRoll = { strip, widths: items.map((s) => s.getBoundingClientRect().width) };
13
+ }
14
+ const { strip, widths } = el._labRoll;
15
+ const x = Math.max(0, t - start) / every;
16
+ let k = Math.floor(x), q = curve(clamp(((x - k) * every) / duration));
17
+ if (k >= list.length - 1) { k = list.length - 1; q = 0; }
18
+ strip.style.transform = `translateY(${-(k + q) * line}em)`;
19
+ el.style.width = `${lerp(widths[k], widths[k + 1] ?? widths[k], q)}px`;
20
+ }
@@ -0,0 +1,10 @@
1
+ import { ease } from '../core.js';
2
+ import { E } from '../kit.js';
3
+
4
+ /** B draws in from the top under a scan line element (`line`, absolutely positioned inside B's parent). */
5
+ export function scanReveal(a, b, p, { line, curve = ease.linear } = {}) {
6
+ const q = E(p, curve);
7
+ b.style.clipPath = `inset(0 0 ${(1 - q) * 100}% 0)`; b.style.opacity = 1;
8
+ if (line) { line.style.top = `${b.offsetTop + q * b.offsetHeight}px`; line.style.opacity = q > 0 && q < 1 ? 1 : 0; }
9
+ if (a) a.style.opacity = p >= 1 ? 0 : 1;
10
+ }
@@ -0,0 +1,21 @@
1
+ import { rng } from '../core.js';
2
+ import { chars, hash } from '../kit.js';
3
+
4
+ /** Letters show random glyphs, then lock into the real text from left to right. Widths stay fixed. */
5
+ export function scramble(el, t, { start = 0, duration = 1.4, fps = 30, rate = 2, seed = 1, glyphs = 'ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&*+=/<>' } = {}) {
6
+ const cs = chars(el);
7
+ if (!el._labText) {
8
+ el._labText = cs.map((c) => c.textContent);
9
+ cs.forEach((c) => { c.style.width = `${c.getBoundingClientRect().width}px`; c.style.textAlign = 'center'; });
10
+ const r = rng(seed); el._labJit = cs.map(() => r.next());
11
+ }
12
+ const n = cs.length, tick = Math.floor((t * fps) / rate);
13
+ cs.forEach((c, i) => {
14
+ const appear = start + (i / n) * duration * 0.35;
15
+ const settle = start + duration * (0.3 + 0.7 * (i / n)) + el._labJit[i] * 0.1;
16
+ if (t < appear) { c.style.opacity = 0; return; }
17
+ if (t >= settle) { c.textContent = el._labText[i]; c.style.opacity = 1; return; }
18
+ c.textContent = glyphs[Math.floor(hash(tick + seed, i) * glyphs.length)];
19
+ c.style.opacity = 0.5;
20
+ });
21
+ }
@@ -0,0 +1,15 @@
1
+ import { noise } from '../core.js';
2
+
3
+ /** A camera shake on each time in `hits`, dying out at `decay`. */
4
+ export function shake(el, t, { hits = [0], amount = 34, rot = 1.4, decay = 7, freq = 24, seed = 1 } = {}) {
5
+ let x = 0, y = 0, r = 0;
6
+ for (const [i, at] of hits.entries()) {
7
+ const dt = t - at;
8
+ if (dt < 0) continue;
9
+ const e = Math.exp(-decay * dt), z = (seed + i) * 3.1;
10
+ x += amount * e * (noise(dt * freq, z, 0) * 2 - 1);
11
+ y += amount * e * (noise(dt * freq, z, 5) * 2 - 1);
12
+ r += rot * e * (noise(dt * freq, z, 9) * 2 - 1);
13
+ }
14
+ el.style.transform = `translate(${x}px, ${y}px) rotate(${r}deg)`;
15
+ }
@@ -0,0 +1,14 @@
1
+ import { ease, clamp } from '../core.js';
2
+ import { rects, clipTo, size } from '../kit.js';
3
+
4
+ /** B comes in as `n` horizontal strips, alternating left and right, staggered top to bottom. */
5
+ export function slices(a, b, p, { n = 8, each = 0.05, curve = ease.outExpo } = {}) {
6
+ if (p >= 1) { b.style.clipPath = 'none'; b.style.opacity = 1; return; }
7
+ const [w, h] = size(a, b), sh = h / n, span = 1 - each * (n - 1), list = [];
8
+ for (let i = 0; i < n; i++) {
9
+ const q = curve(clamp((p - i * each) / span));
10
+ if (q <= 0) continue;
11
+ list.push(i % 2 ? [w * (1 - q), i * sh, w * q, sh + 1] : [0, i * sh, w * q, sh + 1]);
12
+ }
13
+ clipTo(b, rects(list)); b.style.opacity = 1;
14
+ }
@@ -0,0 +1,23 @@
1
+ import { clamp, lerp, progress } from '../core.js';
2
+ import { dims, show, sp, settle, resetAll, copy } from '../kit.js';
3
+
4
+ /** A vertical reel of cards (copies of A and B between them) scrolls past in 3D and snaps onto B; neighbours dim, shrink and tilt away. */
5
+ export function snapScroll(a, b, p, { duration = 1.2, count = 3, gap = 0.1, perspective = 1600 } = {}) {
6
+ const slot = a.parentElement, [, h] = dims(a);
7
+ slot._pmReel ??= [];
8
+ const reel = slot._pmReel;
9
+ if (p <= 0 || p >= 1) { reel.forEach((g) => { g.style.display = 'none'; }); resetAll(a, b); show(a, b, p); return; }
10
+ slot.style.perspective = `${perspective}px`;
11
+ while (reel.length < count - 1) { const g = document.createElement('div'); g.className = 'pm-fx'; g.style.position = 'absolute'; slot.append(g); reel.push(g); }
12
+ const list = [a];
13
+ for (let j = 1; j < count; j++) { const g = reel[j - 1], src = j % 2 ? b : a; g.replaceChildren(copy(src)); Object.assign(g.style, { display: 'block', left: `${src.offsetLeft}px`, top: `${src.offsetTop}px`, width: `${src.offsetWidth}px`, height: `${src.offsetHeight}px`, zIndex: 1 }); list.push(g); }
14
+ list.push(b);
15
+ const pos = count * settle(sp(p, 0, duration, { stiffness: 55, damping: 10 }), p, 0.8), env = progress(p, 0, 0.15) * (1 - progress(p, 0.85, 1));
16
+ list.forEach((el, j) => {
17
+ const d = j - pos, foc = clamp(1 - Math.abs(d)), near = Math.abs(d) < 2.2;
18
+ el.style.transform = `translateY(${d * h * (1 + gap)}px) rotateX(${-d * 16}deg) scale(${0.8 + 0.2 * foc})`;
19
+ el.style.filter = `brightness(${0.3 + 0.7 * foc})`;
20
+ el.style.opacity = near ? lerp(env, 1, foc) * (1 - clamp(Math.abs(d) - 1.2)) : 0;
21
+ el.style.zIndex = Math.round(10 - Math.abs(d) * 3);
22
+ });
23
+ }
@@ -0,0 +1,21 @@
1
+ import { ease, progress } from '../core.js';
2
+ import { dims, items, show, layer, hide, resetAll } from '../kit.js';
3
+
4
+ /** A squashes, then launches up stretched; B falls in stretched, squashes on impact and wobbles to rest while its content follows through. */
5
+ export function squashDrop(a, b, p, { duration = 1.1, height = 1.3 } = {}) {
6
+ const slot = a.parentElement, [w, h] = dims(a);
7
+ if (p <= 0 || p >= 1) { hide(slot, 'sdL', 'sdR'); resetAll(a, b, ...items(b)); show(a, b, p); return; }
8
+ const sq = ease.outQuad(progress(p, 0, 0.13)) * (1 - progress(p, 0.13, 0.2)), up = ease.inCubic(progress(p, 0.12, 0.38));
9
+ a.style.transformOrigin = '50% 100%';
10
+ a.style.transform = `translateY(${-h * height * up}px) scale(${1 + 0.05 * sq - 0.07 * up}, ${1 - 0.09 * sq + 0.16 * up})`;
11
+ a.style.opacity = p < 0.38 ? 1 : 0;
12
+ const HIT = 0.58, fall = ease.inCubic(progress(p, 0.3, HIT)), dt = Math.max(0, (p - HIT) * duration), end = 1 - progress(p, 0.9, 1);
13
+ const wob = p > HIT ? Math.exp(-dt * 7) * Math.cos(dt * 24) * end : 0, st = p < HIT ? 0.14 * fall : 0;
14
+ b.style.transformOrigin = '50% 100%';
15
+ b.style.transform = `translateY(${-h * height * (1 - fall)}px) scale(${1 - 0.06 * st + 0.09 * wob}, ${1 + st - 0.13 * wob})`;
16
+ b.style.opacity = p > 0.3 ? 1 : 0;
17
+ items(b).forEach((c, i) => { const y = p < HIT ? -24 * fall : -24 * Math.exp(-dt * 8) * Math.cos(dt * 20 + i * 0.4) * end; c.style.transform = `translateY(${y}px)`; });
18
+ const l = progress(p, HIT, HIT + 0.22), on = l > 0 && l < 1 ? 'block' : 'none', len = 120 * (1 - l) + 12, reach = 150 * ease.outCubic(l);
19
+ layer(slot, 'sdL', `display:${on};left:${-reach - len}px;top:${h - 2}px;width:${len}px;height:3px;background:#fff;opacity:${1 - l};z-index:4`);
20
+ layer(slot, 'sdR', `display:${on};left:${w + reach}px;top:${h - 2}px;width:${len}px;height:3px;background:#fff;opacity:${1 - l};z-index:4`);
21
+ }
@@ -0,0 +1,29 @@
1
+ import { ease, progress } from '../core.js';
2
+ import { dims, show, copy } from '../kit.js';
3
+
4
+ /** A breaks into tiles that lift and swirl outward in a diagonal wave, flipping over in flight, and land as B. */
5
+ export function tileSwirl(a, b, p, { cols = 6, rows = 4, lift = 280, perspective = 1600 } = {}) {
6
+ const slot = a.parentElement, [w, h] = dims(a), cw = w / cols, ch = h / rows;
7
+ slot._pmTiles ??= [];
8
+ const tiles = slot._pmTiles;
9
+ if (p <= 0 || p >= 1) { tiles.forEach((t) => { t.style.display = 'none'; }); show(a, b, p); return; }
10
+ slot.style.perspective = `${perspective}px`;
11
+ while (tiles.length < cols * rows) {
12
+ const t = document.createElement('div'); t.className = 'pm-fx';
13
+ t.innerHTML = '<div class="f"></div><div class="f"></div>';
14
+ slot.append(t); tiles.push(t);
15
+ }
16
+ a.style.opacity = 0; b.style.opacity = 0;
17
+ for (let j = 0; j < rows; j++) for (let i = 0; i < cols; i++) {
18
+ const t = tiles[j * cols + i], [front, back] = t.children, x = i * cw, y = j * ch;
19
+ const d = (i / (cols - 1)) * 0.32 + (j / (rows - 1)) * 0.12, q = ease.inOutCubic(progress(p, d, d + 0.56)), arc = Math.sin(Math.PI * q);
20
+ const ox = ((i + 0.5) / cols - 0.5) * w * 0.55 * arc, oy = ((j + 0.5) / rows - 0.5) * h * 0.55 * arc - 50 * arc, spin = (((i * 7 + j * 3) % 5) - 2) * 9 * arc;
21
+ t.style.cssText = `position:absolute;left:${x}px;top:${y}px;width:${Math.ceil(cw) + 1}px;height:${Math.ceil(ch) + 1}px;z-index:${5 + Math.round(arc * 10)};transform-style:preserve-3d;`
22
+ + `transform:translate3d(${ox}px,${oy}px,${lift * arc}px) rotateZ(${spin}deg) rotateY(${180 * q}deg) scale(${1 - 0.18 * arc})`;
23
+ for (const [face, src, flip] of [[front, a, ''], [back, b, 'rotateY(180deg)']]) {
24
+ face.style.cssText = `position:absolute;inset:0;overflow:hidden;backface-visibility:hidden;transform:${flip || 'none'};filter:brightness(${1 - 0.35 * arc})`;
25
+ const c = copy(src); c.style.left = `${-x}px`; c.style.top = `${-y}px`;
26
+ face.replaceChildren(c);
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,7 @@
1
+ import { ease, progress } from '../core.js';
2
+
3
+ /** Letters settle from wide spacing (`from`, em) to `to`. */
4
+ export function trackIn(el, t, { start = 0, duration = 1.0, from = 0.4, to = 0, curve = ease.outExpo } = {}) {
5
+ const q = curve(progress(t, start, start + duration));
6
+ el.style.letterSpacing = `${from + (to - from) * q}em`; el.style.opacity = Math.min(1, q * 2);
7
+ }
@@ -0,0 +1,41 @@
1
+ import { clamp, rng } from '../core.js';
2
+
3
+ /**
4
+ * When each character of `text` appears, typed by a person: quick bursts inside words, a beat after spaces,
5
+ * longer after punctuation, the odd hesitation. Deterministic per seed. Use the same times for the text
6
+ * (typeOn's `times`) and the sound (keySounds), so every letter lands on its keystroke.
7
+ * @returns {number[]} times in seconds, one per character
8
+ */
9
+ export function typingTimes(text, { start = 0, cps = 11, seed = 1 } = {}) {
10
+ const r = rng(seed).next, base = 1 / cps, out = [];
11
+ let t = start;
12
+ for (let i = 0; i < text.length; i++) {
13
+ const prev = text[i - 1] ?? '', ch = text[i];
14
+ let k = 0.7 + 0.45 * r(); // inside a word: fast, uneven
15
+ if (prev === ' ') k = 1.35 + 0.5 * r(); // starting a new word
16
+ if (/[.,;:!?]/.test(prev)) k = 2.2 + 1.2 * r(); // after punctuation
17
+ if (/[\/\-_@]/.test(ch) || /[\/\-_@]/.test(prev)) k *= 1.25; // symbols need a reach
18
+ if (r() < 0.04) k *= 2.4; // a hesitation
19
+ t += i === 0 ? 0 : base * k;
20
+ out.push(t);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ /** Schedules one keystroke sound per character at `times` (from typingTimes), plus an optional enter. */
26
+ export function keySounds(s, text, times, { bank, style, gain = 0.16, seed = 1, enter = null, pan = 0.04 } = {}) {
27
+ [...text].forEach((ch, i) => s.sfx(times[i], 'key', { kind: ch === ' ' ? 'space' : 'letter', gain, seed: seed * 1000 + i, pan: ((i % 5) - 2) * pan, ...(bank ? { bank } : {}), ...(style ? { style } : {}) }));
28
+ if (enter !== null) s.sfx(enter, 'key', { kind: 'enter', gain: gain * 1.1, seed: seed * 1000 + 999, ...(bank ? { bank } : {}), ...(style ? { style } : {}) });
29
+ }
30
+
31
+ /** Types `text` from `start` at `cps` characters a second, or at explicit `times` (typingTimes); `cursor` (an element) is solid while typing, blinks at `blink` Hz after. */
32
+ export function typeOn(el, text, t, { start = 0, cps = 35, times = null, cursor = null, blink = 2.2 } = {}) {
33
+ if (times) start = times[0] ?? start;
34
+ const n = times ? times.filter((x) => x <= t).length : clamp(Math.floor((t - start) * cps), 0, text.length);
35
+ el.textContent = text.slice(0, n);
36
+ if (cursor) {
37
+ const typing = t >= start && (n < text.length || (times && t - times[times.length - 1] < 0.25));
38
+ cursor.style.opacity = t < start ? 0 : typing || Math.floor((t - start) * blink * 2) % 2 === 0 ? 1 : 0;
39
+ }
40
+ return n;
41
+ }
@@ -0,0 +1,20 @@
1
+ import { ease, clamp, lerp, progress } from '../core.js';
2
+ import { dims, items, show, sp, settle, blur, layer, hide, resetAll } from '../kit.js';
3
+
4
+ /** A's items leave, A folds into a glowing line, a glint runs along it, and B unfolds out of the line with overshoot as its items rise from it. */
5
+ export function unfold(a, b, p, { duration = 1.2, radius = 14 } = {}) {
6
+ const slot = a.parentElement, [w, h] = dims(a);
7
+ if (p <= 0 || p >= 1) { hide(slot, 'ufLine'); resetAll(a, b, ...items(a), ...items(b)); show(a, b, p); return; }
8
+ items(a).forEach((c, i) => { const l = progress(p, i * 0.03, i * 0.03 + 0.22); c.style.transform = `translateY(${-30 * ease.inCubic(l)}px)`; c.style.opacity = 1 - l; c.style.filter = blur(10 * l); });
9
+ const f = ease.inOutCubic(progress(p, 0.16, 0.42)), ins = (h / 2 - 1.5) * f;
10
+ a.style.clipPath = `inset(${ins}px 0 ${ins}px 0 round ${lerp(radius, 2, f)}px)`;
11
+ a.style.opacity = p < 0.44 ? 1 : 0;
12
+ const lp = progress(p, 0.36, 0.64), breathe = Math.sin(Math.PI * lp), g = ease.inOutCubic(lp) * 120 - 10;
13
+ layer(slot, 'ufLine', `display:${lp > 0 && lp < 1 ? 'block' : 'none'};left:${(w - w * (1 + 0.08 * breathe)) / 2}px;width:${w * (1 + 0.08 * breathe)}px;top:${h / 2 - 1.5}px;height:3px;z-index:4;border-radius:2px;`
14
+ + `background:linear-gradient(90deg,rgba(255,255,255,.35) ${g - 14}%,#fff ${g}%,rgba(255,255,255,.35) ${g + 14}%);box-shadow:0 0 ${18 * breathe}px rgba(255,255,255,${0.6 * breathe})`);
15
+ const u = settle(sp(p, 0.5, duration, { stiffness: 230, damping: 16 }), p), bi = Math.max(0, (h / 2 - 1.5) * (1 - u));
16
+ b.style.clipPath = `inset(${bi}px 0 ${bi}px 0 round ${lerp(2, radius, clamp(u))}px)`;
17
+ b.style.transform = `scaleY(${1 + Math.max(0, u - 1) * 0.6})`;
18
+ b.style.opacity = p > 0.5 ? 1 : 0;
19
+ items(b).forEach((c, i) => { const q = ease.outExpo(progress(p, 0.6 + i * 0.04, 0.88 + i * 0.03)); c.style.transform = `translateY(${40 * (1 - q)}px)`; c.style.opacity = q; c.style.filter = blur(8 * (1 - q)); });
20
+ }
@@ -0,0 +1,21 @@
1
+ import { ease, clamp, lerp, progress } from '../core.js';
2
+ import { dims, items, show, sp, settle, layer, hide, resetAll } from '../kit.js';
3
+
4
+ /** A winds up, then whips out tilted and smeared; a light streak crosses at full speed; B whips in, overshoots and settles, its content trailing. */
5
+ export function whipPan(a, b, p, { duration = 0.9, dir = 'left', tilt = 7, travel = 1.35 } = {}) {
6
+ const slot = a.parentElement, [w] = dims(a), s = dir === 'left' ? -1 : 1;
7
+ if (p <= 0 || p >= 1) { hide(slot, 'wpStreak'); resetAll(a, b, ...items(a), ...items(b)); show(a, b, p); return; }
8
+ const wind = ease.outCubic(progress(p, 0, 0.18)) * (1 - ease.inCubic(progress(p, 0.18, 0.3)));
9
+ const out = ease.inExpo(progress(p, 0.16, 0.5));
10
+ a.style.transformOrigin = '50% 70%';
11
+ a.style.transform = `translateX(${-s * 40 * wind + s * w * travel * out}px) rotate(${-s * 1.6 * wind + s * tilt * out}deg) skewX(${-s * 12 * out}deg) scale(${1 - 0.03 * wind})`;
12
+ a.style.opacity = p < 0.5 ? 1 : 0;
13
+ const e = settle(sp(p, 0.42, duration, { stiffness: 250, damping: 19 }), p), r = 1 - e;
14
+ b.style.transformOrigin = '50% 70%';
15
+ b.style.transform = `translateX(${-s * w * travel * r}px) rotate(${-s * tilt * r}deg) skewX(${s * 12 * clamp(r) * clamp(r)}deg)`;
16
+ b.style.opacity = p > 0.42 ? 1 : 0;
17
+ items(b).forEach((c, i) => { const ci = settle(sp(p, 0.47 + i * 0.03, duration, { stiffness: 230, damping: 17 }), p); c.style.transform = `translateX(${-s * 110 * (1 - ci)}px)`; });
18
+ const st = progress(p, 0.28, 0.64), so = Math.sin(Math.PI * st), sw = w * 0.45;
19
+ layer(slot, 'wpStreak', `display:${so > 0.01 ? 'block' : 'none'};left:${lerp(s < 0 ? w : -sw, s < 0 ? -sw : w, ease.inOutCubic(st))}px;top:-15%;width:${sw}px;height:130%;z-index:5;opacity:${0.7 * so};`
20
+ + `background:linear-gradient(90deg,transparent,rgba(255,255,255,.85),transparent);transform:skewX(${-s * 18}deg);mix-blend-mode:screen;filter:blur(16px)`);
21
+ }
@@ -0,0 +1,14 @@
1
+ import { ease, clamp } from '../core.js';
2
+ import { size, hblur } from '../kit.js';
3
+
4
+ /** B pushes A out very fast, with horizontal motion blur that follows the speed. `dir` is where A leaves to. */
5
+ export function whip(a, b, p, { dir = 'left', blur = 70, curve = ease.inOutQuint } = {}) {
6
+ const q = curve(clamp(p)), v = (curve(clamp(p + 0.01)) - curve(clamp(p - 0.01))) / 0.02;
7
+ const [w] = size(a, b), s = dir === 'left' ? -1 : 1;
8
+ a.style.transform = `translateX(${q * w * s}px)`;
9
+ b.style.transform = `translateX(${(q - 1) * w * s}px)`;
10
+ const id = (a.parentElement._pmWhipId ??= `pm-whip-${Math.random().toString(36).slice(2, 8)}`); // one blur filter per stage, so two whips on a page don't share it
11
+ const f = v > 0.15 ? hblur(id, (blur * v) / 5) : 'none';
12
+ a.style.filter = f; b.style.filter = f;
13
+ a.style.opacity = 1; b.style.opacity = 1;
14
+ }
@@ -0,0 +1,9 @@
1
+ import { E } from '../kit.js';
2
+
3
+ /** B is uncovered by a soft edge at `angle` degrees; A stays underneath. */
4
+ export function wipe(a, b, p, { angle = 100, soft = 30, curve } = {}) {
5
+ const w = E(p, curve) * (100 + soft) - soft;
6
+ const m = `linear-gradient(${angle}deg, #000 ${w}%, transparent ${w + soft}%)`;
7
+ b.style.opacity = 1; b.style.maskImage = m; b.style.webkitMaskImage = m;
8
+ if (a) a.style.opacity = p >= 1 ? 0 : 1;
9
+ }