@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,1364 @@
1
+ // DRAFT-SIDE working-views engine — GENERATED from src/stage/reveal.ts. Do not hand-edit.
2
+ // Regenerate with: node .build-draft-reveal.mjs
3
+ //
4
+ // Ships inside the draft so the Studio's reveal verbs work on the currently
5
+ // deployed engine. Stands down automatically once the engine deploy lands.
6
+ // Working views — the canvas shows what the agent is doing to it.
7
+ //
8
+ // Two gestures, both driven from the Studio over the bridge:
9
+ //
10
+ // reveal(root) a scene (or one component subtree) resolves parent→child
11
+ // after a write lands. Elements are grouped by DOM depth;
12
+ // siblings at a depth fire together, children wait for their
13
+ // parent, and each one wipes in top-down behind a blue ring.
14
+ //
15
+ // working(sel) an element is ringed BEFORE anything is written, because
16
+ // the agent declared it is working there. This is the only
17
+ // thing that covers the silence while the model composes.
18
+ //
19
+ // WHY THIS IS SAFE IN AN EXPORT BUNDLE. The stage bundle is also the export
20
+ // artifact, so nothing here may reach a rendered frame. It can't: every entry
21
+ // point below runs only in response to a postMessage, export workers cold-seek
22
+ // and never post, and the module mutates nothing at import time. An un-messaged
23
+ // stage is byte-identical to one without this file.
24
+ //
25
+ // WHY IT DOESN'T FIGHT THE TIMELINE. Elements are hidden with `opacity` and
26
+ // `clip-path` only — neither participates in layout, so the scene's geometry is
27
+ // final before anything animates (rings can be measured in one pass) and any
28
+ // GSAP tween that lands mid-reveal still computes against true rects. Every
29
+ // animation is a Web Animations object we own and can cancel; we never write to
30
+ // the timeline, and we always hand the inline styles back when we're done.
31
+
32
+ /** Tuned 2026-08-20 and signed off. The ring does NOT converge — offset and
33
+ * lead are 0, so it stamps in place on the element's real box in sync with
34
+ * the content wiping in. A converging ring was tried and rejected: it makes
35
+ * the ring the subject instead of the thing arriving. */
36
+ export type RevealOptions = {
37
+ /** ms between one depth and the next */
38
+ depthStagger?: number
39
+ /** ms between siblings inside a depth */
40
+ siblingStagger?: number
41
+ /** ms for a single element to resolve */
42
+ duration?: number
43
+ /** px the ring starts outside the box; 0 = stamp in place */
44
+ ringOffset?: number
45
+ /** ms the ring starts before its element */
46
+ ringLead?: number
47
+ /** draw the ring at all */
48
+ ring?: boolean
49
+ /** 'wipe' reveals top-down; 'fade' rises and de-blurs */
50
+ resolve?: 'wipe' | 'fade'
51
+ /** How the arrivals are ordered within one moment.
52
+ * bfs — all siblings at a depth together, depth by depth (the tuned one)
53
+ * dfs — one branch completed before the next starts (= document order,
54
+ * since document order IS pre-order)
55
+ * sweep — a wave down the frame: delay follows the element's top edge, so
56
+ * anything sharing a top edge fires together
57
+ * size — biggest first, so containers land before their details
58
+ * leaves — deepest first: details assemble, then the frame around them
59
+ */
60
+ order?: 'bfs' | 'dfs' | 'sweep' | 'size' | 'leaves'
61
+ /** ms per 100px of travel for `sweep`; per element for the sequential modes */
62
+ stagger?: number
63
+ /** How far below the root to cascade. A rebuild triggered by a deletion only
64
+ * needs to redo the slot that changed — going all the way down redraws an
65
+ * entire card because one line was removed. Undefined = the whole subtree. */
66
+ maxDepth?: number
67
+ /** These are already clipped by the caller (see hideUntilRevealed). Animate
68
+ * them open without re-hiding, and leave OPACITY ALONE — the timeline owns
69
+ * opacity via autoAlpha, so holding it at 1 with a forwards fill would stop
70
+ * the scene ever hiding the element again. clip-path is ours alone. */
71
+ preHidden?: boolean
72
+ }
73
+
74
+ const DEFAULTS: Required<RevealOptions> = {
75
+ depthStagger: 300,
76
+ siblingStagger: 120,
77
+ duration: 500,
78
+ ringOffset: 0,
79
+ ringLead: 0,
80
+ ring: true,
81
+ resolve: 'wipe',
82
+ order: 'dfs',
83
+ stagger: 90,
84
+ maxDepth: Infinity,
85
+ preHidden: false,
86
+ }
87
+
88
+ // HIDING: WHEN IT IS SAFE, AND WHEN IT IS NOT.
89
+ //
90
+ // An earlier version clipped arrivals out of sight DURING PLAYBACK, a few
91
+ // hundred ms before the timeline would bring them in. That was unstable: the
92
+ // film was running, so every hidden node depended on a perfectly-timed
93
+ // hand-back, and any path that missed one left a component invisible for the
94
+ // rest of the run.
95
+ //
96
+ // The montage is a different situation and hiding IS safe there. It never
97
+ // plays — it seeks between frozen frames — so suppression is not racing
98
+ // anything. Everything that will ever appear is hidden ONCE, up front, and
99
+ // each element is released at its own creation moment and stays released. An
100
+ // element's only possible appearance is its cascade, by construction rather
101
+ // than by timing.
102
+ //
103
+ // Two rules keep it honest: we suppress with clip-path, which the engine never
104
+ // writes, so we are never fighting GSAP for a property; and we never touch
105
+ // opacity on a scene that has a timeline, because autoAlpha owns it.
106
+
107
+ const EASE = 'cubic-bezier(0.16, 1, 0.3, 1)'
108
+ const RING_COLOR = '#2f6bff'
109
+ const LAYER_ID = 'stage-working-layer'
110
+
111
+ /** The inline values an element carried BEFORE we touched it. The scene sets
112
+ * these itself — GSAP writes opacity/visibility inline for autoAlpha, and a
113
+ * chapter waiting its turn is legitimately opacity:0 — so restoring means
114
+ * putting the old string back, never removeProperty(). Blindly clearing is
115
+ * the same bug as clearProps eating an element's authored styles. */
116
+ type Prop = 'opacity' | 'clipPath' | 'filter' | 'transform'
117
+ const CSS_NAME: Record<Prop, string> = {
118
+ opacity: 'opacity', clipPath: 'clip-path', filter: 'filter', transform: 'transform',
119
+ }
120
+ /** ONLY the properties we actually wrote, and their values at the moment we
121
+ * wrote them. Snapshotting everything and putting it all back re-applies stale
122
+ * values: an element captured at t=0 carries GSAP's opacity:0 from before its
123
+ * entrance, so "restoring" it after its cascade makes it vanish. */
124
+ type Prior = Partial<Record<Prop, string>>
125
+
126
+ /** Every animation and node this module owns, so a second call — or an
127
+ * uninstall — can take all of it back with no residue. */
128
+ let live: Animation[] = []
129
+ let ringAnims: Animation[] = []
130
+ let touched = new Map<HTMLElement, Prior>()
131
+ let layer: HTMLElement | null = null
132
+
133
+ const capture = (el: HTMLElement, props: Prop[]): Prior => {
134
+ const p: Prior = {}
135
+ for (const k of props) p[k] = el.style[k]
136
+ return p
137
+ }
138
+
139
+ /** Merge a capture in without clobbering an earlier one for the same property —
140
+ * the FIRST value we saw is the one the scene authored. */
141
+ const remember = (el: HTMLElement, props: Prop[]): void => {
142
+ const existing = touched.get(el) ?? {}
143
+ for (const k of props) if (!(k in existing)) existing[k] = el.style[k]
144
+ touched.set(el, existing)
145
+ }
146
+
147
+ const restore = (el: HTMLElement, p: Prior | undefined): void => {
148
+ if (!p) return
149
+ for (const k of Object.keys(p) as Prop[]) {
150
+ const v = p[k]
151
+ if (v) el.style.setProperty(CSS_NAME[k], v)
152
+ else el.style.removeProperty(CSS_NAME[k])
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Is this element itself drawn right now?
158
+ *
159
+ * Deliberately does NOT walk the ancestors. A stricter ancestor-aware version
160
+ * was tried: it dates children more accurately, but a container mid-fade at a
161
+ * sampled instant drops its whole subtree, and entire components — a dashboard,
162
+ * a panel — stopped earning a creation moment at all. Being generous here costs
163
+ * a few early moments; being strict costs whole components.
164
+ */
165
+ const onScreen = (el: HTMLElement): boolean => {
166
+ const s = getComputedStyle(el)
167
+ if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) === 0) return false
168
+ const b = el.getBoundingClientRect()
169
+ return b.width >= 2 && b.height >= 2
170
+ }
171
+
172
+ const reduced = () =>
173
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
174
+
175
+ function ringLayer(): HTMLElement {
176
+ if (layer && layer.isConnected) return layer
177
+ const el = document.createElement('div')
178
+ el.id = LAYER_ID
179
+ // The rings live inside the stage document, so they share its coordinate
180
+ // space — a camera transform on the stage carries them for free. Drawn as
181
+ // an overlay in the parent they would need the camera math re-derived.
182
+ el.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483000;'
183
+ document.body.appendChild(el)
184
+ layer = el
185
+ return el
186
+ }
187
+
188
+ /** Put every element back exactly as authored. Called before each new reveal
189
+ * and by uninstall, so an interrupted reveal can never strand a hidden node. */
190
+ export function clearWorking(): void {
191
+ for (const a of live) {
192
+ try {
193
+ a.cancel()
194
+ } catch {
195
+ /* already finished */
196
+ }
197
+ }
198
+ live = []
199
+ clearRings()
200
+ stopWatching()
201
+ for (const [el, prior] of touched) restore(el, prior)
202
+ touched = new Map()
203
+ layer?.replaceChildren()
204
+ }
205
+
206
+ /** Drop the rings without touching any element state. */
207
+ export function clearRings(): void {
208
+ for (const a of ringAnims) {
209
+ try {
210
+ a.cancel()
211
+ } catch {
212
+ /* already finished */
213
+ }
214
+ }
215
+ ringAnims = []
216
+ layer?.replaceChildren()
217
+ }
218
+
219
+ /** Depth = how many participating ancestors an element has. Computed from the
220
+ * DOM rather than authored, so any scene works with no annotation beyond the
221
+ * data-* hooks its direction already needs as selectors. */
222
+ function depthOf(el: Element, participates: (n: Element) => boolean): number {
223
+ let d = 0
224
+ let p = el.parentElement
225
+ while (p) {
226
+ if (participates(p)) d += 1
227
+ p = p.parentElement
228
+ }
229
+ return d
230
+ }
231
+
232
+ function drawRing(el: HTMLElement, at: number, o: Required<RevealOptions>): void {
233
+ const b = el.getBoundingClientRect()
234
+ if (b.width < 2 || b.height < 2) return
235
+ const r = document.createElement('div')
236
+ r.style.cssText =
237
+ `position:absolute;left:${b.left}px;top:${b.top}px;` +
238
+ `width:${b.width}px;height:${b.height}px;` +
239
+ `border-radius:${getComputedStyle(el).borderRadius};` +
240
+ `outline:1.5px solid ${RING_COLOR};outline-offset:0;opacity:0;`
241
+ ringLayer().appendChild(r)
242
+ ringAnims.push(
243
+ r.animate(
244
+ [
245
+ { outlineOffset: `${o.ringOffset}px`, opacity: 0 },
246
+ { outlineOffset: `${o.ringOffset * 0.35}px`, opacity: 1, offset: 0.35 },
247
+ { outlineOffset: '0px', opacity: 1, offset: 0.62 },
248
+ { outlineOffset: '0px', opacity: 0 },
249
+ ],
250
+ { duration: o.duration + o.ringLead + 240, delay: at, easing: EASE, fill: 'both' }
251
+ )
252
+ )
253
+ }
254
+
255
+ /**
256
+ * Resolve a subtree parent→child. Pass the whole stage after a first build, or
257
+ * one component's root after an incremental write — same gesture, smaller tree,
258
+ * which is what keeps a re-reveal from costing three seconds on every save.
259
+ *
260
+ * Returns the total run time in ms so the caller can sequence against it.
261
+ */
262
+ export function reveal(root: Element | null, options: RevealOptions = {}): number {
263
+ if (!root || reduced()) return 0
264
+ const participates = (n: Element) => n.hasAttribute?.('data-reveal') || hasHook(n)
265
+ // Depth is computed against every participating ancestor, but only the
266
+ // currently-visible elements are animated — so a scene whose later chapters
267
+ // are parked at autoAlpha:0 reveals what's on screen and leaves the rest.
268
+ const o = { ...DEFAULTS, ...options }
269
+ const within = (el: Element): boolean => {
270
+ if (!Number.isFinite(o.maxDepth)) return true
271
+ let d = 0
272
+ for (let n = el.parentElement; n && n !== root; n = n.parentElement) if (participates(n)) d += 1
273
+ return d <= o.maxDepth
274
+ }
275
+ const all = [...root.querySelectorAll<HTMLElement>('*')].filter(participates).filter(within)
276
+ // the root itself takes part, so a rebuilt slot reads as one box changing
277
+ if (root instanceof HTMLElement && participates(root)) all.unshift(root)
278
+ return revealElements(all.filter(onScreen), options)
279
+ }
280
+
281
+ /**
282
+ * Cascade an explicit set of elements. This is the shared core: `reveal()`
283
+ * collects a subtree and hands it here, and the montage hands it exactly the
284
+ * elements that arrived at one creation moment.
285
+ */
286
+ export function revealElements(list: HTMLElement[], options: RevealOptions = {}): number {
287
+ const o = { ...DEFAULTS, ...options }
288
+ // Pre-hidden elements are already registered in `touched`; a full clear would
289
+ // restore every one of them and flash the whole scene into view. The rings
290
+ // from the previous stop still have to go, or they pile up.
291
+ if (o.preHidden) clearRings()
292
+ else clearWorking()
293
+ const els = list.filter(onScreen).filter((el) => !isScenery(el))
294
+
295
+ // Hand back anything we are NOT going to animate. A caller may have hidden
296
+ // these ahead of time (see preHide), and an element that is hidden but never
297
+ // revealed stays invisible for the rest of the run.
298
+ const revealing = new Set(els)
299
+ for (const el of list) {
300
+ if (!revealing.has(el) && touched.has(el)) {
301
+ restore(el, touched.get(el))
302
+ touched.delete(el)
303
+ }
304
+ }
305
+ if (!els.length || reduced()) return 0
306
+ const participates = (n: Element) => n.hasAttribute?.('data-reveal') || hasHook(n)
307
+
308
+ // When each element fires. Measured before anything is hidden — hiding uses
309
+ // only non-layout properties, so these rects stay true for the whole run.
310
+ const delay = new Map<HTMLElement, number>()
311
+ const seq = (list: HTMLElement[]) => list.forEach((el, i) => delay.set(el, i * o.stagger))
312
+
313
+ if (o.order === 'dfs') {
314
+ // querySelectorAll returns document order, which IS pre-order DFS
315
+ seq(els)
316
+ } else if (o.order === 'leaves') {
317
+ seq([...els].sort((a, b) => depthOf(b, participates) - depthOf(a, participates)))
318
+ } else if (o.order === 'size') {
319
+ const area = (el: HTMLElement) => {
320
+ const b = el.getBoundingClientRect()
321
+ return b.width * b.height
322
+ }
323
+ seq([...els].sort((a, b) => area(b) - area(a)))
324
+ } else if (o.order === 'sweep') {
325
+ const tops = els.map((el) => el.getBoundingClientRect().top)
326
+ const min = Math.min(...tops)
327
+ els.forEach((el, i) => delay.set(el, ((tops[i] - min) / 100) * o.stagger))
328
+ } else {
329
+ const waves: HTMLElement[][] = []
330
+ for (const el of els) {
331
+ const d = depthOf(el, participates)
332
+ ;(waves[d] ||= []).push(el)
333
+ }
334
+ waves.forEach((wave, d) => {
335
+ if (!wave) return
336
+ wave.forEach((el, i) => delay.set(el, d * o.depthStagger + i * o.siblingStagger))
337
+ })
338
+ }
339
+
340
+ const hidden = o.resolve === 'wipe' ? 'inset(0 0 100% 0)' : ''
341
+ if (!o.preHidden) {
342
+ for (const el of els) {
343
+ remember(el, hidden ? ['opacity', 'clipPath'] : ['opacity'])
344
+ el.style.opacity = '0'
345
+ if (hidden) el.style.clipPath = hidden
346
+ }
347
+ }
348
+
349
+ let total = 0
350
+ {
351
+ for (const el of els) {
352
+ const at = delay.get(el) ?? 0
353
+ if (o.ring) drawRing(el, at, o)
354
+
355
+ // When the timeline owns opacity we animate clip-path alone — otherwise
356
+ // GSAP overwrites our keyframes on its very next render.
357
+ const from: Keyframe =
358
+ o.resolve === 'wipe'
359
+ ? { clipPath: 'inset(0 0 100% 0)', filter: 'blur(3px)' }
360
+ : { filter: 'blur(4px)', transform: 'translateY(8px)' }
361
+ const to: Keyframe =
362
+ o.resolve === 'wipe'
363
+ ? { clipPath: 'inset(0 0 0% 0)', filter: 'blur(0px)' }
364
+ : { filter: 'blur(0px)', transform: 'translateY(0)' }
365
+ if (!o.preHidden) {
366
+ from.opacity = 0
367
+ to.opacity = 1
368
+ }
369
+
370
+ const a = el.animate([from, to], {
371
+ duration: o.duration,
372
+ delay: at + o.ringLead,
373
+ easing: EASE,
374
+ fill: 'both',
375
+ })
376
+ live.push(a)
377
+ // Hand the inline styles back the moment this element is settled, so a
378
+ // scene that keeps animating isn't left fighting our opacity.
379
+ a.finished
380
+ .then(() => {
381
+ // Hand the inline styles back so the scene isn't left fighting ours.
382
+ // For a pre-hidden element this is also its RELEASE: the original
383
+ // clip (usually none) goes back and it stays visible from here.
384
+ restore(el, touched.get(el))
385
+ touched.delete(el)
386
+ })
387
+ .catch(() => {
388
+ /* cancelled by the next reveal, which restores in clearWorking */
389
+ })
390
+ total = Math.max(total, at + o.ringLead + o.duration)
391
+ }
392
+ }
393
+ return total
394
+ }
395
+
396
+ /** Any element the direction could target is a candidate — that is exactly the
397
+ * set carrying a data-* hook, so scenes need no extra markup. */
398
+ function hasHook(n: Element): boolean {
399
+ const el = n as HTMLElement
400
+ if (!el.dataset) return false
401
+ for (const k in el.dataset) if (k !== 'reveal') return true
402
+ return false
403
+ }
404
+
405
+ /**
406
+ * Ring elements the agent has declared it is working on, and hold the ring
407
+ * until it says otherwise. Nothing is hidden — the elements stay exactly as
408
+ * they are; this only draws over them.
409
+ */
410
+ export function setWorking(selectors: string[]): void {
411
+ clearWorking()
412
+ if (reduced() || !selectors?.length) return
413
+ for (const sel of selectors) {
414
+ let nodes: NodeListOf<HTMLElement>
415
+ try {
416
+ nodes = document.querySelectorAll<HTMLElement>(sel)
417
+ } catch {
418
+ continue // a malformed selector must never break the stage
419
+ }
420
+ for (const el of nodes) {
421
+ const b = el.getBoundingClientRect()
422
+ if (b.width < 2 || b.height < 2) continue
423
+ const r = document.createElement('div')
424
+ r.style.cssText =
425
+ `position:absolute;left:${b.left}px;top:${b.top}px;` +
426
+ `width:${b.width}px;height:${b.height}px;` +
427
+ `border-radius:${getComputedStyle(el).borderRadius};` +
428
+ `outline:1.5px solid ${RING_COLOR};outline-offset:0;`
429
+ ringLayer().appendChild(r)
430
+ // A slow breath, not a blink — this can sit on screen for a minute while
431
+ // the model composes, and a blink would be unbearable at that length.
432
+ live.push(
433
+ r.animate([{ opacity: 0.35 }, { opacity: 1 }, { opacity: 0.35 }], {
434
+ duration: 1800,
435
+ iterations: Infinity,
436
+ easing: 'ease-in-out',
437
+ })
438
+ )
439
+ }
440
+ }
441
+ }
442
+
443
+ /* ── watching the film build itself ──────────────────────────────────────── */
444
+
445
+ let watching = 0
446
+
447
+ /**
448
+ * Ring each element at the moment it FIRST APPEARS as the timeline runs.
449
+ *
450
+ * This is the reveal for a scene that already has a direction: the film's own
451
+ * choreography is the schedule, so nothing here re-animates anything — an
452
+ * element's entrance is already authored, and wiping it a second time would
453
+ * double the motion. We only mark it.
454
+ *
455
+ * `selectors` narrows it to the elements a write just touched, which is what
456
+ * turns "watch the film" into "watch MY change land".
457
+ */
458
+ export function watchAppearances(opts: { selectors?: string[]; hold?: number } = {}): void {
459
+ stopWatching()
460
+ if (reduced()) return
461
+
462
+ const pool: HTMLElement[] = []
463
+ if (opts.selectors?.length) {
464
+ for (const sel of opts.selectors) {
465
+ try {
466
+ pool.push(...document.querySelectorAll<HTMLElement>(sel))
467
+ } catch {
468
+ /* a malformed selector must never break the stage */
469
+ }
470
+ }
471
+ } else {
472
+ pool.push(...collect())
473
+ }
474
+ if (!pool.length) return
475
+
476
+ // getComputedStyle across a large scene every frame is real work, and we only
477
+ // need to catch a transition, not measure it — so sample, don't poll.
478
+ const stride = pool.length > 400 ? 6 : 3
479
+ const hold = opts.hold ?? 900
480
+ const seen = new WeakSet<HTMLElement>()
481
+ let frame = 0
482
+
483
+ const tick = () => {
484
+ if (!watching) return
485
+ if (frame++ % stride === 0) {
486
+ for (const el of pool) {
487
+ const now = onScreen(el)
488
+ if (now && !seen.has(el)) {
489
+ seen.add(el)
490
+ stamp(el, hold)
491
+ } else if (!now && seen.has(el)) {
492
+ seen.delete(el) // left the frame; ring it again if it comes back
493
+ }
494
+ }
495
+ }
496
+ watching = requestAnimationFrame(tick)
497
+ }
498
+ watching = requestAnimationFrame(tick)
499
+ }
500
+
501
+ export function stopWatching(): void {
502
+ if (watching) cancelAnimationFrame(watching)
503
+ watching = 0
504
+ }
505
+
506
+ /** A single ring, stamped in place and gone. */
507
+ function stamp(el: HTMLElement, hold: number): void {
508
+ const b = el.getBoundingClientRect()
509
+ if (b.width < 2 || b.height < 2) return
510
+ const r = document.createElement('div')
511
+ r.style.cssText =
512
+ `position:absolute;left:${b.left}px;top:${b.top}px;` +
513
+ `width:${b.width}px;height:${b.height}px;` +
514
+ `border-radius:${getComputedStyle(el).borderRadius};` +
515
+ `outline:1.5px solid ${RING_COLOR};outline-offset:0;opacity:0;`
516
+ ringLayer().appendChild(r)
517
+ const a = r.animate(
518
+ [{ opacity: 0 }, { opacity: 1, offset: 0.18 }, { opacity: 1, offset: 0.6 }, { opacity: 0 }],
519
+ { duration: hold, easing: EASE, fill: 'both' }
520
+ )
521
+ ringAnims.push(a)
522
+ a.finished.then(() => r.remove()).catch(() => r.remove())
523
+ }
524
+
525
+ /* ── the creation montage ────────────────────────────────────────────────── */
526
+
527
+ type StageWindow = Window & {
528
+ __renderAt?: (t: number) => void
529
+ __duration?: number
530
+ }
531
+
532
+ let montageToken = 0
533
+ let montagePaused = false
534
+
535
+ /** Hold the montage where it is. The loop parks between stops, so the frame you
536
+ * are looking at stays exactly as it is — nothing is animating behind it. */
537
+ export function setMontagePaused(on: boolean): void {
538
+ montagePaused = on
539
+ }
540
+ export function isMontagePaused(): boolean {
541
+ return montagePaused
542
+ }
543
+ const waitWhilePaused = async (token: number): Promise<boolean> => {
544
+ while (montagePaused && token === montageToken) await sleep(80)
545
+ return token === montageToken
546
+ }
547
+
548
+ /**
549
+ * When does each element FIRST come on screen?
550
+ *
551
+ * Answered by seeking, not by watching playback — the whole engine is a pure
552
+ * function of timeline position, so stepping the playhead and reading the DOM
553
+ * gives an exact answer with no clock involved. Elements drop out of the pool
554
+ * as they're found, so the sweep gets cheaper the further it goes.
555
+ */
556
+ /**
557
+ * Layout has to be SETTLED before scanning. onScreen requires a box of at least
558
+ * 2px, so text whose webfont hasn't landed measures as nothing and never earns
559
+ * a creation moment — which is why a first run could miss whole components and
560
+ * a second run, with everything settled, found them.
561
+ */
562
+ export async function settleLayout(): Promise<void> {
563
+ try {
564
+ await (document.fonts?.ready ?? Promise.resolve())
565
+ } catch {
566
+ /* no font API */
567
+ }
568
+ await raf()
569
+ await raf()
570
+ }
571
+
572
+ export function scanAppearances(selectors?: string[], step = 1 / 12): Map<HTMLElement, number> {
573
+ const w = window as StageWindow
574
+ const found = new Map<HTMLElement, number>()
575
+ const dur = Number(w.__duration) || 0
576
+ if (!dur || !w.__renderAt) return found
577
+
578
+ const pool = new Set<HTMLElement>(collect(selectors))
579
+ const back = Number((window as unknown as { __timeline?: { time(): number } }).__timeline?.time() ?? 0)
580
+
581
+ for (let t = 0; t <= dur && pool.size; t += step) {
582
+ w.__renderAt(t)
583
+ for (const el of [...pool]) {
584
+ if (onScreen(el)) {
585
+ found.set(el, t)
586
+ pool.delete(el)
587
+ }
588
+ }
589
+ }
590
+ w.__renderAt(back) // leave the playhead where we found it
591
+ return found
592
+ }
593
+
594
+ /**
595
+ * Step through the film stopping ONLY at the moments something is created, and
596
+ * ring what arrived. Deliberately choppy: this is a montage of creation, not
597
+ * playback, and every element gets its moment instead of whichever ones a
598
+ * sampling loop happened to catch.
599
+ *
600
+ * Each stop lands `settle` seconds after the appearance so the element has
601
+ * finished its entrance — a ring measured mid-entrance sits on a box the
602
+ * element has already moved away from, which is what made watch mode look
603
+ * out of register.
604
+ */
605
+ export async function montage(
606
+ opts: {
607
+ selectors?: string[]
608
+ /** minimum time to sit on each creation moment */
609
+ hold?: number
610
+ /** seconds past the appearance to park, so the entrance has finished */
611
+ settle?: number
612
+ maxStops?: number
613
+ step?: number
614
+ /** which algorithm finds the moments */
615
+ algo?: MomentAlgo
616
+ /** play the film between moments instead of cutting to the next one.
617
+ * Safe under accumulation: an unreleased element stays clipped, so the
618
+ * film cannot show one early and there is nothing to rewind. */
619
+ playBetween?: boolean
620
+ /** how the arrivals resolve — the cascade, with its tuned defaults */
621
+ reveal?: RevealOptions
622
+ /** EXPERIMENTAL, off by default. Suppress everything and release each
623
+ * element at its own moment, so the cascade is its only appearance.
624
+ * It reads well when it works, but it means owning visibility for
625
+ * elements the scene owns, and real scenes break it in a dozen ways:
626
+ * full-bleed grounds become curtains, children of transparent parents get
627
+ * mis-dated, elements that leave and return get stranded. Ring-only has
628
+ * none of that surface. */
629
+ accumulate?: boolean
630
+ /** Minimum seconds between creation moments. This matters far more under
631
+ * accumulation than it looks: house style staggers lines ~0.2s apart, so
632
+ * nearly every element earns its own appearance time, and one-at-a-time
633
+ * release leaves the canvas empty for minutes. Folding a beat's worth of
634
+ * arrivals into one moment is what makes the scene visibly fill. */
635
+ minGap?: number
636
+ } = {}
637
+ ): Promise<void> {
638
+ const w = window as StageWindow
639
+ if (!w.__renderAt || reduced()) return
640
+ stopWatching()
641
+ clearWorking()
642
+
643
+ const token = ++montageToken
644
+ await settleLayout()
645
+ if (token !== montageToken) return
646
+ const hold = opts.hold ?? 850
647
+ const settle = opts.settle ?? 0.45
648
+ const maxStops = opts.maxStops ?? 999
649
+
650
+ const stops = findMoments(opts.algo ?? 'changes', opts.selectors, opts.step)
651
+ if (!stops.length) return
652
+ const pool = collect(opts.selectors)
653
+
654
+ // Nothing may be seen before its own moment. Hide EVERYTHING that will ever
655
+ // appear, once, up front — then release each element at its creation moment
656
+ // and leave it released. The scene accumulates in front of the viewer, and
657
+ // an element's only possible appearance is its cascade.
658
+ //
659
+ // Safe here in a way it never was during playback: we are seeking between
660
+ // frozen frames, so suppression is not racing an entrance.
661
+ if (opts.accumulate !== false) hideUntilRevealed(pool)
662
+
663
+ let at = 0
664
+ for (const stop of stops) {
665
+ if (!(await waitWhilePaused(token))) return
666
+ if (token !== montageToken) return
667
+
668
+ const target = stop.at + settle
669
+ if (opts.playBetween && target > at) {
670
+ // Roll the film to the next moment at real speed. Unreleased elements are
671
+ // still clipped, so nothing arrives ahead of its own cascade.
672
+ let prevMs = performance.now()
673
+ while (at < target) {
674
+ if (!(await waitWhilePaused(token))) return
675
+ await raf()
676
+ if (token !== montageToken) return
677
+ const nowMs = performance.now()
678
+ at = Math.min(target, at + Math.min(0.05, (nowMs - prevMs) / 1000))
679
+ prevMs = nowMs
680
+ w.__renderAt(at)
681
+ }
682
+ }
683
+ at = target
684
+ // Park just past the LAST arrival so every element is settled — a ring
685
+ // measured mid-entrance sits on a box the element has already left.
686
+ w.__renderAt(target)
687
+ await raf()
688
+ if (token !== montageToken) return
689
+
690
+ // No awaited frame between the seek and the reveal: one task, so the
691
+ // browser never paints the un-clipped state in between.
692
+ const run = revealElements(stop.els, {
693
+ ...opts.reveal,
694
+ preHidden: opts.accumulate !== false,
695
+ })
696
+ await sleep(Math.max(hold, run + 260))
697
+ }
698
+ // Whatever is left holding a clip — an element whose stop was never reached,
699
+ // or one filtered out of its own cascade — is handed back here.
700
+ if (token === montageToken) clearWorking()
701
+ }
702
+
703
+ /**
704
+ * Is this element scenery rather than a component?
705
+ *
706
+ * Full-bleed layers — chapter grounds, colour fields, `absolute inset-0`
707
+ * backdrops — cover the frame, so clipping one is not a reveal, it is a
708
+ * curtain: the screen goes black and the wipe reads as the page filling from
709
+ * the top. They are never what the viewer is waiting to see arrive, so they
710
+ * stay visible and their CHILDREN do the arriving.
711
+ */
712
+ const isScenery = (el: HTMLElement): boolean => {
713
+ const b = el.getBoundingClientRect()
714
+ const stage = innerWidth * innerHeight
715
+ return stage > 0 && b.width * b.height >= stage * 0.85
716
+ }
717
+
718
+ /** Clip elements out of sight without touching opacity, and remember what they
719
+ * carried so every one can be handed back. */
720
+ export function hideUntilRevealed(list: Iterable<HTMLElement>): void {
721
+ for (const el of list) {
722
+ if (isScenery(el)) continue
723
+ remember(el, ['clipPath'])
724
+ el.style.clipPath = 'inset(0 0 100% 0)'
725
+ }
726
+ }
727
+
728
+ /* ── finding the moments: four algorithms ────────────────────────────────── */
729
+
730
+ export type MomentAlgo = 'appear' | 'beats' | 'changes' | 'structure'
731
+
732
+ type Timelineish = {
733
+ getChildren?: (nested?: boolean, tweens?: boolean, timelines?: boolean) => Array<{
734
+ startTime: () => number
735
+ duration: () => number
736
+ targets?: () => unknown[]
737
+ }>
738
+ }
739
+
740
+ /** Signature of what is drawn right now, per participating element. */
741
+ const frameState = (pool: HTMLElement[]): Map<HTMLElement, string> => {
742
+ const m = new Map<HTMLElement, string>()
743
+ for (const el of pool) {
744
+ if (!onScreen(el)) continue
745
+ const b = el.getBoundingClientRect()
746
+ m.set(el, `${Math.round(b.left / 8)},${Math.round(b.top / 8)},${Math.round(b.width / 8)},${Math.round(b.height / 8)}`)
747
+ }
748
+ return m
749
+ }
750
+
751
+ /**
752
+ * A child can never arrive before its own parent.
753
+ *
754
+ * onScreen is deliberately generous — it does not walk ancestors, because being
755
+ * strict drops a whole component whenever its container is mid-fade at a
756
+ * sampled instant. The cost of that generosity is mis-dated children: opacity
757
+ * is not inherited as a computed value, so a child of an autoAlpha:0 parent
758
+ * reports its own opacity of 1 and looks visible while being invisible.
759
+ *
760
+ * Clamping afterwards fixes it without losing anything — a pass over the moment
761
+ * list rather than a DOM walk during the scan. Shallow to deep, so a chain of
762
+ * ancestors settles in one sweep.
763
+ */
764
+ function clampToAncestors(times: Map<HTMLElement, number>, participates: (n: Element) => boolean): void {
765
+ const depth = new Map<HTMLElement, number>()
766
+ for (const el of times.keys()) depth.set(el, depthOf(el, participates))
767
+ const shallowFirst = [...times.keys()].sort((a, b) => (depth.get(a) ?? 0) - (depth.get(b) ?? 0))
768
+ for (const el of shallowFirst) {
769
+ let p = el.parentElement
770
+ while (p && !times.has(p as HTMLElement)) p = p.parentElement
771
+ if (!p) continue
772
+ const parentAt = times.get(p as HTMLElement)
773
+ const own = times.get(el)
774
+ if (parentAt != null && own != null && own < parentAt) times.set(el, parentAt)
775
+ }
776
+ }
777
+
778
+ /** 1 · APPEAR — first time each element is drawn. Cheap and predictable, but it
779
+ * only ever fires once per element: a container that exists from t=0 collapses
780
+ * into the opening moment and its real section never gets one. */
781
+ function byAppearance(pool: HTMLElement[], step: number): Stop[] {
782
+ const w = window as StageWindow
783
+ const dur = Number(w.__duration) || 0
784
+ const found = new Map<HTMLElement, number>()
785
+ const pending = new Set(pool)
786
+ for (let t = 0; t <= dur && pending.size; t += step) {
787
+ w.__renderAt!(t)
788
+ for (const el of [...pending]) if (onScreen(el)) { found.set(el, t); pending.delete(el) }
789
+ }
790
+ clampToAncestors(found, (n) => n.hasAttribute?.('data-reveal') || hasHook(n))
791
+ return groupStops(found, { bucket: 0.2, maxStops: 999 })
792
+ }
793
+
794
+ /** 2 · BEATS — read the author's structure straight off the timeline. Every
795
+ * tween knows when it starts and what it targets, so clusters of tween starts
796
+ * ARE the beats. No seeking, nothing to miss, and stops land where the
797
+ * direction meant them to. */
798
+ function byBeats(pool: HTMLElement[], gap = 0.35): Stop[] {
799
+ const tl = (window as unknown as { __timeline?: Timelineish }).__timeline
800
+ const kids = tl?.getChildren?.(false, true, true) ?? []
801
+ const inPool = new Set(pool)
802
+ const events: Array<{ at: number; end: number; els: HTMLElement[] }> = []
803
+ for (const k of kids) {
804
+ const els = (k.targets?.() ?? []).filter(
805
+ (n): n is HTMLElement => n instanceof HTMLElement && inPool.has(n)
806
+ )
807
+ if (!els.length) continue
808
+ const at = k.startTime()
809
+ events.push({ at, end: at + (k.duration?.() ?? 0), els })
810
+ }
811
+ events.sort((a, b) => a.at - b.at)
812
+
813
+ const stops: Stop[] = []
814
+ for (const e of events) {
815
+ const prev = stops[stops.length - 1]
816
+ if (prev && e.at - prev.from < gap) {
817
+ prev.at = Math.max(prev.at, e.end)
818
+ prev.els.push(...e.els)
819
+ } else {
820
+ stops.push({ at: e.end, from: e.at, els: [...e.els] })
821
+ }
822
+ }
823
+ for (const s of stops) s.els = [...new Set(s.els)]
824
+ return stops
825
+ }
826
+
827
+ /** 3 · CHANGES — every transition into view, not just the first. Catches an
828
+ * element that leaves and returns, which APPEAR silently drops. */
829
+ function byChanges(pool: HTMLElement[], step: number): Stop[] {
830
+ const w = window as StageWindow
831
+ const dur = Number(w.__duration) || 0
832
+ const seen = new Map<HTMLElement, boolean>()
833
+ const hits: Array<[HTMLElement, number]> = []
834
+ for (let t = 0; t <= dur; t += step) {
835
+ w.__renderAt!(t)
836
+ for (const el of pool) {
837
+ const now = onScreen(el)
838
+ if (now && !seen.get(el)) hits.push([el, t])
839
+ seen.set(el, now)
840
+ }
841
+ }
842
+ // Clamp on FIRST entry per element, then re-apply the shift to its later
843
+ // entries, so a re-appearance stays relative to the corrected first one.
844
+ const first = new Map<HTMLElement, number>()
845
+ for (const [el, t] of hits) if (!first.has(el)) first.set(el, t)
846
+ const before = new Map(first)
847
+ clampToAncestors(first, (n) => n.hasAttribute?.('data-reveal') || hasHook(n))
848
+ const shift = new Map<HTMLElement, number>()
849
+ for (const [el, t] of first) shift.set(el, t - (before.get(el) ?? t))
850
+
851
+ const byBucket = new Map<string, HTMLElement[]>()
852
+ for (const [el, t] of hits) {
853
+ const key = (Math.round((t + (shift.get(el) ?? 0)) / 0.2) * 0.2).toFixed(2)
854
+ const at = byBucket.get(key)
855
+ if (at) at.push(el)
856
+ else byBucket.set(key, [el])
857
+ }
858
+ return [...byBucket.entries()]
859
+ .map(([k, els]) => ({ at: Number(k), from: Number(k), els: [...new Set(els)] }))
860
+ .sort((a, b) => a.at - b.at)
861
+ }
862
+
863
+ /** 4 · STRUCTURE — diff the whole frame and cut where it changes sharply. Finds
864
+ * SECTIONS rather than elements: a beat boundary is where a lot of the frame
865
+ * turns over at once. */
866
+ function byStructure(pool: HTMLElement[], step: number, threshold = 0.22): Stop[] {
867
+ const w = window as StageWindow
868
+ const dur = Number(w.__duration) || 0
869
+ let prev = new Map<HTMLElement, string>()
870
+ const stops: Stop[] = []
871
+ for (let t = 0; t <= dur; t += step) {
872
+ w.__renderAt!(t)
873
+ const now = frameState(pool)
874
+ let changed: HTMLElement[] = []
875
+ for (const [el, sig] of now) if (prev.get(el) !== sig) changed.push(el)
876
+ for (const el of prev.keys()) if (!now.has(el)) changed.push(el)
877
+ const churn = changed.length / Math.max(8, now.size || prev.size || 1)
878
+ if (churn >= threshold) {
879
+ const arriving = changed.filter((el) => now.has(el))
880
+ if (arriving.length) stops.push({ at: t, from: t, els: arriving })
881
+ }
882
+ prev = now
883
+ }
884
+ // collapse runs of consecutive churny frames into one section
885
+ const merged: Stop[] = []
886
+ for (const s of stops) {
887
+ const p = merged[merged.length - 1]
888
+ if (p && s.at - p.at < 0.5) { p.at = s.at; p.els = [...new Set([...p.els, ...s.els])] }
889
+ else merged.push(s)
890
+ }
891
+ return merged
892
+ }
893
+
894
+ export function findMoments(algo: MomentAlgo, selectors?: string[], step = 1 / 12): Stop[] {
895
+ const w = window as StageWindow
896
+ const pool = collect(selectors)
897
+ if (!pool.length || !w.__renderAt) return []
898
+ const back = Number((window as unknown as { __timeline?: { time(): number } }).__timeline?.time() ?? 0)
899
+ let stops: Stop[]
900
+ switch (algo) {
901
+ case 'beats': stops = byBeats(pool); break
902
+ case 'changes': stops = byChanges(pool, step); break
903
+ case 'structure': stops = byStructure(pool, step); break
904
+ default: stops = byAppearance(pool, step)
905
+ }
906
+ w.__renderAt(back)
907
+ ;(window as unknown as { __moments?: unknown }).__moments = {
908
+ algo, stops: stops.length, elements: pool.length,
909
+ span: stops.length ? [stops[0].at, stops[stops.length - 1].at] : [0, 0],
910
+ }
911
+ return stops
912
+ }
913
+
914
+ /* ── the revision digest ─────────────────────────────────────────────────── */
915
+
916
+ export type DigestEntry = {
917
+ /** Stable across revisions where possible: the editor's path id, else a DOM
918
+ * path. Path ids survive edits in place but NOT a wholesale component
919
+ * rewrite, which is why every entry also carries the fallback-match fields
920
+ * below (tag / text / rect) — see matching notes in the Studio consumer. */
921
+ key: string
922
+ /** Fallback identity, in descending reliability. */
923
+ tag: string
924
+ /** OWN text only. textContent includes descendants, so a change in one leaf
925
+ * would otherwise look like a change in every ancestor above it. */
926
+ text: string
927
+ /** EVERY non-studio data-* attribute, as `name=value`, SORTED.
928
+ *
929
+ * For agent-authored drafts this is the real primary key, not a fallback:
930
+ * such scenes carry no studio ids at all, and semantic hooks (data-card,
931
+ * data-sync-button) survive a wholesale component rewrite because the
932
+ * direction.ts targets them — while the DOM path underneath changes
933
+ * completely. Recommended match order: studioObjectId -> hooks ->
934
+ * (tag + normalized text + rect at tolerance) -> path. Path LAST.
935
+ *
936
+ * Sorted deliberately, and ALL of them rather than the first: "first"
937
+ * depends on attribute order, so reordering attributes in a template would
938
+ * silently rekey every element. Do not change this to a single value. */
939
+ hooks: string[]
940
+ /** First time this is drawn, clamped so a child never precedes its parent.
941
+ *
942
+ * null means UNWITNESSED: the element exists in the markup but the film
943
+ * never draws it at any sampled instant. Same concept as the text census's
944
+ * "unwitnessed line" (copy the camera never shows), one level down — so we
945
+ * use the same word: census says unwitnessed LINE, digest says unwitnessed
946
+ * ELEMENT. It is a finding worth surfacing to authors, not an error. */
947
+ appears: number | null
948
+ /** Where it sits once it has arrived, in stage coordinates. */
949
+ rect: { x: number; y: number; w: number; h: number } | null
950
+ depth: number
951
+ parent: string | null
952
+ }
953
+
954
+ export type SceneDigest = {
955
+ /** Which revision this describes, so the reply is self-labelling and a parent
956
+ * holding two hot-swapped bundles cannot mis-file its cache. null when the
957
+ * bundle was built without the define (local scenes, older previews). */
958
+ sourceRevision: number | null
959
+ duration: number
960
+ /** wall-clock ms the scan took, so the Studio can decide whether to cache */
961
+ scanMs: number
962
+ entries: DigestEntry[]
963
+ }
964
+
965
+ /** Elements that render nothing but can still carry a data-* attribute. A
966
+ * bundled-font <style> block would otherwise arrive as an "element" whose
967
+ * text is several hundred lines of CSS. */
968
+ const NON_VISUAL = new Set(['style', 'script', 'link', 'meta', 'title', 'head', 'base'])
969
+
970
+ const ownText = (el: HTMLElement): string =>
971
+ [...el.childNodes]
972
+ .filter((n) => n.nodeType === 3)
973
+ .map((n) => n.nodeValue ?? '')
974
+ .join('')
975
+ .replace(/\s+/g, ' ')
976
+ .trim()
977
+ // this is a matching fingerprint, not content — long is useless and costly
978
+ .slice(0, 200)
979
+
980
+ const domPath = (el: HTMLElement): string => {
981
+ const parts: string[] = []
982
+ for (let n: HTMLElement | null = el; n && n !== document.body; n = n.parentElement) {
983
+ const p = n.parentElement
984
+ parts.unshift(p ? String([...p.children].indexOf(n)) : '0')
985
+ }
986
+ return parts.join('.')
987
+ }
988
+
989
+ const keyOf = (el: HTMLElement): string =>
990
+ el.dataset.studioObjectId || el.dataset.studioTextId || `path:${domPath(el)}`
991
+
992
+ const hooksOf = (el: HTMLElement): string[] => {
993
+ const out: string[] = []
994
+ for (const k in el.dataset) {
995
+ if (k === 'reveal' || k.startsWith('studio')) continue
996
+ out.push(`${k}=${el.dataset[k]}`)
997
+ }
998
+ return out.sort()
999
+ }
1000
+
1001
+ /** Baked in at build time by buildPreview. Undefined for local scenes. */
1002
+ const bundleRevision = (): number | null => {
1003
+ const raw = (import.meta as unknown as { env?: Record<string, string> }).env?.VITE_SOURCE_REVISION
1004
+ const n = Number(raw)
1005
+ return Number.isFinite(n) && raw !== '' && raw != null ? n : null
1006
+ }
1007
+
1008
+ /**
1009
+ * Everything the Studio needs to diff one revision against another.
1010
+ *
1011
+ * Computed by SEEKING rather than by watching playback: the engine is a pure
1012
+ * function of timeline position, so this is exact rather than sampled-and-hoped,
1013
+ * and it runs inside the bundle because a cross-origin sandboxed preview cannot
1014
+ * be read from the parent at all.
1015
+ *
1016
+ * Cost is roughly (duration × 1/step) seeks with a shrinking element pool. The
1017
+ * result is immutable for a given revision, so the caller should cache it by
1018
+ * (draftId, revision) and never compute it twice.
1019
+ */
1020
+ export function digestScene(opts: { selectors?: string[]; step?: number } = {}): SceneDigest {
1021
+ const t0 = performance.now()
1022
+ const w = window as StageWindow
1023
+ const step = opts.step ?? 1 / 12
1024
+ const duration = Number(w.__duration) || 0
1025
+ const pool = collect(opts.selectors).filter(
1026
+ (el) => !NON_VISUAL.has(el.tagName.toLowerCase()) && document.body.contains(el)
1027
+ )
1028
+ const participates = (n: Element) => n.hasAttribute?.('data-reveal') || hasHook(n)
1029
+
1030
+ const appears = new Map<HTMLElement, number>()
1031
+ const rects = new Map<HTMLElement, { x: number; y: number; w: number; h: number }>()
1032
+
1033
+ if (duration && w.__renderAt) {
1034
+ const back = Number((window as unknown as { __timeline?: { time(): number } }).__timeline?.time() ?? 0)
1035
+ const pending = new Set(pool)
1036
+ for (let t = 0; t <= duration && pending.size; t += step) {
1037
+ w.__renderAt(t)
1038
+ for (const el of [...pending]) {
1039
+ if (!onScreen(el)) continue
1040
+ const b = el.getBoundingClientRect()
1041
+ appears.set(el, t)
1042
+ rects.set(el, { x: b.left, y: b.top, w: b.width, h: b.height })
1043
+ pending.delete(el)
1044
+ }
1045
+ }
1046
+ w.__renderAt(back)
1047
+ clampToAncestors(appears, participates)
1048
+ }
1049
+
1050
+ const keys = new Map<HTMLElement, string>()
1051
+ for (const el of pool) keys.set(el, keyOf(el))
1052
+
1053
+ const entries: DigestEntry[] = pool.map((el) => {
1054
+ let p = el.parentElement
1055
+ while (p && !keys.has(p as HTMLElement)) p = p.parentElement
1056
+ return {
1057
+ key: keys.get(el)!,
1058
+ tag: el.tagName.toLowerCase(),
1059
+ text: ownText(el),
1060
+ hooks: hooksOf(el),
1061
+ appears: appears.has(el) ? appears.get(el)! : null,
1062
+ rect: rects.get(el) ?? null,
1063
+ depth: depthOf(el, participates),
1064
+ parent: p ? keys.get(p as HTMLElement)! : null,
1065
+ }
1066
+ })
1067
+
1068
+ return { sourceRevision: bundleRevision(), duration, scanMs: Math.round(performance.now() - t0), entries }
1069
+ }
1070
+
1071
+ export function stopMontage(): void {
1072
+ montageToken += 1
1073
+ montagePaused = false
1074
+ // A montage suppresses the whole scene, so abandoning one without restoring
1075
+ // would leave it blank.
1076
+ clearWorking()
1077
+ }
1078
+
1079
+ const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()))
1080
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
1081
+
1082
+ /**
1083
+ * Fold raw appearance times into a handful of creation MOMENTS.
1084
+ *
1085
+ * `bucket` treats near-simultaneous arrivals as one event; `minGap` then keeps
1086
+ * moments far enough apart that the film has room to play between them —
1087
+ * without it a dense scene produces a hundred stops and reads as a stutter
1088
+ * rather than as building.
1089
+ */
1090
+ type Stop = {
1091
+ /** the LAST appearance in the group — stopping here means every element in
1092
+ * it is genuinely on screen, which stopping at the first would not */
1093
+ at: number
1094
+ /** the FIRST appearance — when suppression has to begin */
1095
+ from: number
1096
+ els: HTMLElement[]
1097
+ }
1098
+
1099
+ function groupStops(
1100
+ found: Map<HTMLElement, number>,
1101
+ opts: { bucket?: number; minGap?: number; maxStops?: number } = {}
1102
+ ): Stop[] {
1103
+ const bucket = opts.bucket ?? 0.15
1104
+ const minGap = opts.minGap ?? 0
1105
+ const maxStops = opts.maxStops ?? 999
1106
+
1107
+ const byTime = new Map<number, HTMLElement[]>()
1108
+ for (const [el, t] of found) {
1109
+ const key = Math.round(t / bucket) * bucket
1110
+ const at = byTime.get(key)
1111
+ if (at) at.push(el)
1112
+ else byTime.set(key, [el])
1113
+ }
1114
+
1115
+ let stops: Stop[] = [...byTime.entries()]
1116
+ .sort((a, b) => a[0] - b[0])
1117
+ .map(([t, els]) => ({ at: t, from: t, els }))
1118
+
1119
+ const fold = (a: Stop, b: Stop): Stop => ({
1120
+ at: Math.max(a.at, b.at),
1121
+ from: Math.min(a.from, b.from),
1122
+ els: [...a.els, ...b.els],
1123
+ })
1124
+
1125
+ if (minGap > 0) {
1126
+ const kept: Stop[] = []
1127
+ for (const s of stops) {
1128
+ const prev = kept[kept.length - 1]
1129
+ if (prev && s.at - prev.at < minGap) kept[kept.length - 1] = fold(prev, s)
1130
+ else kept.push(s)
1131
+ }
1132
+ stops = kept
1133
+ }
1134
+
1135
+ // Merge neighbours rather than truncating, so coverage stays whole-film.
1136
+ while (stops.length > maxStops) {
1137
+ const merged: Stop[] = []
1138
+ for (let i = 0; i < stops.length; i += 2) {
1139
+ const a = stops[i]
1140
+ const b = stops[i + 1]
1141
+ merged.push(b ? fold(a, b) : a)
1142
+ }
1143
+ stops = merged
1144
+ }
1145
+ return stops
1146
+ }
1147
+
1148
+ /**
1149
+ * Play the film for real, and freeze only where something is created.
1150
+ *
1151
+ * Between moments this is ordinary playback — the scene's own choreography,
1152
+ * at speed. At a moment it stops, lets the arrivals cascade in, then picks up
1153
+ * from where it paused. That's the difference from the montage, which skips
1154
+ * everything between; here you still see the film, you just get a beat to
1155
+ * watch each piece land.
1156
+ *
1157
+ * The playhead is driven by renderAt from our own clock rather than by the
1158
+ * timeline's player, because a freeze has to land on an exact frame. The scene
1159
+ * stays a pure function of the time we hand it.
1160
+ */
1161
+ export async function playBuild(
1162
+ opts: {
1163
+ selectors?: string[]
1164
+ /** minimum seconds of playback between freezes */
1165
+ minGap?: number
1166
+ /** minimum ms to hold a freeze */
1167
+ hold?: number
1168
+ /** seconds past the appearance to freeze on */
1169
+ settle?: number
1170
+ maxStops?: number
1171
+ step?: number
1172
+ loop?: boolean
1173
+ reveal?: RevealOptions
1174
+ } = {}
1175
+ ): Promise<void> {
1176
+ const w = window as StageWindow & { __pause?: () => void }
1177
+ if (!w.__renderAt || reduced()) return
1178
+ stopWatching()
1179
+ clearWorking()
1180
+
1181
+ const token = ++montageToken
1182
+ await settleLayout()
1183
+ if (token !== montageToken) return
1184
+ const dur = Number(w.__duration) || 0
1185
+ if (!dur) return
1186
+
1187
+ const settle = opts.settle ?? 0.45
1188
+ const hold = opts.hold ?? 700
1189
+ const stops = groupStops(scanAppearances(opts.selectors, opts.step), {
1190
+ minGap: opts.minGap ?? 0.9,
1191
+ maxStops: opts.maxStops ?? 16,
1192
+ })
1193
+
1194
+ w.__pause?.() // only our clock drives the playhead from here
1195
+
1196
+ do {
1197
+ let t = 0
1198
+ let i = 0
1199
+ let prev = performance.now()
1200
+ clearWorking()
1201
+ w.__renderAt(0)
1202
+
1203
+ while (t < dur) {
1204
+ if (!(await waitWhilePaused(token))) return
1205
+ await raf()
1206
+ if (token !== montageToken) return
1207
+
1208
+ const now = performance.now()
1209
+ // Cap the step so a stalled tab — or a pause — doesn't jump the playhead
1210
+ // past a creation moment on resume.
1211
+ t += Math.min(0.05, (now - prev) / 1000)
1212
+ prev = now
1213
+
1214
+ const next = stops[i]
1215
+ // Stop past the LAST arrival in the group, so every element in it is
1216
+ // genuinely on screen when we mark it.
1217
+ const target = next ? Math.min(dur, next.at + settle) : Infinity
1218
+
1219
+ if (next && t >= target) {
1220
+ t = target
1221
+ w.__renderAt(t)
1222
+ await raf()
1223
+ if (token !== montageToken) return
1224
+ // Ring only. The scene already animated these in — the freeze is what
1225
+ // gives you time to see them, and re-animating would both duplicate
1226
+ // the entrance and require hiding elements we do not own.
1227
+ for (const el of next.els) stamp(el, hold)
1228
+ await sleep(hold + 160)
1229
+ if (token !== montageToken) return
1230
+ i += 1
1231
+ prev = performance.now()
1232
+ continue
1233
+ }
1234
+ w.__renderAt(t)
1235
+ }
1236
+ } while (opts.loop && token === montageToken)
1237
+
1238
+ if (token === montageToken) clearWorking()
1239
+ }
1240
+
1241
+ /** Elements the direction could target — the set a reveal participates on. */
1242
+ function collect(selectors?: string[]): HTMLElement[] {
1243
+ if (selectors?.length) {
1244
+ const out: HTMLElement[] = []
1245
+ for (const sel of selectors) {
1246
+ try {
1247
+ out.push(...document.querySelectorAll<HTMLElement>(sel))
1248
+ } catch {
1249
+ /* ignore a malformed selector */
1250
+ }
1251
+ }
1252
+ return out
1253
+ }
1254
+ return [...document.querySelectorAll<HTMLElement>('*')].filter(hasHook)
1255
+ }
1256
+
1257
+ // The local studio runs the stage same-origin and talks to it through direct
1258
+ // window access rather than the bridge (which stays dormant without a nonce),
1259
+ // so expose the same surface there. Cloud previews keep using the bridge.
1260
+ if (typeof window !== 'undefined') {
1261
+ ;(window as unknown as { __workingViews?: unknown }).__workingViews = {
1262
+ montage, stopMontage, setMontagePaused, playBuild, reveal, revealElements,
1263
+ setWorking, watchAppearances, stopWatching, findMoments, clearWorking,
1264
+ digestScene,
1265
+ clearRings,
1266
+ }
1267
+ }
1268
+
1269
+ /** Remove every trace, including the overlay node itself. */
1270
+ export function uninstallWorkingViews(): void {
1271
+ stopMontage()
1272
+ stopWatching()
1273
+ clearWorking()
1274
+ layer?.remove()
1275
+ layer = null
1276
+ }
1277
+
1278
+
1279
+ /* ------------------------------------------------------------------------- *
1280
+ * DRAFT-SIDE INSTALLER — appended by .build-draft-reveal.mjs
1281
+ *
1282
+ * Present only in the draft-shipped copy of this engine. The engine-side copy
1283
+ * is driven by bridge.ts instead; this block is what lets the same code answer
1284
+ * the Studio from inside a preview bundle built by the DEPLOYED engine.
1285
+ * ------------------------------------------------------------------------- */
1286
+
1287
+ declare global {
1288
+ interface Window {
1289
+ __stageBridgeVerbs?: Set<string>
1290
+ __revealDraftInstalled?: boolean
1291
+ }
1292
+ }
1293
+
1294
+ {
1295
+ const w = window as Window
1296
+
1297
+ // Guard 1 — the engine deploy has landed and bridge.ts owns these verbs now.
1298
+ // Standing down keeps a draft written before the deploy from double-handling
1299
+ // every command for the rest of its life.
1300
+ const engineOwnsVerbs = w.__stageBridgeVerbs?.has('stage-reveal') === true
1301
+
1302
+ // Guard 2 — self. A double import (two kits both pulling this in) must not
1303
+ // install two listeners; the second would run every reveal twice.
1304
+ const alreadyInstalled = w.__revealDraftInstalled === true
1305
+
1306
+ if (!engineOwnsVerbs && !alreadyInstalled) {
1307
+ w.__revealDraftInstalled = true
1308
+
1309
+ // Same derivation as bridge.ts:46. Dormant without a nonce, which is what
1310
+ // keeps the local same-origin studio on direct window access.
1311
+ const nonce = new URLSearchParams(window.location.hash.slice(1)).get('nonce')
1312
+
1313
+ if (nonce) {
1314
+ window.addEventListener('message', (event: MessageEvent<Record<string, any>>) => {
1315
+ const data = event.data
1316
+ if (!data || typeof data !== 'object' || data.nonce !== nonce) return
1317
+
1318
+ switch (data.type) {
1319
+ case 'stage-reveal':
1320
+ reveal(data.root ? document.querySelector(data.root) : document.body, data.options)
1321
+ break
1322
+ case 'stage-working':
1323
+ // An empty list is how the Studio says "finished working here".
1324
+ setWorking(data.selectors ?? [])
1325
+ break
1326
+ case 'stage-watch':
1327
+ if (data.on) watchAppearances({ selectors: data.selectors })
1328
+ else stopWatching()
1329
+ break
1330
+ case 'stage-montage':
1331
+ if (data.on) void montage({ selectors: data.selectors, hold: data.hold, algo: data.algo,
1332
+ playBetween: data.playBetween, reveal: data.reveal })
1333
+ else stopMontage()
1334
+ break
1335
+ case 'stage-build':
1336
+ if (data.on) void playBuild({ selectors: data.selectors, loop: data.loop })
1337
+ else stopMontage()
1338
+ break
1339
+ case 'stage-montage-pause':
1340
+ setMontagePaused(data.on)
1341
+ break
1342
+ case 'stage-clear':
1343
+ // Must be handled BEFORE any seek: a reveal restores the values it
1344
+ // captured when it started, so one still running across a seek
1345
+ // writes the old moment's state onto the new frame.
1346
+ clearWorking()
1347
+ break
1348
+ case 'stage-digest': {
1349
+ // Answered on the same channel, token echoed, so a parent awaiting
1350
+ // two revisions can tell the replies apart.
1351
+ const d = digestScene({ selectors: data.selectors, step: data.step })
1352
+ window.parent.postMessage(
1353
+ { type: 'stage-digest-result', nonce, token: data.token ?? null, ...d }, '*')
1354
+ break
1355
+ }
1356
+ default:
1357
+ break
1358
+ }
1359
+ })
1360
+ }
1361
+ }
1362
+ }
1363
+
1364
+ export {}