@impractical-ai/motion 0.1.6 → 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,343 @@
1
+ // Side-effect import: the draft-side working-views engine (Studio reveal
2
+ // verbs on the currently deployed engine). Every kit imports this module,
3
+ // so any kit-using draft carries it. It installs a nonce-gated message
4
+ // listener and stands down when the engine-side bridge takes over.
5
+ import './reveal'
6
+
7
+ // ─── Shared kit primitives ────────────────────────────────────────────────
8
+ // Cross-kit helpers that were each duplicated inline before. `logo.ts` already
9
+ // imports `prand` from `./text`, so cross-kit imports are house style.
10
+ //
11
+ // Everything here is deterministic: nothing reads a clock, and nothing that
12
+ // runs per frame allocates. The kits that use these still obey the five
13
+ // conventions (factory, every tween through `deps.tl`, return `{duration,…}`,
14
+ // `(target, effect, opts?, position?)`, deterministic only) — plus a sixth
15
+ // that was unwritten until now:
16
+ //
17
+ // 6. Kit DOM appends to `stage`, not `stage.parentElement`. The stage div
18
+ // carries `key={build.tick}` (src/stage/main.tsx:1279), so it is
19
+ // destroyed and recreated on every rebuild — anything under it is
20
+ // auto-cleaned. `transition.ts` self-clears its overlays precisely
21
+ // because it mounts on the parent to escape the camera transform.
22
+
23
+ /** Marker for a kit's proxy-tween target.
24
+ *
25
+ * It lives on the tween TARGET, never in `vars`, for two reasons: the Tweak
26
+ * panel enumerates `tween.vars` (engine.ts:1003-1025) and would otherwise show
27
+ * `kit`/`on` as bogus text controls, while `kindOf` (engine.ts:658-670) reads
28
+ * `targets[0]` and can classify from here. Without a marker a proxy tween is
29
+ * classified `'hold'`, which labels it "Hold the frame", HIDES its ease
30
+ * selector (main.tsx:2792), and files it under the "pause" filter chip. */
31
+ export type KitProxy = {
32
+ kit: 'filter' | 'mask' | 'axis' | 'three' | 'audio'
33
+ /** Human label for the element the proxy drives — `labelForTween` uses it. */
34
+ on: string
35
+ }
36
+
37
+ /** A short, stable description of an element for proxy-tween labels. Mirrors
38
+ * what `describeTarget` (engine.ts:635-644) produces for real element tweens,
39
+ * so the Tweak panel reads consistently across both. */
40
+ export function labelOf(el: Element): string {
41
+ const data = Array.from((el as HTMLElement).attributes ?? [])
42
+ .map((a) => a.name)
43
+ .find((n) => n.startsWith('data-') && n !== 'data-block')
44
+ if (data) return `[${data}]`
45
+ if (el.id) return `#${el.id}`
46
+ const cls = (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)[0]
47
+ return cls ? `.${cls}` : el.tagName.toLowerCase()
48
+ }
49
+
50
+ /** One `<svg><defs>` host per build, for SVG filters and masks.
51
+ *
52
+ * Created LAZILY on first use, not in `buildScene`'s prep zone
53
+ * (engine.ts:717-741). The prep zone is for things that must exist *before the
54
+ * direction runs* (fonts, video metadata); a defs host is needed only if a kit
55
+ * that uses one is actually called. Putting it in the prep zone would make all
56
+ * 145 scenes pay for a DOM node they never touch and would couple engine.ts to
57
+ * kit internals. The precedent is `transition.ts`'s lazy `makeRoot()`.
58
+ *
59
+ * Ids are per-build sequential, so preview and export of the same scene
60
+ * produce identical markup. */
61
+ export type DefsHost = {
62
+ def: (prefix: string, node: SVGElement) => string
63
+ }
64
+
65
+ export function createDefsHost(stage: HTMLElement): DefsHost {
66
+ const ns = 'http://www.w3.org/2000/svg'
67
+ let defs: SVGDefsElement | null = null
68
+ let n = 0
69
+ return {
70
+ def(prefix, node) {
71
+ if (!defs) {
72
+ const svg = document.createElementNS(ns, 'svg')
73
+ svg.setAttribute('data-kit-defs', '')
74
+ svg.setAttribute('width', '0')
75
+ svg.setAttribute('height', '0')
76
+ svg.style.cssText =
77
+ 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;'
78
+ defs = document.createElementNS(ns, 'defs')
79
+ svg.appendChild(defs)
80
+ stage.appendChild(svg)
81
+ }
82
+ const id = `kit-${prefix}-${n++}`
83
+ node.setAttribute('id', id)
84
+ defs.appendChild(node)
85
+ return id
86
+ },
87
+ }
88
+ }
89
+
90
+ /** Serialize an element's silhouette to a data-URI mask value, from a CLEAN
91
+ * clone. Lifted from `logo.shine` (logo.ts:601-617), which needs the cleaning
92
+ * because by the time it runs, reveal effects have written inline styles onto
93
+ * the live shapes — `draw` zeroes every `stroke-dasharray` and hides fills,
94
+ * which would serialize into a fully transparent mask, and a transparent mask
95
+ * hides everything.
96
+ *
97
+ * Returns `null` when there is no usable source, so callers can warn and
98
+ * degrade rather than silently masking an element to nothing. */
99
+ export function silhouetteUrl(source: Element): string | null {
100
+ if (source instanceof HTMLImageElement) return `url("${source.src}")`
101
+ const svg =
102
+ source instanceof SVGSVGElement
103
+ ? source
104
+ : (source.querySelector('svg') as SVGSVGElement | null)
105
+ if (svg) {
106
+ const clean = svg.cloneNode(true) as SVGSVGElement
107
+ for (const node of [clean, ...Array.from(clean.querySelectorAll('*'))]) {
108
+ node.removeAttribute('style')
109
+ node.removeAttribute('stroke-dasharray')
110
+ node.removeAttribute('stroke-dashoffset')
111
+ }
112
+ const markup = new XMLSerializer().serializeToString(clean)
113
+ return `url("data:image/svg+xml;utf8,${encodeURIComponent(markup)}")`
114
+ }
115
+ const img = source.querySelector('img') as HTMLImageElement | null
116
+ return img ? `url("${img.src}")` : null
117
+ }
118
+
119
+ /** Freeze per-unit widths so a property that changes glyph advance can't
120
+ * reflow the line mid-tween. Third occurrence of this pattern (text.ts:300-306
121
+ * for scramble, text.ts:528-533 for count), which is why it lives here.
122
+ *
123
+ * Matters most for variable-font `wght`: an unlocked weight wave makes the
124
+ * whole line breathe horizontally as it passes. */
125
+ export function lockWidths(units: HTMLElement[]): void {
126
+ // Measure every unit BEFORE mutating any of them — setting `display` on unit
127
+ // i changes layout for unit i+1, so an interleaved read/write loop records
128
+ // widths that are already wrong.
129
+ const widths = units.map((u) => u.offsetWidth)
130
+ units.forEach((u, i) => {
131
+ u.style.width = `${widths[i]}px`
132
+ u.style.display = 'inline-block'
133
+ u.style.textAlign = 'center'
134
+ })
135
+ }
136
+
137
+ /** Give an element's beat its own stacking context so a blend mode composites
138
+ * against that beat and not the whole frame.
139
+ *
140
+ * `mix-blend-mode` composites against the nearest stacking context. Assembled
141
+ * videos hide sections with `autoAlpha: 0` (studio/blocks.ts:1888) and
142
+ * `opacity < 1` creates one — so a blend inside a HIDDEN block is already
143
+ * contained. But a VISIBLE block at opacity 1 (`absolute inset-0`, no
144
+ * z-index) does not create one, so its blend would reach past its own beat and
145
+ * composite against whatever sits underneath. One line fixes it, and without
146
+ * it blends are subtly wrong in exactly the assembled-video case the product
147
+ * generates most. */
148
+ export function isolateBlock(el: Element, stage: HTMLElement): void {
149
+ const host = (el.closest('[data-block]') as HTMLElement | null) ?? stage
150
+ host.style.isolation = 'isolate'
151
+ }
152
+
153
+ /** Composes a multi-slot CSS property (`filter`, `mask-image`) so two kits, or
154
+ * two effects from one kit, can drive the same element without clobbering each
155
+ * other's style string.
156
+ *
157
+ * Whatever the element already carried is captured once as the base and always
158
+ * emitted first, so an authored `filter: drop-shadow(...)` survives a later
159
+ * `fx.apply(el, 'blur')`. */
160
+ export type CssChain = {
161
+ /** Register `value` under `slot` and rewrite the property. Idempotent. */
162
+ add: (el: HTMLElement, slot: string, value: string) => void
163
+ /** Drop a slot and rewrite. */
164
+ remove: (el: HTMLElement, slot: string) => void
165
+ /** Is this slot already registered on this element? */
166
+ has: (el: HTMLElement, slot: string) => boolean
167
+ }
168
+
169
+ export function cssChain(prop: 'filter' | 'maskImage'): CssChain {
170
+ const slots = new WeakMap<HTMLElement, Map<string, string>>()
171
+ const bases = new WeakMap<HTMLElement, string>()
172
+ const joiner = prop === 'filter' ? ' ' : ', '
173
+
174
+ const write = (el: HTMLElement) => {
175
+ const map = slots.get(el)
176
+ const base = bases.get(el) ?? ''
177
+ const parts = [base, ...(map ? Array.from(map.values()) : [])].filter(Boolean)
178
+ const value = parts.join(joiner)
179
+ if (prop === 'filter') {
180
+ el.style.filter = value
181
+ } else {
182
+ el.style.setProperty('mask-image', value)
183
+ el.style.setProperty('-webkit-mask-image', value)
184
+ }
185
+ }
186
+
187
+ return {
188
+ add(el, slot, value) {
189
+ if (!bases.has(el)) {
190
+ const authored =
191
+ prop === 'filter'
192
+ ? el.style.filter
193
+ : el.style.getPropertyValue('mask-image')
194
+ bases.set(el, authored && authored !== 'none' ? authored : '')
195
+ }
196
+ let map = slots.get(el)
197
+ if (!map) {
198
+ map = new Map()
199
+ slots.set(el, map)
200
+ }
201
+ map.set(slot, value)
202
+ write(el)
203
+ },
204
+ remove(el, slot) {
205
+ slots.get(el)?.delete(slot)
206
+ write(el)
207
+ },
208
+ has(el, slot) {
209
+ return slots.get(el)?.has(slot) ?? false
210
+ },
211
+ }
212
+ }
213
+
214
+ /** Resolve a `string | Element` target against the stage, throwing the same
215
+ * shape of error `ctx.q` does (engine.ts:835-839) so a bad selector fails
216
+ * loudly at build time rather than silently animating nothing. */
217
+ export function resolveTarget(
218
+ stage: HTMLElement,
219
+ target: string | Element,
220
+ kind: string
221
+ ): HTMLElement {
222
+ const el =
223
+ typeof target === 'string' ? stage.querySelector(target) : (target as Element)
224
+ if (!el) throw new Error(`${kind} target not found: ${String(target)}`)
225
+ return el as HTMLElement
226
+ }
227
+
228
+ /** Resolve to many elements — the group form. Empty is an error for the same
229
+ * reason: an effect that animates nothing is a silent authoring bug. */
230
+ export function resolveTargets(
231
+ stage: HTMLElement,
232
+ target: string | Element | Element[],
233
+ kind: string
234
+ ): HTMLElement[] {
235
+ if (Array.isArray(target)) return target as HTMLElement[]
236
+ if (typeof target !== 'string') return [target as HTMLElement]
237
+ const found = Array.from(stage.querySelectorAll(target)) as HTMLElement[]
238
+ if (!found.length) throw new Error(`${kind} target not found: ${target}`)
239
+ return found
240
+ }
241
+
242
+ /** Element rect in AUTHORED-LAYOUT scene coords.
243
+ *
244
+ * gsap `from()` renders its from-state at BUILD time, so a live rect measured
245
+ * while a direction is being authored is displaced by whatever entrance tweens
246
+ * are mid-flight. Kits that place overlays or compute travel (actions,
247
+ * highlight, looks) want the RESTING layout, so this measures with every
248
+ * transform from the element up to and including the camera-carrying stage
249
+ * temporarily neutralized, then restores them. Scaling OUTSIDE the stage (the
250
+ * studio player fitting the composition to the window) divides out via the
251
+ * stage's own ratio. */
252
+ export function layoutRect(
253
+ stage: HTMLElement,
254
+ el: HTMLElement
255
+ ): { left: number; top: number; width: number; height: number } {
256
+ return withNeutralizedTransforms(stage, el, (measure) => measure(el.getBoundingClientRect()))
257
+ }
258
+
259
+ /** Element rect in stage-local coords WITH authored transforms applied — where
260
+ * the element actually sits RIGHT NOW, with only the stage's own transform
261
+ * (camera + studio player fit) divided out. `layoutRect` answers "where does
262
+ * this element rest in the authored layout"; `visualRect` answers "where is it
263
+ * at this moment". Anything that AIMS at an element from stage space (cursor
264
+ * hotspots, rings, flights) wants visualRect resolved at action time — a
265
+ * container the direction has scaled mid-timeline moves its children away from
266
+ * their resting rects, and an aim taken from layoutRect lands beside the UI. */
267
+ export function visualRect(
268
+ stage: HTMLElement,
269
+ el: HTMLElement
270
+ ): { left: number; top: number; width: number; height: number } {
271
+ const stageRect = stage.getBoundingClientRect()
272
+ const k = stage.offsetWidth ? stageRect.width / stage.offsetWidth : 1
273
+ const r = el.getBoundingClientRect()
274
+ return {
275
+ left: (r.left - stageRect.left) / k,
276
+ top: (r.top - stageRect.top) / k,
277
+ width: r.width / k,
278
+ height: r.height / k,
279
+ }
280
+ }
281
+
282
+ /** The TEXT rect of an element in authored-layout scene coords.
283
+ *
284
+ * A block element's box spans its full column even when its glyphs don't —
285
+ * the same tension `textRect` solves for camera framing. Annotations that
286
+ * decorate text (marker swipes, hand-drawn circles, underlines) must fit the
287
+ * GLYPHS, or a highlight on "99.99%" swallows half a card of empty space.
288
+ * Falls back to the element box when there is no text content. */
289
+ export function layoutTextRect(
290
+ stage: HTMLElement,
291
+ el: HTMLElement
292
+ ): { left: number; top: number; width: number; height: number } {
293
+ return withNeutralizedTransforms(stage, el, (measure) => {
294
+ const range = document.createRange()
295
+ range.selectNodeContents(el)
296
+ const r = range.getBoundingClientRect()
297
+ return r.width > 0 && r.height > 0 ? measure(r) : measure(el.getBoundingClientRect())
298
+ })
299
+ }
300
+
301
+ function withNeutralizedTransforms(
302
+ stage: HTMLElement,
303
+ el: HTMLElement,
304
+ read: (
305
+ measure: (r: DOMRect) => { left: number; top: number; width: number; height: number }
306
+ ) => { left: number; top: number; width: number; height: number }
307
+ ): { left: number; top: number; width: number; height: number } {
308
+ const touched: Array<{ node: HTMLElement; transform: string }> = []
309
+ let node: HTMLElement | null = el
310
+ while (node) {
311
+ if (node.style.transform) {
312
+ touched.push({ node, transform: node.style.transform })
313
+ node.style.transform = 'none'
314
+ }
315
+ if (node === stage) break
316
+ node = node.parentElement
317
+ }
318
+ const stageRect = stage.getBoundingClientRect()
319
+ const k = stage.offsetWidth ? stageRect.width / stage.offsetWidth : 1
320
+ const result = read((r) => ({
321
+ left: (r.left - stageRect.left) / k,
322
+ top: (r.top - stageRect.top) / k,
323
+ width: r.width / k,
324
+ height: r.height / k,
325
+ }))
326
+ for (const { node: touchedNode, transform } of touched) {
327
+ touchedNode.style.transform = transform
328
+ }
329
+ return result
330
+ }
331
+
332
+ /** Warn once per (scope, key). Mirrors `logo.ts`'s `warned` Set: kits degrade
333
+ * with a console warning rather than throwing, because templates and the AI
334
+ * director can regenerate markup out from under a direction and a hard throw
335
+ * turns "renders flat" into "renders not at all". */
336
+ export function createWarner(scope: string) {
337
+ const seen = new Set<string>()
338
+ return (key: string, message: string) => {
339
+ if (seen.has(key)) return
340
+ seen.add(key)
341
+ console.warn(`[${scope}] ${message}`)
342
+ }
343
+ }