@impractical-ai/motion 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/engine.d.ts +65 -1
- package/assets/impractical/SKILL.md +35 -263
- package/dist/{api-DR3NJ7TI.js → api-NVZQJBVP.js} +2 -2
- package/dist/{chunk-Z74SLKEU.js → chunk-H5TR3QJN.js} +6 -3
- package/dist/{chunk-MQKW7NFI.js → chunk-KI5E2464.js} +1 -1
- package/dist/{chunk-M7SHFM25.js → chunk-NG3Q3BYW.js} +57 -4
- package/dist/{commands-R3LJU2BW.js → commands-56WH3ZVJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/{stdio-XUVQKDCB.js → stdio-MJ4LTXME.js} +2717 -1454
- package/package.json +3 -3
- package/assets/kits/actions.ts +0 -803
- package/assets/kits/ensemble.ts +0 -483
- package/assets/kits/highlight.ts +0 -374
- package/assets/kits/impact.ts +0 -103
- package/assets/kits/kits.ts +0 -343
- package/assets/kits/looks.ts +0 -935
- package/assets/kits/morphkit.ts +0 -438
- package/assets/kits/reveal.ts +0 -1364
- package/assets/kits/sentence.ts +0 -218
- package/assets/kits/streamkit.ts +0 -195
package/assets/kits/reveal.ts
DELETED
|
@@ -1,1364 +0,0 @@
|
|
|
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 {}
|