@impractical-ai/motion 0.1.5 → 0.1.7

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.
@@ -0,0 +1,935 @@
1
+ // ─── Looks — scene lighting, shading, ambient backdrops, and the hero shot ──
2
+ //
3
+ // Three families that share one structural idea: consistency comes from SCENE
4
+ // STATE, not per-element decoration.
5
+ //
6
+ // LIGHT. `light.set({ angle })` declares THE scene light once; every sweep,
7
+ // rim, and cast shadow derives its direction from it. Mismatched shadow
8
+ // directions are the amateur tell — with a single source of truth they cannot
9
+ // happen.
10
+ //
11
+ // BACKDROP. Named ambient systems (`gradient-drift`, `orbs`, `dot-grid`) with
12
+ // an ENERGY BUDGET: presets are tuned so a backdrop alone stays alive but
13
+ // sub-salient on the motion meter (~0.5–2% pixel change per interval — large
14
+ // soft forms, slow velocity, capped contrast). `calm()`/`lift()` duck the
15
+ // background under dense foreground beats, like audio ducking for pixels.
16
+ // The backdrop mounts BEHIND the scene root, so the scene's own background
17
+ // must be transparent (or partially so) where the backdrop should show.
18
+ //
19
+ // HERO. The signature product-shot move: `hero(target, { anchor:
20
+ // 'bottom-right' })` pushes the camera toward one corner of a panel WHILE the
21
+ // panel tilts a few degrees in perspective, a sheen sweeps across the surface
22
+ // as the angle changes, and the shadow deepens — zoom, angle, and light
23
+ // responding together in one round-trip call. `tilt` is the standalone
24
+ // perspective pose. Perspective is applied to the TARGET only, never the
25
+ // stage: promoting the whole scaled stage to a 3D layer blows Chromium's
26
+ // texture budget (see camera.set).
27
+ //
28
+ // Deterministic throughout: drifting backdrops are infinite yoyo tweens (pure
29
+ // functions of timeline position), nothing reads a clock, nothing random.
30
+
31
+ import gsap from 'gsap'
32
+ import { layoutRect, resolveTarget } from './kits'
33
+
34
+ export type LooksDeps = {
35
+ tl: gsap.core.Timeline
36
+ stage: HTMLElement
37
+ config: { width: number; height: number }
38
+ /** Register work to run after the direction body completes (buildScene's
39
+ * pending queue). Backdrop drift is added HERE, sized to the finished
40
+ * timeline: an infinite repeat on the master timeline would explode
41
+ * tl.duration() to gsap's infinity sentinel and the export would never
42
+ * end. */
43
+ defer?: (work: Promise<unknown>) => void
44
+ }
45
+
46
+ // ─── light ────────────────────────────────────────────────────────────────
47
+
48
+ export type LightState = {
49
+ /** Direction the light COMES FROM, degrees clockwise from 12 o'clock.
50
+ * 135 = upper-left key (the portrait default). */
51
+ angle: number
52
+ /** 0 cool … 1 warm. Tints sweeps and rims. */
53
+ warmth: number
54
+ }
55
+
56
+ export type LightKit = {
57
+ /** Declare the scene light. Call once, early; sweeps, rims, and cast
58
+ * shadows derive their direction from it. */
59
+ set: (state: Partial<LightState>) => void
60
+ /** A glint sweeps across the element, angled with the scene light — the
61
+ * `logo.shine` move generalized to any surface. */
62
+ sweep: (
63
+ target: string | Element,
64
+ opts?: { duration?: number; intensity?: number; width?: number; ease?: string },
65
+ position?: gsap.Position
66
+ ) => { duration: number }
67
+ /** Edge light on the side facing the scene light — the premium product-
68
+ * shot rim. Fades in and holds. */
69
+ rim: (
70
+ target: string | Element,
71
+ opts?: { color?: string; intensity?: number; duration?: number },
72
+ position?: gsap.Position
73
+ ) => { duration: number }
74
+ }
75
+
76
+ export type ShadeKit = {
77
+ /** Soft shadow under the element, offset along the scene light. Replaces
78
+ * hand-tuned boxShadow strings and always agrees with every other shadow. */
79
+ cast: (
80
+ target: string | Element,
81
+ opts?: { distance?: number; softness?: number; opacity?: number; duration?: number },
82
+ position?: gsap.Position
83
+ ) => { duration: number }
84
+ /** Frame vignette, faded in once and held (like the grain plate — never
85
+ * cut, so it doesn't blink at hard cuts). */
86
+ vignette: (
87
+ opts?: { strength?: number; duration?: number },
88
+ position?: gsap.Position
89
+ ) => { duration: number }
90
+ /** Named color grade on the scene content. 'warm-product' | 'cool-saas' |
91
+ * 'noir'. Applied to the scene root, so overlays (cursors, highlights)
92
+ * stay ungraded and legible. */
93
+ grade: (
94
+ preset: 'warm-product' | 'cool-saas' | 'noir',
95
+ opts?: { duration?: number },
96
+ position?: gsap.Position
97
+ ) => { duration: number }
98
+ }
99
+
100
+ // ─── backdrop ─────────────────────────────────────────────────────────────
101
+
102
+ export type BackdropStyle =
103
+ | 'gradient-drift'
104
+ | 'orbs'
105
+ | 'dot-grid'
106
+ | 'paper'
107
+ // Designed grounds mined from web heroes (references/cards/web-hero-grounds.md):
108
+ | 'aurora' // Stripe — mesh gradient ribbons on a diagonal, drifting slowly
109
+ | 'threads' // Mintlify — fanned fine line strands sweeping the corners
110
+ | 'dot-matrix' // AgentMail — shimmering dot field, radial falloff off content
111
+ | 'grid-glow' // dev-tool grid with a traveling soft glow pulse
112
+
113
+ export type BackdropOpts = {
114
+ /** 0..1 — scales amplitude, speed, and opacity together. The default 0.3
115
+ * keeps the preset inside the sub-salient budget. */
116
+ energy?: number
117
+ /** Colors for the soft forms. Keep them near the scene's background
118
+ * luminance — the preset caps opacity, but hue contrast is yours. */
119
+ palette?: string[]
120
+ /** Base fill the backdrop paints under its forms. The backdrop mounts
121
+ * BEHIND the scene root, so a root that goes transparent needs the
122
+ * backdrop to own the ground color. */
123
+ base?: string
124
+ /** FIXED BACKPLATE (default true): mount outside the camera-carrying
125
+ * stage, so zooms and pans move the UI plane while the ground stays put —
126
+ * the studio-cyclorama behavior of reference launch films. Set false to
127
+ * make the backdrop part of the world the camera moves through. */
128
+ fixed?: boolean
129
+ }
130
+
131
+ export type BackdropHandle = {
132
+ el: HTMLElement
133
+ /** Duck the backdrop under a dense foreground beat. */
134
+ calm: (position?: gsap.Position) => void
135
+ /** Bring it back up during a hold. */
136
+ lift: (position?: gsap.Position) => void
137
+ }
138
+
139
+ // ─── hero ─────────────────────────────────────────────────────────────────
140
+
141
+ export type HeroAnchor =
142
+ | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'
143
+ | { x: number; y: number }
144
+
145
+ export type HeroOpts = {
146
+ /** Which region of the target the camera pushes toward. Default
147
+ * 'bottom-right' — the submit-button corner. */
148
+ anchor?: HeroAnchor
149
+ /** How far in: scale ≈ (frame width / target width) × zoom. Default 1.35 —
150
+ * the target overflows the frame, close-up territory. */
151
+ zoom?: number
152
+ /** Perspective tilt in degrees. Defaults derive from the anchor (the panel
153
+ * leans its far corner away): e.g. bottom-right → pitch 7, yaw -9. */
154
+ pitch?: number
155
+ yaw?: number
156
+ /** Push-in time. Default 0.9. */
157
+ duration?: number
158
+ /** Seconds held at the angle before returning. Default 1.2. */
159
+ hold?: number
160
+ /** Return time. Default 0.8; `settle: false` stays in the pose. */
161
+ returnDuration?: number
162
+ settle?: boolean
163
+ /** Sheen sweep across the surface during the push. Default true. */
164
+ sheen?: boolean
165
+ /** Shadow deepens as the panel lifts. Default true. */
166
+ shadow?: boolean
167
+ ease?: string
168
+ }
169
+
170
+ export type LooksKit = {
171
+ light: LightKit
172
+ shade: ShadeKit
173
+ /** Mount an ambient animated backdrop behind the scene root. */
174
+ backdrop: (style: BackdropStyle, opts?: BackdropOpts) => BackdropHandle
175
+ /** The angle-zoom product shot: camera pushes toward one corner of the
176
+ * target while it tilts in perspective, sheen and shadow responding.
177
+ * A full round trip by default. */
178
+ hero: (target: string | Element, opts?: HeroOpts, position?: gsap.Position) => { duration: number }
179
+ /** Standalone perspective pose: tilt an element (not the stage) and hold.
180
+ * Tilt back with pitch/yaw 0. */
181
+ tilt: (
182
+ target: string | Element,
183
+ opts?: { pitch?: number; yaw?: number; duration?: number; ease?: string; perspective?: number },
184
+ position?: gsap.Position
185
+ ) => { duration: number }
186
+ /** Camera FOLLOWS an element while it moves: per-frame the tracked
187
+ * element's live position re-centers the camera at a locked occupancy.
188
+ * Seek-deterministic — each frame is a pure function of timeline position
189
+ * (the catch-up from `from` is an eased blend, not stateful smoothing).
190
+ * AUTHOR IT AFTER the motion it follows, so it renders after the movers. */
191
+ track: (
192
+ target: string | Element,
193
+ opts?: {
194
+ /** Tracking window in seconds. Default 2. */
195
+ duration?: number
196
+ /** Target occupancy as fraction of frame height. Default 0.5. */
197
+ height?: number
198
+ /** Fraction of the window spent easing from `from` onto the target.
199
+ * Default 0.25. */
200
+ attach?: number
201
+ /** Camera pose the track blends FROM (what the camera shows when the
202
+ * window opens). Default full frame. */
203
+ from?: { scale: number; x: number; y: number }
204
+ /** After the window: 'hold' keeps the last framing (a later camera call
205
+ * takes over); 'full' returns to the full frame. Default 'full'. */
206
+ release?: 'hold' | 'full'
207
+ },
208
+ position?: gsap.Position
209
+ ) => { duration: number }
210
+ }
211
+
212
+ const EASE = 'ease-in-out-cubic'
213
+
214
+ /** Light-direction unit vector: where shadows FALL (opposite the source). */
215
+ export function lightVector(angleDeg: number): { x: number; y: number } {
216
+ const rad = ((angleDeg + 180) * Math.PI) / 180
217
+ return { x: Math.sin(rad), y: -Math.cos(rad) }
218
+ }
219
+
220
+ /** Default tilt for a hero anchor: the panel leans so the anchored corner
221
+ * comes toward the camera. */
222
+ export function heroTiltFor(anchor: HeroAnchor): { pitch: number; yaw: number } {
223
+ if (typeof anchor === 'object') {
224
+ return { pitch: (anchor.y - 0.5) * 14, yaw: (0.5 - anchor.x) * 18 }
225
+ }
226
+ switch (anchor) {
227
+ case 'top-left': return { pitch: -7, yaw: 9 }
228
+ case 'top-right': return { pitch: -7, yaw: -9 }
229
+ case 'bottom-left': return { pitch: 7, yaw: 9 }
230
+ case 'bottom-right': return { pitch: 7, yaw: -9 }
231
+ default: return { pitch: 0, yaw: 0 }
232
+ }
233
+ }
234
+
235
+ export function anchorFraction(anchor: HeroAnchor): { x: number; y: number } {
236
+ if (typeof anchor === 'object') return anchor
237
+ switch (anchor) {
238
+ case 'top-left': return { x: 0.2, y: 0.22 }
239
+ case 'top-right': return { x: 0.8, y: 0.22 }
240
+ case 'bottom-left': return { x: 0.2, y: 0.78 }
241
+ case 'bottom-right': return { x: 0.8, y: 0.78 }
242
+ default: return { x: 0.5, y: 0.5 }
243
+ }
244
+ }
245
+
246
+ /** Alpha of a computed background-color ('rgba(…)', 'rgb(…)', 'transparent'). */
247
+ function bgAlpha(color: string): number {
248
+ const match = color.match(/rgba?\(([^)]+)\)/)
249
+ if (!match) return color === 'transparent' ? 0 : 1
250
+ const parts = match[1].split(',')
251
+ return parts.length === 4 ? parseFloat(parts[3]) : 1
252
+ }
253
+
254
+ /** A "surface" is something light can land on: an element that paints its own
255
+ * background. Light only exists ON material — a sweep over a transparent
256
+ * container must distribute to its surfaces, never hover over the gaps. */
257
+ function isSurface(el: HTMLElement): boolean {
258
+ const style = getComputedStyle(el)
259
+ return bgAlpha(style.backgroundColor) > 0.03 || style.backgroundImage !== 'none'
260
+ }
261
+
262
+ /** Top-most surfaces under `root` (children of a collected surface are lit by
263
+ * their parent's sweep). The root itself counts when it paints a background. */
264
+ function collectSurfaces(root: HTMLElement): HTMLElement[] {
265
+ if (isSurface(root)) return [root]
266
+ const out: HTMLElement[] = []
267
+ const walk = (node: HTMLElement) => {
268
+ for (const child of Array.from(node.children) as HTMLElement[]) {
269
+ if (child instanceof HTMLElement) {
270
+ if (isSurface(child)) out.push(child)
271
+ else walk(child)
272
+ }
273
+ }
274
+ }
275
+ walk(root)
276
+ return out
277
+ }
278
+
279
+ const GRADES: Record<string, string> = {
280
+ 'warm-product': 'saturate(1.06) contrast(1.04) sepia(0.07)',
281
+ 'cool-saas': 'saturate(1.05) contrast(1.03) hue-rotate(-6deg)',
282
+ noir: 'grayscale(0.92) contrast(1.16) brightness(0.98)',
283
+ }
284
+
285
+ let backdropSeq = 0
286
+
287
+ export function createLooksKit(deps: LooksDeps): LooksKit {
288
+ const { tl, stage, config } = deps
289
+ const lightState: LightState = { angle: 135, warmth: 0.35 }
290
+
291
+ const at = (position: gsap.Position | undefined): number =>
292
+ typeof position === 'number' ? position : tl.duration()
293
+
294
+ const rectFor = (target: string | Element, kind: string) => {
295
+ const el = resolveTarget(stage, target, kind)
296
+ return { el, rect: layoutRect(stage, el) }
297
+ }
298
+
299
+ const light: LightKit = {
300
+ set: (state) => {
301
+ Object.assign(lightState, state)
302
+ },
303
+
304
+ sweep: (target, opts = {}, position) => {
305
+ const { el, rect: rootRect } = rectFor(target, 'light.sweep')
306
+ const duration = opts.duration ?? 0.9
307
+ const intensity = opts.intensity ?? 0.28
308
+ const warmTint = lightState.warmth > 0.5 ? '255,246,230' : '255,255,255'
309
+ const start = at(position)
310
+
311
+ // A TRAVELING POINT LIGHT, not a gradient stripe. One light position
312
+ // moves through root space along the scene-light direction; every
313
+ // surface is lit by a radial pool centered at the light's projected
314
+ // position, falling off with distance. Intensity ramps in and out with
315
+ // a smooth envelope — no visibility toggles, so nothing pops.
316
+ const v = lightVector(lightState.angle)
317
+ const radius = opts.width
318
+ ? Math.max(rootRect.width, rootRect.height) * opts.width * 2.4
319
+ : Math.max(rootRect.width, rootRect.height) * 0.85
320
+ const cx0 = rootRect.width / 2
321
+ const cy0 = rootRect.height / 2
322
+ const halfSpan = Math.hypot(rootRect.width, rootRect.height) / 2 + radius * 0.4
323
+ const fromPt = { x: cx0 - v.x * halfSpan, y: cy0 - v.y * halfSpan }
324
+ const toPt = { x: cx0 + v.x * halfSpan, y: cy0 + v.y * halfSpan }
325
+ const envelope = (p: number) => {
326
+ const up = Math.min(1, p / 0.18)
327
+ const down = Math.min(1, (1 - p) / 0.18)
328
+ return up * up * (3 - 2 * up) * (down * down * (3 - 2 * down))
329
+ }
330
+
331
+ const surfaces = collectSurfaces(el)
332
+ type Lit = { holder: HTMLElement; pool: HTMLElement; offsetX: number; offsetY: number }
333
+ const lit: Lit[] = []
334
+
335
+ const surfaceTint = `radial-gradient(circle, rgba(${warmTint},${Math.min(1, intensity)}) 0%, ` +
336
+ `rgba(${warmTint},${Math.min(1, intensity) * 0.45}) 38%, transparent 68%)`
337
+ // Light reads on BRIGHT surfaces by contrast: while the light passes,
338
+ // ambient dips a touch everywhere the pool is not. Without this, a
339
+ // white pool on a white card is physically invisible.
340
+ const ambientDip = Math.min(0.35, intensity * 0.55)
341
+
342
+ if (surfaces.length) {
343
+ for (const surface of surfaces) {
344
+ const sRect = layoutRect(stage, surface)
345
+ if (getComputedStyle(surface).position === 'static') surface.style.position = 'relative'
346
+ const holder = document.createElement('div')
347
+ holder.setAttribute('data-light-sweep', '')
348
+ // Inside the surface (inherits any 3D transform), clipped by its
349
+ // corners; opacity is driven by the envelope every frame.
350
+ holder.style.cssText =
351
+ `position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:40;` +
352
+ `opacity:0;border-radius:inherit;background:rgba(26,28,46,${ambientDip.toFixed(3)});`
353
+ const pool = document.createElement('div')
354
+ pool.style.cssText =
355
+ `position:absolute;left:0;top:0;width:${radius * 2}px;height:${radius * 2}px;` +
356
+ `background:${surfaceTint};will-change:transform;`
357
+ holder.appendChild(pool)
358
+ surface.appendChild(holder)
359
+ lit.push({
360
+ holder,
361
+ pool,
362
+ offsetX: sRect.left - rootRect.left,
363
+ offsetY: sRect.top - rootRect.top,
364
+ })
365
+ }
366
+ } else {
367
+ // Bare text: the light pool lives in the GLYPHS via a
368
+ // background-clip:text clone — never a rectangle.
369
+ const textContent = (el.textContent ?? '').trim()
370
+ if (!textContent) return { duration: 0 }
371
+ const style = getComputedStyle(el)
372
+ if (style.position === 'static') el.style.position = 'relative'
373
+ const holder = document.createElement('div')
374
+ holder.setAttribute('data-light-sweep', '')
375
+ holder.style.cssText =
376
+ 'position:absolute;inset:0;pointer-events:none;z-index:40;opacity:0;'
377
+ const clone = document.createElement('div')
378
+ clone.textContent = textContent
379
+ clone.style.cssText =
380
+ `font-family:${style.fontFamily};font-size:${style.fontSize};` +
381
+ `font-weight:${style.fontWeight};font-style:${style.fontStyle};` +
382
+ `line-height:${style.lineHeight};letter-spacing:${style.letterSpacing};` +
383
+ `white-space:${style.whiteSpace};color:transparent;` +
384
+ `background-image:radial-gradient(circle ${Math.round(radius)}px at 0px 0px, ` +
385
+ `rgba(${warmTint},${Math.min(1, intensity * 2.4)}) 0%, transparent 70%);` +
386
+ `background-repeat:no-repeat;-webkit-background-clip:text;background-clip:text;`
387
+ holder.appendChild(clone)
388
+ el.appendChild(holder)
389
+ const proxyText = { p: 0 }
390
+ tl.to(
391
+ proxyText,
392
+ {
393
+ p: 1,
394
+ duration,
395
+ ease: opts.ease ?? EASE,
396
+ onUpdate: () => {
397
+ const p = proxyText.p
398
+ const lx = fromPt.x + (toPt.x - fromPt.x) * p
399
+ const ly = fromPt.y + (toPt.y - fromPt.y) * p
400
+ clone.style.backgroundPosition = `${(lx - radius).toFixed(1)}px ${(ly - radius).toFixed(1)}px`
401
+ holder.style.opacity = envelope(p).toFixed(3)
402
+ },
403
+ },
404
+ start
405
+ )
406
+ return { duration }
407
+ }
408
+
409
+ const proxy = { p: 0 }
410
+ tl.to(
411
+ proxy,
412
+ {
413
+ p: 1,
414
+ duration,
415
+ ease: opts.ease ?? EASE,
416
+ onUpdate: () => {
417
+ const p = proxy.p
418
+ const lx = fromPt.x + (toPt.x - fromPt.x) * p
419
+ const ly = fromPt.y + (toPt.y - fromPt.y) * p
420
+ const alpha = envelope(p).toFixed(3)
421
+ for (const entry of lit) {
422
+ // Transform-only per frame: the pool re-centers on the light's
423
+ // position in this surface's local space.
424
+ const px = lx - entry.offsetX - radius
425
+ const py = ly - entry.offsetY - radius
426
+ entry.pool.style.transform = `translate(${px.toFixed(1)}px, ${py.toFixed(1)}px)`
427
+ entry.holder.style.opacity = alpha
428
+ }
429
+ },
430
+ },
431
+ start
432
+ )
433
+ return { duration }
434
+ },
435
+
436
+ rim: (target, opts = {}, position) => {
437
+ const { el } = rectFor(target, 'light.rim')
438
+ const intensity = opts.intensity ?? 0.5
439
+ const duration = opts.duration ?? 0.5
440
+ const color = opts.color ?? (lightState.warmth > 0.5 ? '#ffe9c9' : '#eaf2ff')
441
+ const v = lightVector(lightState.angle)
442
+ const rim = document.createElement('div')
443
+ rim.setAttribute('data-light-rim', '')
444
+ // Inside the target for the same reason as the sweep: the rim must
445
+ // tilt with the panel it lights.
446
+ rim.style.cssText =
447
+ `position:absolute;inset:0;pointer-events:none;z-index:39;opacity:0;` +
448
+ `border-radius:inherit;` +
449
+ // The lit edge faces the source: inset shadow offset TOWARD the light.
450
+ `box-shadow:inset ${(-v.x * 3).toFixed(1)}px ${(-v.y * 3).toFixed(1)}px 8px ${color};`
451
+ if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
452
+ el.appendChild(rim)
453
+ tl.to(rim, { opacity: intensity, duration, ease: EASE }, at(position))
454
+ return { duration }
455
+ },
456
+ }
457
+
458
+ const shade: ShadeKit = {
459
+ cast: (target, opts = {}, position) => {
460
+ const { el, rect } = rectFor(target, 'shade.cast')
461
+ const distance = opts.distance ?? Math.max(10, rect.height * 0.06)
462
+ const softness = opts.softness ?? distance * 2.4
463
+ const opacity = opts.opacity ?? 0.22
464
+ const duration = opts.duration ?? 0.5
465
+ const v = lightVector(lightState.angle)
466
+ const shadow = document.createElement('div')
467
+ shadow.setAttribute('data-shade-cast', '')
468
+ shadow.style.cssText =
469
+ `position:absolute;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;` +
470
+ `height:${rect.height}px;pointer-events:none;z-index:0;opacity:0;` +
471
+ `border-radius:${getComputedStyle(el).borderRadius || '8px'};` +
472
+ `box-shadow:${(v.x * distance).toFixed(1)}px ${(v.y * distance).toFixed(1)}px ` +
473
+ `${softness.toFixed(1)}px rgba(10,10,16,${opacity});`
474
+ // Behind the scene root, so the shadow never paints over the element.
475
+ stage.insertBefore(shadow, stage.firstChild)
476
+ tl.to(shadow, { opacity: 1, duration, ease: EASE }, at(position))
477
+ return { duration }
478
+ },
479
+
480
+ vignette: (opts = {}, position) => {
481
+ const strength = opts.strength ?? 0.22
482
+ const duration = opts.duration ?? 0.8
483
+ const overlay = document.createElement('div')
484
+ overlay.setAttribute('data-shade-vignette', '')
485
+ overlay.style.cssText =
486
+ `position:absolute;inset:0;pointer-events:none;z-index:9840;opacity:0;` +
487
+ `background:radial-gradient(ellipse 78% 72% at 50% 46%, transparent 58%, ` +
488
+ `rgba(8,8,14,${strength}) 100%);`
489
+ stage.appendChild(overlay)
490
+ tl.to(overlay, { opacity: 1, duration, ease: EASE }, at(position))
491
+ return { duration }
492
+ },
493
+
494
+ grade: (preset, opts = {}, position) => {
495
+ const filter = GRADES[preset]
496
+ const duration = opts.duration ?? 0.8
497
+ const root = stage.firstElementChild as HTMLElement | null
498
+ if (!filter || !root) return { duration: 0 }
499
+ tl.to(root, { filter, duration, ease: EASE }, at(position))
500
+ return { duration }
501
+ },
502
+ }
503
+
504
+ const backdrop = (style: BackdropStyle, opts: BackdropOpts = {}): BackdropHandle => {
505
+ const energy = Math.max(0, Math.min(1, opts.energy ?? 0.3))
506
+ const palette = opts.palette ?? ['#5865F2', '#00B476', '#ffb020']
507
+ const { width: W, height: H } = config
508
+ const container = document.createElement('div')
509
+ container.setAttribute('data-backdrop', style)
510
+ container.style.cssText =
511
+ `position:absolute;inset:0;overflow:hidden;pointer-events:none;` +
512
+ (opts.base ? `background:${opts.base};` : '')
513
+ if (opts.fixed !== false && stage.parentElement) {
514
+ // Fixed backplate: mounted on the stage's PARENT (the transition kit's
515
+ // escape-the-camera precedent), behind the stage — camera transforms
516
+ // move the UI plane over an unmoving ground. Parent-mounted nodes are
517
+ // not auto-cleaned per rebuild, so clear any predecessor first.
518
+ const parent = stage.parentElement
519
+ for (const old of Array.from(parent.querySelectorAll(':scope > [data-backdrop]'))) {
520
+ old.remove()
521
+ }
522
+ parent.insertBefore(container, stage)
523
+ } else {
524
+ // Behind the scene root — the scene's background must let it show through.
525
+ stage.insertBefore(container, stage.firstChild)
526
+ }
527
+ // calm()/lift() duck the FORMS, never the base ground.
528
+ const forms = document.createElement('div')
529
+ forms.style.cssText = 'position:absolute;inset:0;'
530
+ container.appendChild(forms)
531
+
532
+ // Speed scales with energy; drift cycles are long so per-frame change
533
+ // stays inside the sub-salient budget.
534
+ const cycle = 14 - energy * 8
535
+
536
+ // Collect the drifting elements now; add their tweens AFTER the direction
537
+ // body finishes, so each drift can be sized to the real scene duration
538
+ // with FINITE repeats (never inflating tl.duration()).
539
+ const drifting: Array<{ el: HTMLElement; vars: Record<string, unknown>; cycle: number }> = []
540
+
541
+ if (style === 'paper') {
542
+ // The reference-film ground: a softly shaded, essentially STILL sheet —
543
+ // cloudy light from one corner, gentle density in the opposite one.
544
+ // No drifting forms; the confidence is in the stillness.
545
+ const tint = palette[0] ?? '#8ea3c0'
546
+ const sheet = document.createElement('div')
547
+ sheet.style.cssText =
548
+ `position:absolute;inset:0;` +
549
+ `background:` +
550
+ `radial-gradient(120% 95% at 18% 8%, rgba(255,255,255,0.85) 0%, transparent 58%),` +
551
+ `radial-gradient(95% 85% at 86% 92%, ${tint}44 0%, transparent 62%),` +
552
+ `radial-gradient(70% 55% at 72% 18%, rgba(255,255,255,0.5) 0%, transparent 60%),` +
553
+ `radial-gradient(80% 70% at 8% 88%, ${tint}2e 0%, transparent 65%);`
554
+ forms.appendChild(sheet)
555
+ } else if (style === 'aurora') {
556
+ // Stripe-style mesh ribbons: elongated PRE-SOFT gradient sheets on a
557
+ // diagonal, drifting very slowly. No filters — stacked radial stops.
558
+ const colors = [palette[0] ?? '#635bff', palette[1] ?? '#ff7ac8', palette[2] ?? '#ffb020']
559
+ for (let i = 0; i < 2; i += 1) {
560
+ const ribbon = document.createElement('div')
561
+ ribbon.style.cssText =
562
+ `position:absolute;left:${(W * (i ? 0.02 : -0.18)).toFixed(0)}px;` +
563
+ `top:${(H * (i ? 0.4 : 0.0)).toFixed(0)}px;` +
564
+ `width:${(W * 1.5).toFixed(0)}px;height:${(H * (0.68 - i * 0.16)).toFixed(0)}px;` +
565
+ `transform:rotate(${-24 + i * 12}deg);` +
566
+ `background:` +
567
+ `radial-gradient(50% 62% at 28% 50%, ${colors[i % 3]}59 0%, transparent 70%),` +
568
+ `radial-gradient(46% 56% at 55% 44%, ${colors[(i + 1) % 3]}47 0%, transparent 70%),` +
569
+ `radial-gradient(40% 52% at 79% 56%, ${colors[(i + 2) % 3]}3d 0%, transparent 72%);` +
570
+ `opacity:${(0.65 + energy * 0.3).toFixed(2)};`
571
+ forms.appendChild(ribbon)
572
+ drifting.push({
573
+ el: ribbon,
574
+ vars: {
575
+ x: (i ? -1 : 1) * (200 + energy * 120),
576
+ y: (i ? 1 : -1) * (110 + energy * 70),
577
+ rotation: `+=${i ? -6 : 6}`,
578
+ scale: 1.12,
579
+ },
580
+ cycle: 6.5 + i * 1.8,
581
+ })
582
+ }
583
+ } else if (style === 'threads') {
584
+ // Mintlify-style strands: two fans of fine gradient curves sweeping
585
+ // opposite corners. Deterministic geometry, whole plate drifts gently.
586
+ const gradId = `bd-threads-${(backdropSeq += 1)}`
587
+ const plate = document.createElement('div')
588
+ plate.style.cssText = 'position:absolute;left:-6%;top:-6%;width:112%;height:112%;'
589
+ const ns = 'http://www.w3.org/2000/svg'
590
+ const svg = document.createElementNS(ns, 'svg')
591
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`)
592
+ svg.setAttribute('preserveAspectRatio', 'none')
593
+ svg.setAttribute('width', '100%')
594
+ svg.setAttribute('height', '100%')
595
+ const defs = document.createElementNS(ns, 'defs')
596
+ const grad = document.createElementNS(ns, 'linearGradient')
597
+ grad.setAttribute('id', gradId)
598
+ grad.setAttribute('x1', '0')
599
+ grad.setAttribute('y1', '0')
600
+ grad.setAttribute('x2', '1')
601
+ grad.setAttribute('y2', '1')
602
+ const s1 = document.createElementNS(ns, 'stop')
603
+ s1.setAttribute('offset', '0%')
604
+ s1.setAttribute('stop-color', palette[0] ?? '#16a34a')
605
+ const s2 = document.createElementNS(ns, 'stop')
606
+ s2.setAttribute('offset', '100%')
607
+ s2.setAttribute('stop-color', palette[1] ?? palette[0] ?? '#a3e635')
608
+ grad.appendChild(s1)
609
+ grad.appendChild(s2)
610
+ defs.appendChild(grad)
611
+ svg.appendChild(defs)
612
+ const strands = 14
613
+ for (let b = 0; b < 2; b += 1) {
614
+ for (let k = 0; k < strands; k += 1) {
615
+ const p = document.createElementNS(ns, 'path')
616
+ const f = k / (strands - 1)
617
+ const d = b
618
+ ? `M ${-W * 0.08} ${H * (0.62 + f * 0.3)} C ${W * 0.22} ${H * (0.5 + f * 0.28)}, ${W * 0.38} ${H * (0.92 - f * 0.24)}, ${W * 0.62} ${H * (0.66 + f * 0.2)}`
619
+ : `M ${W * (0.4 + f * 0.05)} ${-H * 0.08} C ${W * (0.52 + f * 0.06)} ${H * (0.22 + f * 0.1)}, ${W * (0.78 - f * 0.05)} ${H * (0.18 + f * 0.16)}, ${W * 1.08} ${H * (0.42 + f * 0.26)}`
620
+ p.setAttribute('d', d)
621
+ p.setAttribute('fill', 'none')
622
+ p.setAttribute('stroke', `url(#${gradId})`)
623
+ p.setAttribute('stroke-width', '1.5')
624
+ p.setAttribute('opacity', (0.3 + 0.35 * Math.sin(Math.PI * f)).toFixed(2))
625
+ svg.appendChild(p)
626
+ }
627
+ }
628
+ plate.appendChild(svg)
629
+ forms.appendChild(plate)
630
+ drifting.push({ el: plate, vars: { x: 55 + energy * 40, y: 30 + energy * 20, rotation: 1.5 }, cycle: 9 })
631
+ } else if (style === 'dot-matrix') {
632
+ // AgentMail-style shimmering dot field: two dot layers with DIFFERENT
633
+ // spacings drifting opposite ways — the moiré between them reads as an
634
+ // organic density wave. Radial mask keeps the content area clear.
635
+ const fg = palette[0] ?? '#8f9bb3'
636
+ const mask =
637
+ 'radial-gradient(85% 78% at 50% 46%, transparent 28%, black 74%)'
638
+ const mk = (spacing: number, alpha: string, dir: 1 | -1) => {
639
+ const p = document.createElement('div')
640
+ p.style.cssText =
641
+ `position:absolute;left:-12%;top:-12%;width:124%;height:124%;` +
642
+ `background-image:radial-gradient(circle, ${fg}${alpha} 2px, transparent 2px);` +
643
+ `background-size:${spacing}px ${spacing}px;` +
644
+ `-webkit-mask-image:${mask};mask-image:${mask};`
645
+ forms.appendChild(p)
646
+ drifting.push({
647
+ el: p,
648
+ vars: { x: dir * (spacing * 2.5 + energy * 40), y: -dir * (spacing * 1.4 + energy * 24) },
649
+ cycle: dir > 0 ? 7 : 8.5,
650
+ })
651
+ }
652
+ mk(22, '73', 1)
653
+ mk(22, '40', -1)
654
+ } else if (style === 'grid-glow') {
655
+ // Blueprint grid + one traveling pre-soft glow pulse.
656
+ const lineC = palette[0] ?? '#3b82f6'
657
+ const grid = document.createElement('div')
658
+ grid.style.cssText =
659
+ `position:absolute;left:-8%;top:-8%;width:116%;height:116%;` +
660
+ `background-image:` +
661
+ `repeating-linear-gradient(0deg, ${lineC}38 0 1px, transparent 1px 56px),` +
662
+ `repeating-linear-gradient(90deg, ${lineC}38 0 1px, transparent 1px 56px);`
663
+ forms.appendChild(grid)
664
+ const glow = document.createElement('div')
665
+ const gsize = Math.round(W * 0.42)
666
+ glow.style.cssText =
667
+ `position:absolute;left:${Math.round(W * 0.1)}px;top:${Math.round(H * 0.15)}px;` +
668
+ `width:${gsize}px;height:${gsize}px;border-radius:50%;` +
669
+ `background:radial-gradient(circle, ${palette[1] ?? lineC}59 0%, transparent 62%);`
670
+ forms.appendChild(glow)
671
+ // Grid stays STATIC — drifting 1px lines crawl/alias at video scale.
672
+ drifting.push({
673
+ el: glow,
674
+ vars: { x: W * 0.5 + energy * 120, y: H * 0.38 + energy * 60 },
675
+ cycle: 7,
676
+ })
677
+ const glow2 = document.createElement('div')
678
+ const g2 = Math.round(W * 0.3)
679
+ glow2.style.cssText =
680
+ `position:absolute;left:${Math.round(W * 0.62)}px;top:${Math.round(H * 0.55)}px;` +
681
+ `width:${g2}px;height:${g2}px;border-radius:50%;` +
682
+ `background:radial-gradient(circle, ${lineC}47 0%, transparent 62%);`
683
+ forms.appendChild(glow2)
684
+ drifting.push({ el: glow2, vars: { x: -W * 0.4, y: -H * 0.3 }, cycle: 8.6 })
685
+ } else if (style === 'gradient-drift' || style === 'orbs') {
686
+ const count = style === 'orbs' ? 4 : 2
687
+ const sizeBase = style === 'orbs' ? 0.34 : 0.62
688
+ for (let i = 0; i < count; i += 1) {
689
+ const orb = document.createElement('div')
690
+ const size = W * sizeBase * (1 - i * 0.12)
691
+ const x = W * (0.15 + (i * 0.27) % 0.7)
692
+ const y = H * (0.12 + ((i * 0.37 + 0.1) % 0.66))
693
+ // A pre-soft radial disc, NOT filter:blur — an animated element
694
+ // carrying a huge blur filter recomposites every frame and janks the
695
+ // live preview (and breaks the house 20px animated-blur ceiling).
696
+ orb.style.cssText =
697
+ `position:absolute;left:${x - size * 0.5}px;top:${y - size * 0.5}px;` +
698
+ `width:${size * 2}px;height:${size * 2}px;border-radius:50%;` +
699
+ `background:radial-gradient(circle, ${palette[i % palette.length]} 0%, transparent 62%);` +
700
+ `opacity:${(0.1 + energy * 0.14).toFixed(3)};`
701
+ forms.appendChild(orb)
702
+ drifting.push({
703
+ el: orb,
704
+ vars: {
705
+ x: (i % 2 ? -1 : 1) * (40 + energy * 120) * (1 + i * 0.3),
706
+ y: (i % 3 ? 1 : -1) * (30 + energy * 90),
707
+ scale: 1 + 0.05 + energy * 0.08,
708
+ },
709
+ cycle: cycle + i * 2.3,
710
+ })
711
+ }
712
+ } else {
713
+ // dot-grid: a pattern plate panning very slowly.
714
+ const plate = document.createElement('div')
715
+ plate.style.cssText =
716
+ `position:absolute;left:-10%;top:-10%;width:120%;height:120%;` +
717
+ `background-image:radial-gradient(circle, rgba(120,120,140,${(0.16 + energy * 0.12).toFixed(3)}) 1.6px, transparent 1.6px);` +
718
+ `background-size:44px 44px;`
719
+ forms.appendChild(plate)
720
+ drifting.push({
721
+ el: plate,
722
+ vars: { x: 44 + energy * 66, y: 22 + energy * 33 },
723
+ cycle: cycle * 1.6,
724
+ })
725
+ }
726
+
727
+ const addDrift = () => {
728
+ const span = tl.duration()
729
+ if (!(span > 0) || span > 1e6) return
730
+ for (const drift of drifting) {
731
+ // Finite yoyo repeats capped at the scene span, so the drift covers
732
+ // the whole film without extending it.
733
+ const repeats = Math.max(1, Math.floor(span / drift.cycle))
734
+ tl.to(
735
+ drift.el,
736
+ {
737
+ ...drift.vars,
738
+ // Fills [0, span] exactly: cycles stretch slightly rather than
739
+ // leaving a dead tail.
740
+ duration: span / repeats,
741
+ repeat: repeats - 1,
742
+ yoyo: true,
743
+ ease: 'sine.inOut',
744
+ },
745
+ 0
746
+ )
747
+ }
748
+ }
749
+ if (deps.defer) deps.defer(Promise.resolve().then(addDrift))
750
+ else addDrift()
751
+
752
+ return {
753
+ el: container,
754
+ calm: (position) => {
755
+ tl.to(forms, { opacity: 0.35, duration: 0.6, ease: EASE }, at(position))
756
+ },
757
+ lift: (position) => {
758
+ tl.to(forms, { opacity: 1, duration: 0.8, ease: EASE }, at(position))
759
+ },
760
+ }
761
+ }
762
+
763
+ const tilt: LooksKit['tilt'] = (target, opts = {}, position) => {
764
+ const el = resolveTarget(stage, target, 'tilt')
765
+ const duration = opts.duration ?? 0.8
766
+ tl.to(
767
+ el,
768
+ {
769
+ rotationX: opts.pitch ?? 0,
770
+ rotationY: opts.yaw ?? 0,
771
+ transformPerspective: opts.perspective ?? 1400,
772
+ duration,
773
+ ease: opts.ease ?? EASE,
774
+ },
775
+ at(position)
776
+ )
777
+ return { duration }
778
+ }
779
+
780
+ const hero: LooksKit['hero'] = (target, opts = {}, position) => {
781
+ const { el, rect } = rectFor(target, 'hero')
782
+ const anchor = opts.anchor ?? 'bottom-right'
783
+ const frac = anchorFraction(anchor)
784
+ const tiltPose = heroTiltFor(anchor)
785
+ const pitch = opts.pitch ?? tiltPose.pitch
786
+ const yaw = opts.yaw ?? tiltPose.yaw
787
+ const zoom = opts.zoom ?? 1.35
788
+ const duration = opts.duration ?? 0.9
789
+ const hold = opts.hold ?? 1.2
790
+ const returnDuration = opts.returnDuration ?? 0.8
791
+ const settle = opts.settle !== false
792
+ const ease = opts.ease ?? 'promo-inout'
793
+ const start = at(position)
794
+ const { width: W, height: H } = config
795
+
796
+ // Camera push toward the anchored region of the target (same transform
797
+ // model as camera.framing: viewport = scene × scale + translate).
798
+ const scale = (W / rect.width) * zoom
799
+ const cx = rect.left + rect.width * frac.x
800
+ const cy = rect.top + rect.height * frac.y
801
+ const x = W / 2 - scale * cx
802
+ const y = H / 2 - scale * cy
803
+ tl.to(stage, { scale, x, y, duration, ease, force3D: false }, start)
804
+
805
+ // The panel leans its far corner away — perspective on the TARGET, never
806
+ // the stage (GPU texture budget). The tilt starts a beat into the push
807
+ // and develops on a LONGER ease-out, so the angle is seen arriving in
808
+ // close-up instead of completing while the panel is still small.
809
+ const tiltStart = start + duration * 0.25
810
+ const tiltDuration = duration * 1.5
811
+ tl.to(
812
+ el,
813
+ {
814
+ rotationX: pitch,
815
+ rotationY: yaw,
816
+ transformPerspective: 1400,
817
+ duration: tiltDuration,
818
+ ease: 'ease-out-quart',
819
+ },
820
+ tiltStart
821
+ )
822
+
823
+ // Light RESPONDS to the angle: the sheen runs on exactly the tilt's
824
+ // window and curve, so the glint crosses the glass as the glass turns.
825
+ if (opts.sheen !== false) {
826
+ light.sweep(
827
+ el,
828
+ { duration: tiltDuration, intensity: 0.18, ease: 'ease-out-quart' },
829
+ tiltStart
830
+ )
831
+ }
832
+ // The shadow is painted BY the element (boxShadow), so it tilts with the
833
+ // panel — a stage-level shadow plate would stay flat under an angled card.
834
+ const restShadow = getComputedStyle(el).boxShadow
835
+ const from = restShadow === 'none' ? '0px 0px 0px rgba(6,6,14,0)' : restShadow
836
+ const v = lightVector(lightState.angle)
837
+ const deep = `${(v.x * 26).toFixed(0)}px ${(Math.max(v.y, 0.3) * 34).toFixed(0)}px 70px rgba(6,6,14,0.5)`
838
+ if (opts.shadow !== false) {
839
+ tl.fromTo(el, { boxShadow: from }, { boxShadow: deep, duration, ease }, start)
840
+ }
841
+
842
+ if (!settle) return { duration: duration + hold }
843
+
844
+ const back = start + duration + hold
845
+ tl.to(stage, { scale: 1, x: 0, y: 0, duration: returnDuration, ease, force3D: false }, back)
846
+ tl.to(
847
+ el,
848
+ { rotationX: 0, rotationY: 0, duration: returnDuration, ease },
849
+ back
850
+ )
851
+ if (opts.shadow !== false) {
852
+ tl.to(el, { boxShadow: from, duration: returnDuration, ease }, back)
853
+ }
854
+ return { duration: duration + hold + returnDuration }
855
+ }
856
+
857
+ const track: LooksKit['track'] = (target, opts = {}, position) => {
858
+ const el = resolveTarget(stage, target, 'track')
859
+ const duration = opts.duration ?? 2
860
+ const height = opts.height ?? 0.5
861
+ const attach = Math.max(0.01, Math.min(1, opts.attach ?? 0.25))
862
+ const from = opts.from ?? { scale: 1, x: 0, y: 0 }
863
+ const releaseMode = opts.release ?? 'full'
864
+ // The release lives INSIDE the same per-frame computation, never as a
865
+ // separate start-capturing tween: the exporter renders with parallel
866
+ // workers, and a fresh worker seeking past this tween gets its callbacks
867
+ // suppressed as "jumped over" — a capturing release tween then starts
868
+ // from an unposed camera and snaps. With the release folded in, every
869
+ // frame is a pure function of the seeked time, and the tween ENDS at the
870
+ // camera baseline, so a suppressed final render lands on the same pose
871
+ // the stage already holds.
872
+ const releaseDur = releaseMode === 'full' ? 0.8 : 0
873
+ const total = duration + releaseDur
874
+ const start = at(position)
875
+ const { width: W, height: H } = config
876
+ const proxy = { p: 0 }
877
+ tl.to(
878
+ proxy,
879
+ {
880
+ p: 1,
881
+ duration: total,
882
+ ease: 'none',
883
+ onUpdate: () => {
884
+ // Scene coords from the LIVE rect: unmap the current camera
885
+ // transform and any outer studio scaling, then re-center. Reading
886
+ // then writing in one pass — no feedback loop, and deterministic
887
+ // because gsap renders this proxy after the movers (insertion
888
+ // order; track is authored after the motion it follows).
889
+ const stageScale = Number(gsap.getProperty(stage, 'scaleX')) || 1
890
+ const sr = stage.getBoundingClientRect()
891
+ const k = stage.offsetWidth ? sr.width / (stage.offsetWidth * stageScale) : 1
892
+ const r = el.getBoundingClientRect()
893
+ const sceneL = (r.left - sr.left) / (k * stageScale)
894
+ const sceneT = (r.top - sr.top) / (k * stageScale)
895
+ const sceneW = r.width / (k * stageScale)
896
+ const sceneH = r.height / (k * stageScale)
897
+ if (!(sceneW > 0) || !(sceneH > 0)) return
898
+ const scale = Math.min((H * height) / sceneH, (W * 0.9) / sceneW)
899
+ const cx = sceneL + sceneW / 2
900
+ const cy = sceneT + sceneH / 2
901
+ const tx = W / 2 - scale * cx
902
+ const ty = H / 2 - scale * cy
903
+ const tt = proxy.p * total
904
+ // Eased catch-up from the declared start pose — pure function of tt.
905
+ const t = Math.min(1, tt / Math.max(1e-6, attach * duration))
906
+ const a = 1 - Math.pow(1 - t, 3)
907
+ let bs = from.scale + (scale - from.scale) * a
908
+ let bx = from.x + (tx - from.x) * a
909
+ let by = from.y + (ty - from.y) * a
910
+ if (releaseDur > 0 && tt > duration) {
911
+ // Ease back to the full frame — the subject's rect at any
912
+ // release-phase time is its rest pose, so this too replays
913
+ // correctly from a cold seek.
914
+ const rp = Math.min(1, (tt - duration) / releaseDur)
915
+ const re = rp < 0.5 ? 4 * rp * rp * rp : 1 - Math.pow(-2 * rp + 2, 3) / 2
916
+ bs = bs + (1 - bs) * re
917
+ bx = bx * (1 - re)
918
+ by = by * (1 - re)
919
+ }
920
+ // Clamp to the stage like camera.framing does — a tracked subject
921
+ // near an edge must never drag the frame off the stage into black.
922
+ const sw2 = stage.offsetWidth * bs
923
+ const sh2 = stage.offsetHeight * bs
924
+ bx = sw2 >= W ? Math.min(0, Math.max(W - sw2, bx)) : (W - sw2) / 2
925
+ by = sh2 >= H ? Math.min(0, Math.max(H - sh2, by)) : (H - sh2) / 2
926
+ gsap.set(stage, { scale: bs, x: bx, y: by, force3D: false })
927
+ },
928
+ },
929
+ start
930
+ )
931
+ return { duration: total }
932
+ }
933
+
934
+ return { light, shade, backdrop, hero, tilt, track }
935
+ }