@impractical-ai/motion 0.1.7 → 0.1.10
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/assets/engine.d.ts +154 -23
- package/assets/impractical/SKILL.md +35 -263
- package/dist/{api-DR3NJ7TI.js → api-4WE4LYPB.js} +2 -2
- package/dist/{chunk-Z74SLKEU.js → chunk-6FVZ6IWR.js} +6 -3
- package/dist/{chunk-MQKW7NFI.js → chunk-CZANULCU.js} +1 -1
- package/dist/{chunk-M7SHFM25.js → chunk-WB7FRXAG.js} +57 -4
- package/dist/{commands-R3LJU2BW.js → commands-YJFMKK2K.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/{stdio-XUVQKDCB.js → stdio-S7VGBKNS.js} +2944 -1466
- package/package.json +3 -3
- package/assets/kits/actions.ts +0 -803
- package/assets/kits/ensemble.ts +0 -483
- package/assets/kits/highlight.ts +0 -374
- package/assets/kits/impact.ts +0 -103
- package/assets/kits/kits.ts +0 -343
- package/assets/kits/looks.ts +0 -935
- package/assets/kits/morphkit.ts +0 -438
- package/assets/kits/reveal.ts +0 -1364
- package/assets/kits/sentence.ts +0 -218
- package/assets/kits/streamkit.ts +0 -195
package/assets/kits/morphkit.ts
DELETED
|
@@ -1,438 +0,0 @@
|
|
|
1
|
-
// Universal shape morph — the shared-element architecture (how Framer
|
|
2
|
-
// Motion's layoutId / iOS matchedGeometryEffect / Flutter Hero do it):
|
|
3
|
-
// NO snapshots, NO carrier. Both REAL elements ride one interpolated box
|
|
4
|
-
// path (position + size + corner radius, with an arc lift); each is scaled
|
|
5
|
-
// to the path's box from its own natural geometry and they crossfade
|
|
6
|
-
// mid-flight, so each side is only visible while near its natural shape —
|
|
7
|
-
// distortion is never seen, and the target that lands IS the live element.
|
|
8
|
-
//
|
|
9
|
-
// Why this beats the snapshot-carrier approach it replaced:
|
|
10
|
-
// · the content that arrives is the real element — "part of the morph"
|
|
11
|
-
// by construction, not a bitmap dissolve
|
|
12
|
-
// · real box-shadows ride along (nothing goes flat mid-flight)
|
|
13
|
-
// · a spring ease rides the shared path — box, corners, and travel ring
|
|
14
|
-
// together with no delayed settle
|
|
15
|
-
// · the target's children are live and arrive individually (staggered)
|
|
16
|
-
//
|
|
17
|
-
// Determinism: geometry measured at build; per-frame updates are pure
|
|
18
|
-
// functions of one proxy tween; all end states are exact identities.
|
|
19
|
-
import { resolveTarget, layoutRect } from './kits'
|
|
20
|
-
|
|
21
|
-
type Position = number | string | undefined
|
|
22
|
-
type Rect = { left: number; top: number; width: number; height: number }
|
|
23
|
-
type Corners = { tl: number; tr: number; br: number; bl: number }
|
|
24
|
-
|
|
25
|
-
const EASE = 'ease-in-out-cubic'
|
|
26
|
-
|
|
27
|
-
function cornersOf(el: HTMLElement | null, rect: Rect): Corners {
|
|
28
|
-
if (!el) return { tl: 0, tr: 0, br: 0, bl: 0 }
|
|
29
|
-
const s = getComputedStyle(el)
|
|
30
|
-
const parse = (v: string): number => {
|
|
31
|
-
const n = parseFloat(v)
|
|
32
|
-
if (v.trim().endsWith('%')) return (n / 100) * Math.min(rect.width, rect.height)
|
|
33
|
-
return isNaN(n) ? 0 : n
|
|
34
|
-
}
|
|
35
|
-
const cap = Math.min(rect.width, rect.height) / 2
|
|
36
|
-
return {
|
|
37
|
-
tl: Math.min(cap, parse(s.borderTopLeftRadius)),
|
|
38
|
-
tr: Math.min(cap, parse(s.borderTopRightRadius)),
|
|
39
|
-
br: Math.min(cap, parse(s.borderBottomRightRadius)),
|
|
40
|
-
bl: Math.min(cap, parse(s.borderBottomLeftRadius)),
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export type MorphOpts = {
|
|
45
|
-
/** 'apple' = expo-out flight (~90% of the change in the first ~15% of the
|
|
46
|
-
* time, long soft landing). 'spring' = the reference bounce: expo-out
|
|
47
|
-
* flight, then a LIGHT damped sine rides the same path — calibrated from
|
|
48
|
-
* the dnyxstudios AE expression (amp .04·v, freq 1.8Hz, decay e^-3t,
|
|
49
|
-
* eased envelope). One motion, no delayed settle, ~one visible overshoot. */
|
|
50
|
-
feel?: 'apple' | 'spring'
|
|
51
|
-
/** Tune the 'spring' bounce. `amp` = first overshoot as a fraction of the
|
|
52
|
-
* travel (default 0.025 — light), `freq` Hz (default 1.8), `decay` per
|
|
53
|
-
* second (default 3), `flight` = fraction of `duration` spent on the main
|
|
54
|
-
* move before the bounce tail (default 0.55). */
|
|
55
|
-
bounce?: { amp?: number; freq?: number; decay?: number; flight?: number }
|
|
56
|
-
duration?: number
|
|
57
|
-
/** Flight curve — ANY GSAP ease works: house names, 'elastic.out(1, 0.4)',
|
|
58
|
-
* 'back.out(1.8)'… Overshoots extrapolate past the target (kept renderable). */
|
|
59
|
-
ease?: string
|
|
60
|
-
/** 0..1 — center of the A→B crossfade along the flight. */
|
|
61
|
-
handoff?: number
|
|
62
|
-
/** Width of the crossfade as a fraction of the flight (default 0.3 —
|
|
63
|
-
* tight; each side shows only near its natural shape). */
|
|
64
|
-
crossfade?: number
|
|
65
|
-
/** Arc lift in px (0 disables). Default: auto from travel distance. */
|
|
66
|
-
arc?: number
|
|
67
|
-
/** Part choreography (default 'stagger'): the target's direct children
|
|
68
|
-
* arrive individually inside the landing box; the source's children are
|
|
69
|
-
* absorbed as it leaves. `false` = containers only. */
|
|
70
|
-
parts?: 'stagger' | false
|
|
71
|
-
/** Sub-component matching (default 'auto'): elements tagged
|
|
72
|
-
* `data-morph="name"` on BOTH sides (any depth) morph INTO each other —
|
|
73
|
-
* each pair rides its own interpolated box inside the flight, so the
|
|
74
|
-
* source's sub-component visibly becomes the target's. Untagged direct
|
|
75
|
-
* children are greedily auto-paired by text overlap. `false` disables. */
|
|
76
|
-
match?: 'auto' | false
|
|
77
|
-
/** Landing overshoot on top of a non-spring flight. `true`/default object
|
|
78
|
-
* for the soft house settle, `{ scale, duration, ease }` to tune (elastic
|
|
79
|
-
* landings live here), `false` for none. 'spring' feel ignores this — its
|
|
80
|
-
* ring is already in the flight. */
|
|
81
|
-
settle?: boolean | { scale?: number; duration?: number; ease?: string }
|
|
82
|
-
zIndex?: number
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function createMorphKit(deps: {
|
|
86
|
-
tl: any
|
|
87
|
-
gsap: any
|
|
88
|
-
stage: HTMLElement
|
|
89
|
-
config: { width: number; height: number }
|
|
90
|
-
}) {
|
|
91
|
-
const { tl, gsap, stage, config } = deps
|
|
92
|
-
const at = (position: Position): number => (typeof position === 'number' ? position : tl.duration())
|
|
93
|
-
|
|
94
|
-
const resolveEnd = (
|
|
95
|
-
target: string | Element | Rect | 'frame'
|
|
96
|
-
): { el: HTMLElement | null; rect: Rect } => {
|
|
97
|
-
if (target === 'frame') {
|
|
98
|
-
return { el: null, rect: { left: 0, top: 0, width: config.width, height: config.height } }
|
|
99
|
-
}
|
|
100
|
-
if (typeof target === 'object' && target !== null && !(target instanceof Element)) {
|
|
101
|
-
return { el: null, rect: target as Rect }
|
|
102
|
-
}
|
|
103
|
-
const el = resolveTarget(stage, target as string | Element, 'morph')
|
|
104
|
-
return { el, rect: layoutRect(stage, el) }
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// KNOWN LIMIT: morph and expand measure at build time via layoutRect. Inside
|
|
108
|
-
// a container the direction scales mid-timeline the flight path lands off —
|
|
109
|
-
// do the container zoom with camera.move (stage-level, measured out), or
|
|
110
|
-
// morph before/after the zoom. actions and highlight resolve lazily; morph's
|
|
111
|
-
// corner interpolation bakes its geometry and has not been converted.
|
|
112
|
-
const morph = (
|
|
113
|
-
from: string | Element,
|
|
114
|
-
to: string | Element | Rect | 'frame',
|
|
115
|
-
opts: MorphOpts = {},
|
|
116
|
-
position?: Position
|
|
117
|
-
): { duration: number } => {
|
|
118
|
-
const aEl = resolveTarget(stage, from, 'morph')
|
|
119
|
-
const aRect = layoutRect(stage, aEl)
|
|
120
|
-
const bEnd = resolveEnd(to)
|
|
121
|
-
const bEl = bEnd.el
|
|
122
|
-
const bRect = bEnd.rect
|
|
123
|
-
const apple = opts.feel === 'apple'
|
|
124
|
-
const springy = opts.feel === 'spring'
|
|
125
|
-
const duration = opts.duration ?? (apple ? 0.6 : springy ? 1.15 : 0.7)
|
|
126
|
-
const handoff = Math.max(0.1, Math.min(0.9, opts.handoff ?? 0.5))
|
|
127
|
-
const cross = Math.max(0.08, Math.min(0.8, opts.crossfade ?? 0.3))
|
|
128
|
-
const start = at(position)
|
|
129
|
-
const travel = Math.hypot(bRect.left - aRect.left, bRect.top - aRect.top)
|
|
130
|
-
const arc = opts.arc ?? (apple || springy ? Math.min(24, travel * 0.05) : Math.min(40, travel * 0.08))
|
|
131
|
-
const ca = cornersOf(aEl, aRect)
|
|
132
|
-
const cb = cornersOf(bEl, bRect)
|
|
133
|
-
let eased: (p: number) => number
|
|
134
|
-
if (springy) {
|
|
135
|
-
// The reference bounce (dnyxstudios AE expression): main move, then
|
|
136
|
-
// value + v·amp·sin(2π·freq·t)/e^(decay·t)·easeOut-envelope. Ported
|
|
137
|
-
// with amp normalized to "first overshoot as a fraction of travel".
|
|
138
|
-
const bo = opts.bounce ?? {}
|
|
139
|
-
const amp = bo.amp ?? 0.025
|
|
140
|
-
const freq = bo.freq ?? 1.8
|
|
141
|
-
const decay = bo.decay ?? 3
|
|
142
|
-
const flight = Math.max(0.2, Math.min(0.9, bo.flight ?? 0.55))
|
|
143
|
-
// power3.out, not expo.out: expo parks motionless for the last ~30% of
|
|
144
|
-
// the flight, and a bounce after a stall reads as LAG. The wobble also
|
|
145
|
-
// OVERLAPS the flight's tail so the arrival momentum flows straight
|
|
146
|
-
// into the first overshoot — motion never stops.
|
|
147
|
-
const base = gsap.parseEase(opts.ease ?? 'power3.out')
|
|
148
|
-
// Normalize so the first sine peak equals `amp` exactly.
|
|
149
|
-
const tPeak = 1 / (4 * freq)
|
|
150
|
-
const peak = Math.sin(2 * Math.PI * freq * tPeak) * Math.exp(-decay * tPeak)
|
|
151
|
-
const scale = amp / Math.max(1e-6, peak)
|
|
152
|
-
const Dm = duration * flight
|
|
153
|
-
const overlap = Math.min(0.12, Dm * 0.25)
|
|
154
|
-
eased = (p: number) => {
|
|
155
|
-
const tAbs = p * duration
|
|
156
|
-
let v = base(Math.min(1, tAbs / Math.max(1e-6, Dm)))
|
|
157
|
-
const t = tAbs - (Dm - overlap)
|
|
158
|
-
if (t > 0) {
|
|
159
|
-
const envelope = Math.max(0, 1 - t / 3)
|
|
160
|
-
v += scale * Math.sin(2 * Math.PI * freq * t) * Math.exp(-decay * t) * envelope
|
|
161
|
-
}
|
|
162
|
-
return v
|
|
163
|
-
}
|
|
164
|
-
} else {
|
|
165
|
-
eased = gsap.parseEase(opts.ease ?? (apple ? 'expo.out' : EASE))
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Both elements need transform-from-origin-0 semantics for the box math.
|
|
169
|
-
gsap.set(aEl, { transformOrigin: '0 0' })
|
|
170
|
-
if (bEl) gsap.set(bEl, { transformOrigin: '0 0' })
|
|
171
|
-
// Authors set radius INLINE (React styles) — clearProps would delete it,
|
|
172
|
-
// so capture the original declarations to restore at the end.
|
|
173
|
-
const aRadius0 = aEl.style.borderRadius
|
|
174
|
-
const bRadius0 = bEl ? bEl.style.borderRadius : ''
|
|
175
|
-
|
|
176
|
-
// ── Sub-component matching: pairs ride their own box inside the flight ──
|
|
177
|
-
const wrapLooseText = (el: HTMLElement | null) => {
|
|
178
|
-
if (!el) return
|
|
179
|
-
for (const node of Array.from(el.childNodes)) {
|
|
180
|
-
if (node.nodeType === 3 && node.textContent && node.textContent.trim()) {
|
|
181
|
-
const span = document.createElement('span')
|
|
182
|
-
span.style.display = 'inline-block'
|
|
183
|
-
node.parentNode?.insertBefore(span, node)
|
|
184
|
-
span.appendChild(node)
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
wrapLooseText(aEl)
|
|
189
|
-
wrapLooseText(bEl)
|
|
190
|
-
type Pair = { a: HTMLElement; b: HTMLElement; ar: Rect; br: Rect }
|
|
191
|
-
const pairs: Pair[] = []
|
|
192
|
-
if ((opts.match ?? 'auto') !== false && bEl) {
|
|
193
|
-
// Explicit: data-morph names present on both sides, any depth.
|
|
194
|
-
const named = (root: HTMLElement) => {
|
|
195
|
-
const map = new Map<string, HTMLElement>()
|
|
196
|
-
for (const el of Array.from(root.querySelectorAll<HTMLElement>('[data-morph]'))) {
|
|
197
|
-
const name = el.dataset.morph
|
|
198
|
-
if (name && !map.has(name)) map.set(name, el)
|
|
199
|
-
}
|
|
200
|
-
return map
|
|
201
|
-
}
|
|
202
|
-
const aNamed = named(aEl)
|
|
203
|
-
const bNamed = named(bEl)
|
|
204
|
-
for (const [name, a] of aNamed) {
|
|
205
|
-
const b = bNamed.get(name)
|
|
206
|
-
if (b) pairs.push({ a, b, ar: layoutRect(stage, a), br: layoutRect(stage, b) })
|
|
207
|
-
}
|
|
208
|
-
// Greedy auto-match of untagged DIRECT children by text-token overlap.
|
|
209
|
-
const tokens = (el: HTMLElement) =>
|
|
210
|
-
new Set(
|
|
211
|
-
(el.textContent ?? '')
|
|
212
|
-
.toLowerCase()
|
|
213
|
-
.replace(/[^a-z0-9\s]/g, ' ')
|
|
214
|
-
.split(/\s+/)
|
|
215
|
-
.filter((w) => w.length > 1)
|
|
216
|
-
)
|
|
217
|
-
const taken = new Set(pairs.flatMap((p) => [p.a, p.b]))
|
|
218
|
-
const free = (el: HTMLElement) =>
|
|
219
|
-
!taken.has(el) && !el.querySelector('[data-morph]') && !el.dataset.morph
|
|
220
|
-
const aFree = (Array.from(aEl.children) as HTMLElement[]).filter(free)
|
|
221
|
-
const bFree = bEl ? (Array.from(bEl.children) as HTMLElement[]).filter(free) : []
|
|
222
|
-
for (const a of aFree) {
|
|
223
|
-
const ta = tokens(a)
|
|
224
|
-
if (!ta.size) continue
|
|
225
|
-
let best: HTMLElement | null = null
|
|
226
|
-
let bestScore = 0
|
|
227
|
-
for (const b of bFree) {
|
|
228
|
-
if (taken.has(b)) continue
|
|
229
|
-
let s = 0
|
|
230
|
-
for (const w of tokens(b)) if (ta.has(w)) s += 1
|
|
231
|
-
if (b.tagName === a.tagName) s += 0.25
|
|
232
|
-
if (s > bestScore) {
|
|
233
|
-
bestScore = s
|
|
234
|
-
best = b
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
if (best && bestScore >= 1) {
|
|
238
|
-
taken.add(a)
|
|
239
|
-
taken.add(best)
|
|
240
|
-
pairs.push({ a, b: best, ar: layoutRect(stage, a), br: layoutRect(stage, best) })
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
// An element that is (or contains) a matched pair must not be absorbed
|
|
245
|
-
// or arrival-staggered — the pair choreography owns it.
|
|
246
|
-
const ownedByPair = (el: HTMLElement) =>
|
|
247
|
-
pairs.some((p) => el === p.a || el === p.b || el.contains(p.a) || el.contains(p.b))
|
|
248
|
-
|
|
249
|
-
// ── The shared box path — one pure function both elements ride ──
|
|
250
|
-
const proxy = { p: 0 }
|
|
251
|
-
const pose = (p: number) => {
|
|
252
|
-
const e = eased(p)
|
|
253
|
-
const w = Math.max(2, aRect.width + (bRect.width - aRect.width) * e)
|
|
254
|
-
const h = Math.max(2, aRect.height + (bRect.height - aRect.height) * e)
|
|
255
|
-
const x = aRect.left + (bRect.left - aRect.left) * e
|
|
256
|
-
const y =
|
|
257
|
-
aRect.top +
|
|
258
|
-
(bRect.top - aRect.top) * e -
|
|
259
|
-
Math.sin(Math.PI * Math.min(1, Math.max(0, e))) * arc
|
|
260
|
-
const cap = Math.min(w, h) / 2
|
|
261
|
-
const cl = (v: number) => Math.max(0, Math.min(cap, v))
|
|
262
|
-
const radius = cl((ca.tl + ca.tr + ca.br + ca.bl) / 4 + ((cb.tl + cb.tr + cb.br + cb.bl) / 4 - (ca.tl + ca.tr + ca.br + ca.bl) / 4) * e)
|
|
263
|
-
// Two-axis radius compensation: under non-uniform scale a single
|
|
264
|
-
// radius renders elliptical, so counter each axis separately.
|
|
265
|
-
const sxA0 = w / Math.max(1, aRect.width)
|
|
266
|
-
const syA0 = h / Math.max(1, aRect.height)
|
|
267
|
-
gsap.set(aEl, {
|
|
268
|
-
x: x - aRect.left,
|
|
269
|
-
y: y - aRect.top,
|
|
270
|
-
scaleX: sxA0,
|
|
271
|
-
scaleY: syA0,
|
|
272
|
-
borderRadius: `${radius / Math.max(0.01, sxA0)}px / ${radius / Math.max(0.01, syA0)}px`,
|
|
273
|
-
force3D: false,
|
|
274
|
-
})
|
|
275
|
-
if (bEl) {
|
|
276
|
-
const sxB0 = w / Math.max(1, bRect.width)
|
|
277
|
-
const syB0 = h / Math.max(1, bRect.height)
|
|
278
|
-
gsap.set(bEl, {
|
|
279
|
-
x: x - bRect.left,
|
|
280
|
-
y: y - bRect.top,
|
|
281
|
-
scaleX: sxB0,
|
|
282
|
-
scaleY: syB0,
|
|
283
|
-
borderRadius: `${radius / Math.max(0.01, sxB0)}px / ${radius / Math.max(0.01, syB0)}px`,
|
|
284
|
-
force3D: false,
|
|
285
|
-
})
|
|
286
|
-
}
|
|
287
|
-
// Matched pairs: counter-transform each side inside its parent so both
|
|
288
|
-
// occupy the SAME interpolated scene box — during the crossfade they
|
|
289
|
-
// are geometrically aligned, and one sub-component becomes the other.
|
|
290
|
-
if (pairs.length) {
|
|
291
|
-
const arcTerm = Math.sin(Math.PI * Math.min(1, Math.max(0, e))) * arc
|
|
292
|
-
const sxA = w / Math.max(1, aRect.width)
|
|
293
|
-
const syA = h / Math.max(1, aRect.height)
|
|
294
|
-
const sxB = w / Math.max(1, bRect.width)
|
|
295
|
-
const syB = h / Math.max(1, bRect.height)
|
|
296
|
-
for (const pr of pairs) {
|
|
297
|
-
const cw = pr.ar.width + (pr.br.width - pr.ar.width) * e
|
|
298
|
-
const chh = pr.ar.height + (pr.br.height - pr.ar.height) * e
|
|
299
|
-
const px = pr.ar.left + (pr.br.left - pr.ar.left) * e
|
|
300
|
-
const py = pr.ar.top + (pr.br.top - pr.ar.top) * e - arcTerm
|
|
301
|
-
gsap.set(pr.a, {
|
|
302
|
-
transformOrigin: '0 0',
|
|
303
|
-
x: (px - x) / sxA - (pr.ar.left - aRect.left),
|
|
304
|
-
y: (py - y) / syA - (pr.ar.top - aRect.top),
|
|
305
|
-
scaleX: cw / Math.max(0.01, sxA * pr.ar.width),
|
|
306
|
-
scaleY: chh / Math.max(0.01, syA * pr.ar.height),
|
|
307
|
-
force3D: false,
|
|
308
|
-
})
|
|
309
|
-
gsap.set(pr.b, {
|
|
310
|
-
transformOrigin: '0 0',
|
|
311
|
-
x: (px - x) / sxB - (pr.br.left - bRect.left),
|
|
312
|
-
y: (py - y) / syB - (pr.br.top - bRect.top),
|
|
313
|
-
scaleX: cw / Math.max(0.01, sxB * pr.br.width),
|
|
314
|
-
scaleY: chh / Math.max(0.01, syB * pr.br.height),
|
|
315
|
-
force3D: false,
|
|
316
|
-
})
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// ── Crossfade: each side is visible only near its natural shape ──
|
|
322
|
-
const fadeStart = start + duration * (handoff - cross / 2)
|
|
323
|
-
const fadeSpan = duration * cross
|
|
324
|
-
if (bEl) gsap.set(bEl, { autoAlpha: 0 })
|
|
325
|
-
tl.to(aEl, { autoAlpha: 0, duration: fadeSpan * 0.6, ease: 'ease-in-quad' }, fadeStart + fadeSpan * 0.2)
|
|
326
|
-
if (bEl) {
|
|
327
|
-
tl.to(bEl, { autoAlpha: 1, duration: fadeSpan * 0.7, ease: 'ease-out-quad' }, fadeStart)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ── Parts: the target's children ARRIVE as live elements; the source's
|
|
331
|
-
// are absorbed as it leaves. All end states are exact identities. ──
|
|
332
|
-
if ((opts.parts ?? 'stagger') !== false) {
|
|
333
|
-
const kids = (el: HTMLElement | null): HTMLElement[] =>
|
|
334
|
-
el ? (Array.from(el.children) as HTMLElement[]).filter((k) => !ownedByPair(k)) : []
|
|
335
|
-
const absorbSpan = Math.max(0.15, duration * handoff * 0.7)
|
|
336
|
-
for (const [i, kid] of kids(aEl).entries()) {
|
|
337
|
-
tl.to(
|
|
338
|
-
kid,
|
|
339
|
-
{ scale: 0.88, y: 8, autoAlpha: 0, duration: absorbSpan, ease: 'ease-in-quad', force3D: false },
|
|
340
|
-
start + i * 0.05
|
|
341
|
-
)
|
|
342
|
-
// The source ends hidden; restore its children for a clean baseline.
|
|
343
|
-
tl.set(kid, { scale: 1, y: 0, autoAlpha: 1 }, start + duration)
|
|
344
|
-
}
|
|
345
|
-
for (const [i, kid] of kids(bEl).entries()) {
|
|
346
|
-
gsap.set(kid, { autoAlpha: 0, y: 12, scale: 0.94 })
|
|
347
|
-
tl.to(
|
|
348
|
-
kid,
|
|
349
|
-
{ autoAlpha: 1, y: 0, scale: 1, duration: 0.36, ease: 'promo-out', force3D: false },
|
|
350
|
-
start + duration * handoff + i * 0.07
|
|
351
|
-
)
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// ── The flight ──
|
|
356
|
-
tl.to(proxy, { p: 1, duration, ease: 'none', onUpdate: () => pose(proxy.p) }, start)
|
|
357
|
-
// Exact end states: A hidden and reset, B at true identity.
|
|
358
|
-
// Restore the author's inline radius; if it lived in a stylesheet,
|
|
359
|
-
// clearing the inline override reveals it again.
|
|
360
|
-
tl.set(
|
|
361
|
-
aEl,
|
|
362
|
-
{ autoAlpha: 0, x: 0, y: 0, scaleX: 1, scaleY: 1, ...(aRadius0 ? { borderRadius: aRadius0 } : { clearProps: 'borderRadius' }) },
|
|
363
|
-
start + duration
|
|
364
|
-
)
|
|
365
|
-
if (bEl) {
|
|
366
|
-
tl.set(
|
|
367
|
-
bEl,
|
|
368
|
-
{ x: 0, y: 0, scaleX: 1, scaleY: 1, ...(bRadius0 ? { borderRadius: bRadius0 } : { clearProps: 'borderRadius' }) },
|
|
369
|
-
start + duration
|
|
370
|
-
)
|
|
371
|
-
}
|
|
372
|
-
for (const pr of pairs) {
|
|
373
|
-
tl.set(pr.a, { x: 0, y: 0, scaleX: 1, scaleY: 1 }, start + duration)
|
|
374
|
-
tl.set(pr.b, { x: 0, y: 0, scaleX: 1, scaleY: 1 }, start + duration)
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ── Optional landing settle (spring feel needs none — it rang in-flight) ──
|
|
378
|
-
const settle = opts.settle === undefined ? !springy : opts.settle !== false
|
|
379
|
-
if (settle && bEl) {
|
|
380
|
-
const s = typeof opts.settle === 'object' ? opts.settle : {}
|
|
381
|
-
tl.fromTo(
|
|
382
|
-
bEl,
|
|
383
|
-
{ scale: s.scale ?? (apple ? 1.03 : 1.05), transformOrigin: '50% 50%' },
|
|
384
|
-
{
|
|
385
|
-
scale: 1,
|
|
386
|
-
duration: s.duration ?? (apple ? 0.4 : 0.26),
|
|
387
|
-
ease: s.ease ?? 'promo-soft',
|
|
388
|
-
force3D: false,
|
|
389
|
-
immediateRender: false,
|
|
390
|
-
},
|
|
391
|
-
start + duration + 0.001
|
|
392
|
-
)
|
|
393
|
-
}
|
|
394
|
-
// Anticipation: the source contracts just before leaving.
|
|
395
|
-
if (opts.settle !== false && start >= 0.14) {
|
|
396
|
-
tl.to(aEl, { scale: 0.985, duration: 0.14, ease: 'ease-in-quad', force3D: false }, start - 0.14)
|
|
397
|
-
tl.set(aEl, { scale: 1 }, start)
|
|
398
|
-
}
|
|
399
|
-
return { duration }
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** The chip⇄full-bleed round trip. Meant for MEDIA (a thumbnail, a video) —
|
|
403
|
-
* the element persists and fills the frame; 'cover' bleeds on aspect
|
|
404
|
-
* mismatch, 'contain' letterboxes. Expanding a text-heavy card reads as a
|
|
405
|
-
* mistake — expand its media child. */
|
|
406
|
-
const expand = (
|
|
407
|
-
target: string | Element,
|
|
408
|
-
opts: { duration?: number; hold?: number; back?: boolean; inset?: number; fit?: 'cover' | 'contain' } = {},
|
|
409
|
-
position?: Position
|
|
410
|
-
): { duration: number } => {
|
|
411
|
-
const el = resolveTarget(stage, target, 'expand')
|
|
412
|
-
const rect = layoutRect(stage, el)
|
|
413
|
-
const inset = opts.inset ?? 0
|
|
414
|
-
const frame: Rect = {
|
|
415
|
-
left: inset,
|
|
416
|
-
top: inset,
|
|
417
|
-
width: config.width - inset * 2,
|
|
418
|
-
height: config.height - inset * 2,
|
|
419
|
-
}
|
|
420
|
-
const duration = opts.duration ?? 0.7
|
|
421
|
-
const hold = opts.hold ?? 1.2
|
|
422
|
-
const start = at(position)
|
|
423
|
-
const scale = (opts.fit === 'contain' ? Math.min : Math.max)(
|
|
424
|
-
frame.width / rect.width,
|
|
425
|
-
frame.height / rect.height
|
|
426
|
-
)
|
|
427
|
-
const cx = rect.left + rect.width / 2
|
|
428
|
-
const cy = rect.top + rect.height / 2
|
|
429
|
-
const dx = frame.left + frame.width / 2 - cx
|
|
430
|
-
const dy = frame.top + frame.height / 2 - cy
|
|
431
|
-
tl.to(el, { x: dx, y: dy, scale, duration, ease: EASE, force3D: false }, start)
|
|
432
|
-
if (opts.back === false) return { duration: duration + hold }
|
|
433
|
-
tl.to(el, { x: 0, y: 0, scale: 1, duration: duration * 0.85, ease: EASE, force3D: false }, start + duration + hold)
|
|
434
|
-
return { duration: duration + hold + duration * 0.85 }
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
return { morph, expand }
|
|
438
|
-
}
|