@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.
- package/README.md +19 -0
- package/assets/engine.d.ts +793 -5
- package/assets/impractical/SKILL.md +53 -6
- package/assets/kits/actions.ts +803 -0
- package/assets/kits/ensemble.ts +483 -0
- package/assets/kits/highlight.ts +374 -0
- package/assets/kits/impact.ts +103 -0
- package/assets/kits/kits.ts +343 -0
- package/assets/kits/looks.ts +935 -0
- package/assets/kits/morphkit.ts +438 -0
- package/assets/kits/reveal.ts +1364 -0
- package/assets/kits/sentence.ts +218 -0
- package/assets/kits/streamkit.ts +195 -0
- package/dist/{api-KD6BNKBQ.js → api-DR3NJ7TI.js} +5 -2
- package/dist/chunk-77HVPD4G.js +43 -0
- package/dist/{chunk-DMAQPSA4.js → chunk-M7SHFM25.js} +222 -27
- package/dist/{chunk-7724464Z.js → chunk-MQKW7NFI.js} +65 -14
- package/dist/chunk-Z74SLKEU.js +67 -0
- package/dist/{commands-Y35XPDVH.js → commands-R3LJU2BW.js} +29 -4
- package/dist/index.js +6 -5
- package/dist/main-BEONOBG5.js +2218 -0
- package/dist/{stdio-ZFPBM6Z6.js → stdio-XUVQKDCB.js} +3169 -431
- package/package.json +4 -3
- package/dist/chunk-JDN3QHCX.js +0 -98
|
@@ -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
|
+
}
|