saccade 0.0.3 → 0.2.0

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,1819 @@
1
+ // src/core/timing.ts
2
+ var MEDIA_RATE_MIN = 0.0625;
3
+ var MEDIA_RATE_MAX = 16;
4
+ var ZERO_SPEED_DIVISOR = 1e-4;
5
+ var TimingController = class {
6
+ constructor() {
7
+ this.speed = 1;
8
+ this.virtualBaseline = 0;
9
+ this.intervalMap = /* @__PURE__ */ new Map();
10
+ this.nextIntervalId = 1e6;
11
+ this._origAnimate = null;
12
+ this.installed = false;
13
+ this.animPollId = 0;
14
+ this.gsapInstance = null;
15
+ // WeakMap tracking for animations and media
16
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
17
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
18
+ this._raf = requestAnimationFrame.bind(window);
19
+ this._caf = cancelAnimationFrame.bind(window);
20
+ this._setTimeout = setTimeout.bind(window);
21
+ this._clearTimeout = clearTimeout.bind(window);
22
+ this._setInterval = setInterval.bind(window);
23
+ this._clearInterval = clearInterval.bind(window);
24
+ this._perfNow = performance.now.bind(performance);
25
+ this._dateNow = Date.now;
26
+ this.realBaseline = this._perfNow();
27
+ this.dateRealBaseline = this._dateNow();
28
+ this.dateVirtualBaseline = this.dateRealBaseline;
29
+ }
30
+ getVirtualTime() {
31
+ const realElapsed = this._perfNow() - this.realBaseline;
32
+ return this.virtualBaseline + realElapsed * this.speed;
33
+ }
34
+ getVirtualDateNow() {
35
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
36
+ return this.dateVirtualBaseline + realElapsed * this.speed;
37
+ }
38
+ reanchor() {
39
+ const virtualNow = this.getVirtualTime();
40
+ this.realBaseline = this._perfNow();
41
+ this.virtualBaseline = virtualNow;
42
+ const virtualDateNow = this.getVirtualDateNow();
43
+ this.dateRealBaseline = this._dateNow();
44
+ this.dateVirtualBaseline = virtualDateNow;
45
+ }
46
+ /** Effective speed divisor — avoids division by zero at speed=0. */
47
+ get speedDivisor() {
48
+ return this.speed || ZERO_SPEED_DIVISOR;
49
+ }
50
+ /** Install timing patches. Safe to call multiple times. */
51
+ install() {
52
+ if (this.installed) return;
53
+ if (window.__saccadeInstalled) return;
54
+ window.__saccadeInstalled = true;
55
+ this.installed = true;
56
+ const self = this;
57
+ window.__LAPSE_ORIGINAL_RAF__ = this._raf;
58
+ const origAnimate = Element.prototype.animate;
59
+ this._origAnimate = origAnimate;
60
+ Element.prototype.animate = function(...args) {
61
+ const anim = origAnimate.apply(this, args);
62
+ if (self.speed !== 1) {
63
+ const originalRate = anim.playbackRate;
64
+ const applied = originalRate * (self.speed || 1e-3);
65
+ anim.playbackRate = applied;
66
+ self.trackedAnims.set(anim, { original: originalRate, applied });
67
+ }
68
+ return anim;
69
+ };
70
+ performance.now = () => self.getVirtualTime();
71
+ Date.now = () => self.getVirtualDateNow();
72
+ window.requestAnimationFrame = (callback) => {
73
+ return self._raf(() => {
74
+ if (self.speed === 0) {
75
+ window.requestAnimationFrame(callback);
76
+ return;
77
+ }
78
+ callback(self.getVirtualTime());
79
+ });
80
+ };
81
+ window.cancelAnimationFrame = this._caf;
82
+ window.setTimeout = ((handler, delay, ...args) => {
83
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
84
+ return self._setTimeout(handler, scaledDelay, ...args);
85
+ });
86
+ window.clearTimeout = this._clearTimeout;
87
+ window.setInterval = ((handler, delay, ...args) => {
88
+ const id = self.nextIntervalId++;
89
+ const baseDelay = delay ?? 0;
90
+ function tick() {
91
+ const scaledDelay = baseDelay / self.speedDivisor;
92
+ const realId = self._setTimeout(() => {
93
+ if (typeof handler === "function") {
94
+ ;
95
+ handler(...args);
96
+ }
97
+ if (self.intervalMap.has(id)) tick();
98
+ }, scaledDelay);
99
+ const entry = self.intervalMap.get(id);
100
+ if (entry) entry.realId = realId;
101
+ }
102
+ self.intervalMap.set(id, { handler, delay: baseDelay, realId: 0 });
103
+ tick();
104
+ return id;
105
+ });
106
+ window.clearInterval = ((id) => {
107
+ if (id == null) return;
108
+ const entry = self.intervalMap.get(id);
109
+ if (entry) {
110
+ self._clearTimeout(entry.realId);
111
+ self.intervalMap.delete(id);
112
+ } else {
113
+ self._clearInterval(id);
114
+ }
115
+ });
116
+ this.startAnimationPoll();
117
+ }
118
+ /** Set playback speed. Requires install() first. */
119
+ setSpeed(newSpeed) {
120
+ if (!this.installed) this.install();
121
+ this.reanchor();
122
+ this.speed = newSpeed;
123
+ this.patchAnimations();
124
+ this.patchMedia();
125
+ this.patchGSAP();
126
+ }
127
+ getSpeed() {
128
+ return this.speed;
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Animation polling — per-frame via original rAF
132
+ // ---------------------------------------------------------------------------
133
+ startAnimationPoll() {
134
+ const poll = () => {
135
+ if (!this.installed) return;
136
+ this.patchAnimations();
137
+ this.patchMedia();
138
+ this.animPollId = this._raf(poll);
139
+ };
140
+ this.animPollId = this._raf(poll);
141
+ }
142
+ /** Patch playbackRate on all active animations via WAAPI. */
143
+ patchAnimations() {
144
+ try {
145
+ const anims = document.getAnimations();
146
+ for (const anim of anims) {
147
+ const target = anim.effect?.target;
148
+ if (target?.closest?.("[data-lapse-panel]")) continue;
149
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
150
+ const effectiveSpeed = this.speed || 1e-3;
151
+ let tracked = this.trackedAnims.get(anim);
152
+ if (!tracked) {
153
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
154
+ this.trackedAnims.set(anim, tracked);
155
+ } else if (anim.playbackRate !== tracked.applied) {
156
+ tracked.original = anim.playbackRate;
157
+ }
158
+ const desired = tracked.original * effectiveSpeed;
159
+ if (anim.playbackRate !== desired) {
160
+ anim.playbackRate = desired;
161
+ tracked.applied = desired;
162
+ }
163
+ }
164
+ } catch {
165
+ }
166
+ }
167
+ /** Patch playbackRate on all video/audio elements. */
168
+ patchMedia() {
169
+ try {
170
+ document.querySelectorAll("video, audio").forEach((node) => {
171
+ const el = node;
172
+ if (el.closest?.("[data-lapse-panel]")) return;
173
+ if (el.closest?.("[data-saccade-exclude]")) return;
174
+ let tracked = this.trackedMedia.get(el);
175
+ if (!tracked) {
176
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
177
+ this.trackedMedia.set(el, tracked);
178
+ } else if (el.playbackRate !== tracked.applied) {
179
+ tracked.original = el.playbackRate;
180
+ }
181
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
182
+ if (el.playbackRate !== desired) {
183
+ el.playbackRate = desired;
184
+ tracked.applied = desired;
185
+ }
186
+ });
187
+ } catch {
188
+ }
189
+ }
190
+ /**
191
+ * Register a GSAP instance (for ES-module imports where window.gsap is
192
+ * undefined). Applies the current timeScale immediately if speed !== 1.
193
+ */
194
+ registerGSAP(gsap) {
195
+ this.gsapInstance = gsap;
196
+ if (this.speed !== 1) this.patchGSAP();
197
+ }
198
+ /** Sync GSAP's global timeline if present. */
199
+ patchGSAP() {
200
+ try {
201
+ const gsap = this.gsapInstance ?? window.gsap;
202
+ gsap?.globalTimeline?.timeScale(this.speed || 1e-3);
203
+ } catch {
204
+ }
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Cleanup
208
+ // ---------------------------------------------------------------------------
209
+ /** Restore all patched APIs to originals. */
210
+ destroy() {
211
+ if (!this.installed) return;
212
+ performance.now = this._perfNow;
213
+ Date.now = this._dateNow;
214
+ window.requestAnimationFrame = this._raf;
215
+ window.cancelAnimationFrame = this._caf;
216
+ window.setTimeout = this._setTimeout;
217
+ window.clearTimeout = this._clearTimeout;
218
+ window.setInterval = this._setInterval;
219
+ window.clearInterval = this._clearInterval;
220
+ for (const [, entry] of this.intervalMap) {
221
+ this._clearTimeout(entry.realId);
222
+ }
223
+ this.intervalMap.clear();
224
+ if (this._origAnimate) {
225
+ Element.prototype.animate = this._origAnimate;
226
+ this._origAnimate = null;
227
+ }
228
+ if (this.animPollId) {
229
+ this._caf(this.animPollId);
230
+ this.animPollId = 0;
231
+ }
232
+ try {
233
+ for (const anim of document.getAnimations()) {
234
+ const tracked = this.trackedAnims.get(anim);
235
+ anim.playbackRate = tracked?.original ?? 1;
236
+ }
237
+ } catch {
238
+ }
239
+ try {
240
+ document.querySelectorAll("video, audio").forEach((node) => {
241
+ const el = node;
242
+ const tracked = this.trackedMedia.get(el);
243
+ el.playbackRate = tracked?.original ?? 1;
244
+ });
245
+ } catch {
246
+ }
247
+ try {
248
+ const gsap = this.gsapInstance ?? window.gsap;
249
+ gsap?.globalTimeline?.timeScale(1);
250
+ } catch {
251
+ }
252
+ this.gsapInstance = null;
253
+ delete window.__LAPSE_ORIGINAL_RAF__;
254
+ delete window.__saccadeInstalled;
255
+ this.installed = false;
256
+ }
257
+ };
258
+
259
+ // src/core/recorder.ts
260
+ var SAFE_PROPS = [
261
+ "opacity",
262
+ "transform",
263
+ "background-color",
264
+ "color",
265
+ "border-color",
266
+ "box-shadow",
267
+ "border-radius",
268
+ "filter",
269
+ "clip-path",
270
+ "scale",
271
+ "rotate",
272
+ "translate",
273
+ "outline-color",
274
+ "outline-width",
275
+ "outline-offset",
276
+ "outline-style",
277
+ "text-decoration-color",
278
+ "fill",
279
+ "stroke",
280
+ "stroke-dasharray",
281
+ "stroke-dashoffset",
282
+ "visibility",
283
+ "pointer-events"
284
+ ];
285
+ var LAYOUT_PROPS = [
286
+ "width",
287
+ "height",
288
+ "top",
289
+ "left",
290
+ "right",
291
+ "bottom",
292
+ "margin-top",
293
+ "margin-right",
294
+ "margin-bottom",
295
+ "margin-left",
296
+ "padding-top",
297
+ "padding-right",
298
+ "padding-bottom",
299
+ "padding-left",
300
+ "max-height",
301
+ "max-width",
302
+ "min-height",
303
+ "min-width",
304
+ "display"
305
+ ];
306
+ var SNAPSHOT_PROPS = [...SAFE_PROPS, ...LAYOUT_PROPS];
307
+ var SAFE_PROPS_SET = new Set(SAFE_PROPS);
308
+ var SNAPSHOT_ATTRS = [
309
+ "data-state",
310
+ "data-checked",
311
+ "data-disabled",
312
+ "data-highlighted",
313
+ "data-orientation",
314
+ "data-active",
315
+ "data-selected",
316
+ "data-open",
317
+ "data-closed",
318
+ "data-side",
319
+ "data-align",
320
+ "data-focus",
321
+ "data-hover",
322
+ "data-at-boundary",
323
+ "data-scrubbing",
324
+ "data-starting-style",
325
+ "data-ending-style",
326
+ "data-panel-open",
327
+ "data-hidden",
328
+ "aria-checked",
329
+ "aria-selected",
330
+ "aria-expanded",
331
+ "aria-pressed",
332
+ "aria-hidden",
333
+ "aria-disabled",
334
+ "aria-valuenow",
335
+ "aria-valuemin",
336
+ "aria-valuemax",
337
+ "aria-invalid",
338
+ "checked",
339
+ "disabled",
340
+ "hidden",
341
+ "inert",
342
+ "value",
343
+ "class",
344
+ "style"
345
+ ];
346
+ var STATE_SELECTORS = [
347
+ "[data-state]",
348
+ "[aria-checked]",
349
+ "[aria-selected]",
350
+ "[aria-expanded]",
351
+ "[aria-pressed]",
352
+ '[role="radio"]',
353
+ '[role="checkbox"]',
354
+ '[role="switch"]',
355
+ '[role="tab"]',
356
+ '[role="tabpanel"]',
357
+ '[role="option"]',
358
+ '[role="slider"]',
359
+ '[role="menuitem"]',
360
+ '[role="menuitemcheckbox"]',
361
+ '[role="menuitemradio"]',
362
+ '[type="radio"]',
363
+ '[type="checkbox"]',
364
+ '[type="range"]',
365
+ "input",
366
+ "button",
367
+ "select",
368
+ "textarea",
369
+ "[data-radix-collection-item]",
370
+ '[data-slot="slider-thumb"]',
371
+ '[data-slot="slider-track"]'
372
+ ].join(",");
373
+ function getSelector(el) {
374
+ if (!el || !el.tagName) return null;
375
+ const parts = [];
376
+ let current = el;
377
+ for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
378
+ const tag = current.tagName.toLowerCase();
379
+ const parent = current.parentElement;
380
+ if (parent) {
381
+ const siblings = Array.from(parent.children);
382
+ const idx = siblings.indexOf(current) + 1;
383
+ parts.unshift(tag + ":nth-child(" + idx + ")");
384
+ } else {
385
+ parts.unshift(tag);
386
+ }
387
+ current = parent;
388
+ }
389
+ return parts.join(" > ");
390
+ }
391
+ function getReadableSelector(el) {
392
+ if (!el || !el.tagName) return "unknown";
393
+ if (el.id) return "#" + el.id;
394
+ const tag = el.tagName.toLowerCase();
395
+ const classes = Array.from(el.classList || []).slice(0, 3).map((c) => "." + c).join("");
396
+ return tag + classes;
397
+ }
398
+ function getElementLabel(el) {
399
+ if (!el) return "unknown";
400
+ const ariaLabel = el.getAttribute("aria-label");
401
+ if (ariaLabel) return ariaLabel;
402
+ const text = (el.textContent || "").trim();
403
+ if (text && text.length < 40 && text.length > 0) return text;
404
+ const label = el.closest("label") || el.closest("[aria-label]");
405
+ if (label) {
406
+ const lt = label.getAttribute("aria-label") || (label.textContent || "").trim();
407
+ if (lt && lt.length < 40) return lt;
408
+ }
409
+ const state = el.getAttribute("data-state");
410
+ const role = el.getAttribute("role");
411
+ if (role && state) return role + " (" + state + ")";
412
+ if (role) return role;
413
+ return getReadableSelector(el);
414
+ }
415
+ function cssSplit(str) {
416
+ const parts = [];
417
+ let depth = 0;
418
+ let start = 0;
419
+ for (let i = 0; i < str.length; i++) {
420
+ if (str[i] === "(") depth++;
421
+ else if (str[i] === ")") depth--;
422
+ else if (str[i] === "," && depth === 0) {
423
+ parts.push(str.slice(start, i).trim());
424
+ start = i + 1;
425
+ }
426
+ }
427
+ parts.push(str.slice(start).trim());
428
+ return parts;
429
+ }
430
+ var _TimelineRecorder = class _TimelineRecorder {
431
+ constructor() {
432
+ // ---- Recording state ----------------------------------------------------
433
+ this.recording = false;
434
+ this.startTime = 0;
435
+ this.boundingBox = null;
436
+ /** Structural-selector -> live DOM element. */
437
+ this.elements = /* @__PURE__ */ new Map();
438
+ /** Animation id -> info. */
439
+ this.animations = /* @__PURE__ */ new Map();
440
+ /** Original inline style per element (captured on first encounter). */
441
+ this.originalStyles = /* @__PURE__ */ new Map();
442
+ /** Captured frames. */
443
+ this.frames = [];
444
+ // ---- Portal management --------------------------------------------------
445
+ this.activePortals = /* @__PURE__ */ new Map();
446
+ this.portalIdCounter = 0;
447
+ this.currentPortalIds = /* @__PURE__ */ new Set();
448
+ this.capturedPortals = /* @__PURE__ */ new Set();
449
+ // ---- JS animation detection (Phase 2) -----------------------------------
450
+ this.prevInlineStyles = /* @__PURE__ */ new Map();
451
+ this.jsAnimStartTimes = /* @__PURE__ */ new Map();
452
+ this.jsAnimLastSeen = /* @__PURE__ */ new Map();
453
+ this.jsAnimFromValues = /* @__PURE__ */ new Map();
454
+ this.jsAnimChangeCount = /* @__PURE__ */ new Map();
455
+ // ---- Interaction state --------------------------------------------------
456
+ this.currentHoverEls = /* @__PURE__ */ new Set();
457
+ this.currentFocusSel = null;
458
+ this.currentPointer = { x: 0, y: 0, buttons: 0 };
459
+ // ---- Observers & listeners ----------------------------------------------
460
+ this.attrObserver = null;
461
+ this.portalObserver = null;
462
+ this.exitObserver = null;
463
+ this.onMouseOver = null;
464
+ this.onMouseOut = null;
465
+ this.onFocusIn = null;
466
+ this.onFocusOut = null;
467
+ this.onPointerMove = null;
468
+ this.onPointerDown = null;
469
+ this.onPointerUp = null;
470
+ // ---- WAAPI interception --------------------------------------------------
471
+ /** Animations captured via Element.prototype.animate monkey-patch. */
472
+ this.interceptedAnimations = [];
473
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
474
+ this.seekableClones = /* @__PURE__ */ new Map();
475
+ this.hiddenSince = null;
476
+ this.onVisibilityChange = null;
477
+ /** Set to true when the capture loop self-terminates due to limits. */
478
+ this.autoStopped = false;
479
+ /** Called when recording auto-stops. Set by the engine. */
480
+ this.onAutoStop = null;
481
+ // ---- Saved originals (for restoration) ----------------------------------
482
+ this._removeChild = null;
483
+ this._remove = null;
484
+ this._elementAnimate = null;
485
+ // ---- DOM elements injected during stop ----------------------------------
486
+ /** `<style>` that disables all transitions/animations after recording. */
487
+ this.noTransitionsEl = null;
488
+ /** Full-screen overlay blocking interaction during scrub. */
489
+ this.blockerEl = null;
490
+ /** `<style>` with cloned :hover / :focus rules rewritten as `[data-lapse-*]`. */
491
+ this.lapseStyleEl = null;
492
+ // =========================================================================
493
+ // Public API — helpers exposed for the scrubber
494
+ // =========================================================================
495
+ this.getSelector = getSelector;
496
+ }
497
+ // ---- Unpatched rAF reference --------------------------------------------
498
+ get _raf() {
499
+ return window.__LAPSE_ORIGINAL_RAF__ || requestAnimationFrame;
500
+ }
501
+ get SAFE_PROPS_SET() {
502
+ return SAFE_PROPS_SET;
503
+ }
504
+ get capturedPortalIds() {
505
+ return this.capturedPortals;
506
+ }
507
+ // =========================================================================
508
+ // startRecording
509
+ // =========================================================================
510
+ startRecording(boundingBox) {
511
+ if (this.recording) return;
512
+ this.recording = true;
513
+ this.autoStopped = false;
514
+ this.hiddenSince = null;
515
+ this.startTime = performance.now();
516
+ this.frames = [];
517
+ this.animations.clear();
518
+ this.elements.clear();
519
+ this.originalStyles.clear();
520
+ this.boundingBox = boundingBox || null;
521
+ this.activePortals.clear();
522
+ this.portalIdCounter = 0;
523
+ this.currentPortalIds.clear();
524
+ this.capturedPortals.clear();
525
+ this.seekableClones.clear();
526
+ this.prevInlineStyles.clear();
527
+ this.jsAnimStartTimes.clear();
528
+ this.jsAnimLastSeen.clear();
529
+ this.jsAnimFromValues.clear();
530
+ this.jsAnimChangeCount.clear();
531
+ this.currentHoverEls.clear();
532
+ this.currentFocusSel = null;
533
+ this.currentPointer = { x: 0, y: 0, buttons: 0 };
534
+ this.onVisibilityChange = () => {
535
+ if (!this.recording) return;
536
+ if (document.hidden) {
537
+ this.hiddenSince = performance.now();
538
+ } else {
539
+ this.hiddenSince = null;
540
+ }
541
+ };
542
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
543
+ const trackElement = (el) => {
544
+ if (!el || !el.tagName) return;
545
+ if (el.closest?.("[data-lapse-panel]")) return;
546
+ const sel = getSelector(el);
547
+ if (!sel) return;
548
+ const isNew = !this.elements.has(sel);
549
+ this.elements.set(sel, el);
550
+ if (isNew) {
551
+ this.originalStyles.set(sel, el.getAttribute("style") || "");
552
+ }
553
+ };
554
+ const trackTree = (root) => {
555
+ if (!root || !root.querySelectorAll) return;
556
+ trackElement(root);
557
+ for (const child of root.querySelectorAll("*")) {
558
+ trackElement(child);
559
+ }
560
+ };
561
+ const isInBounds = (el) => {
562
+ if (el.closest?.("[data-lapse-panel]")) return false;
563
+ if (!this.boundingBox) return true;
564
+ const r = el.getBoundingClientRect();
565
+ const bb = this.boundingBox;
566
+ return r.x < bb.x + bb.width && r.x + r.width > bb.x && r.y < bb.y + bb.height && r.y + r.height > bb.y;
567
+ };
568
+ try {
569
+ const stateEls = document.querySelectorAll(STATE_SELECTORS);
570
+ for (const el of stateEls) {
571
+ if (!isInBounds(el)) continue;
572
+ trackElement(el);
573
+ for (const child of el.querySelectorAll("*")) {
574
+ trackElement(child);
575
+ }
576
+ const parent = el.parentElement;
577
+ if (parent) {
578
+ trackElement(parent);
579
+ for (const sibling of parent.children) {
580
+ trackElement(sibling);
581
+ for (const child of sibling.querySelectorAll("*")) {
582
+ trackElement(child);
583
+ }
584
+ }
585
+ }
586
+ }
587
+ } catch (_) {
588
+ }
589
+ this.attrObserver = new MutationObserver((mutations) => {
590
+ for (const m of mutations) {
591
+ if (m.target instanceof HTMLElement) {
592
+ trackElement(m.target);
593
+ const parent = m.target.parentElement;
594
+ if (parent) {
595
+ for (const sibling of parent.children) {
596
+ trackElement(sibling);
597
+ for (const child of sibling.querySelectorAll("*")) {
598
+ trackElement(child);
599
+ }
600
+ }
601
+ }
602
+ }
603
+ }
604
+ });
605
+ this.attrObserver.observe(document.body, {
606
+ attributes: true,
607
+ attributeFilter: [
608
+ "data-state",
609
+ "data-highlighted",
610
+ "data-hover",
611
+ "data-focus",
612
+ "data-active",
613
+ "data-selected",
614
+ "data-open",
615
+ "data-closed",
616
+ "data-at-boundary",
617
+ "data-scrubbing",
618
+ "aria-checked",
619
+ "aria-selected",
620
+ "aria-expanded",
621
+ "aria-valuenow",
622
+ "aria-invalid",
623
+ "class",
624
+ "style"
625
+ ],
626
+ subtree: true
627
+ });
628
+ this._removeChild = Node.prototype.removeChild;
629
+ const savedRemoveChild = this._removeChild;
630
+ const self = this;
631
+ Node.prototype.removeChild = function(child) {
632
+ if (self.recording && child instanceof HTMLElement && child.hasAttribute("data-lapse-portal-id")) {
633
+ child.style.display = "none";
634
+ child.setAttribute("data-lapse-portal-hidden", "");
635
+ const id = child.getAttribute("data-lapse-portal-id");
636
+ if (id) self.currentPortalIds.delete(id);
637
+ return child;
638
+ }
639
+ return savedRemoveChild.call(this, child);
640
+ };
641
+ this._remove = Element.prototype.remove;
642
+ const savedRemove = this._remove;
643
+ Element.prototype.remove = function() {
644
+ if (self.recording && this instanceof HTMLElement && this.hasAttribute("data-lapse-portal-id")) {
645
+ this.style.display = "none";
646
+ this.setAttribute("data-lapse-portal-hidden", "");
647
+ const id = this.getAttribute("data-lapse-portal-id");
648
+ if (id) self.currentPortalIds.delete(id);
649
+ return;
650
+ }
651
+ return savedRemove.call(this);
652
+ };
653
+ this._elementAnimate = Element.prototype.animate;
654
+ const savedAnimate = this._elementAnimate;
655
+ Element.prototype.animate = function(keyframes, options) {
656
+ const anim = savedAnimate.call(this, keyframes, options);
657
+ if (self.recording && this instanceof HTMLElement && isInBounds(this)) {
658
+ trackElement(this);
659
+ self.interceptedAnimations.push({ animation: anim, target: this });
660
+ }
661
+ return anim;
662
+ };
663
+ this.portalObserver = new MutationObserver((mutations) => {
664
+ for (const m of mutations) {
665
+ for (const node of m.addedNodes) {
666
+ if (!(node instanceof HTMLElement)) continue;
667
+ if (node.parentElement !== document.body) continue;
668
+ if (node.hasAttribute("data-lapse-portal-id")) continue;
669
+ const isPortal = node.hasAttribute("data-radix-popper-content-wrapper") || node.hasAttribute("data-radix-portal") || node.getAttribute("role") === "dialog" || node.getAttribute("role") === "alertdialog" || node.querySelector(
670
+ '[role="menu"], [role="dialog"], [role="alertdialog"], [role="listbox"], [role="tooltip"], [data-radix-popper-content-wrapper]'
671
+ ) || node.hasAttribute("data-state") || node.style && (node.style.position === "fixed" || node.style.position === "absolute") || getComputedStyle(node).position === "fixed";
672
+ if (!isPortal) continue;
673
+ const id = "__lapse_portal_" + this.portalIdCounter++;
674
+ node.setAttribute("data-lapse-portal-id", id);
675
+ this.activePortals.set(id, { element: node });
676
+ this.currentPortalIds.add(id);
677
+ this.capturedPortals.add(id);
678
+ trackTree(node);
679
+ }
680
+ }
681
+ });
682
+ this.portalObserver.observe(document.body, { childList: true });
683
+ this.exitObserver = new MutationObserver((mutations) => {
684
+ if (!this.recording) return;
685
+ for (const m of mutations) {
686
+ for (const node of m.removedNodes) {
687
+ if (!(node instanceof HTMLElement)) continue;
688
+ const sel = getSelector(node);
689
+ if (sel && this.elements.has(sel)) {
690
+ if (!node.isConnected) {
691
+ node.style.display = "none";
692
+ node.setAttribute("data-lapse-exit-captured", "");
693
+ (m.target || document.body).appendChild(node);
694
+ }
695
+ }
696
+ }
697
+ }
698
+ });
699
+ this.exitObserver.observe(document.body, { childList: true, subtree: true });
700
+ this.onMouseOver = (e) => {
701
+ if (!this.recording) return;
702
+ this.currentHoverEls.clear();
703
+ let el = e.target;
704
+ while (el && el !== document.body) {
705
+ const sel = getSelector(el);
706
+ if (sel) {
707
+ this.currentHoverEls.add(sel);
708
+ trackElement(el);
709
+ }
710
+ el = el.parentElement;
711
+ }
712
+ };
713
+ this.onMouseOut = (e) => {
714
+ if (!this.recording) return;
715
+ if (!e.relatedTarget || !document.body.contains(e.relatedTarget)) {
716
+ this.currentHoverEls.clear();
717
+ }
718
+ };
719
+ document.addEventListener("mouseover", this.onMouseOver, true);
720
+ document.addEventListener("mouseout", this.onMouseOut, true);
721
+ this.onFocusIn = (e) => {
722
+ if (!this.recording) return;
723
+ const el = e.target;
724
+ if (el && el !== document.body) {
725
+ const sel = getSelector(el);
726
+ this.currentFocusSel = sel;
727
+ trackElement(el);
728
+ let parent = el.parentElement;
729
+ while (parent && parent !== document.body) {
730
+ trackElement(parent);
731
+ parent = parent.parentElement;
732
+ }
733
+ }
734
+ };
735
+ this.onFocusOut = () => {
736
+ if (!this.recording) return;
737
+ this.currentFocusSel = null;
738
+ };
739
+ document.addEventListener("focusin", this.onFocusIn, true);
740
+ document.addEventListener("focusout", this.onFocusOut, true);
741
+ this.onPointerMove = (e) => {
742
+ if (!this.recording) return;
743
+ this.currentPointer = { x: e.clientX, y: e.clientY, buttons: e.buttons };
744
+ };
745
+ this.onPointerDown = (e) => {
746
+ if (!this.recording) return;
747
+ this.currentPointer = { x: e.clientX, y: e.clientY, buttons: e.buttons };
748
+ };
749
+ this.onPointerUp = (e) => {
750
+ if (!this.recording) return;
751
+ this.currentPointer = { x: e.clientX, y: e.clientY, buttons: 0 };
752
+ };
753
+ document.addEventListener("pointermove", this.onPointerMove, true);
754
+ document.addEventListener("pointerdown", this.onPointerDown, true);
755
+ document.addEventListener("pointerup", this.onPointerUp, true);
756
+ const captureFrame = () => {
757
+ if (!this.recording) return;
758
+ const time = performance.now() - this.startTime;
759
+ if (time >= _TimelineRecorder.MAX_DURATION_MS || this.frames.length >= _TimelineRecorder.MAX_FRAMES || this.hiddenSince && performance.now() - this.hiddenSince > 5e3) {
760
+ this.recording = false;
761
+ this.autoStopped = true;
762
+ this.onAutoStop?.();
763
+ return;
764
+ }
765
+ const anims = document.getAnimations();
766
+ const frameAnims = [];
767
+ for (const a of anims) {
768
+ const target = a.effect?.target;
769
+ if (!target) continue;
770
+ if (!isInBounds(target)) continue;
771
+ const uniqueSelector = getSelector(target);
772
+ if (!uniqueSelector) continue;
773
+ const readableSelector = getReadableSelector(target);
774
+ trackElement(target);
775
+ const timing = a.effect?.getTiming?.() || {};
776
+ const duration = typeof timing.duration === "number" ? timing.duration : 0;
777
+ const delay = timing.delay || 0;
778
+ const easing = timing.easing || "linear";
779
+ let name = "";
780
+ let type = "WebAnimation";
781
+ if (a.constructor.name === "CSSTransition" || a.transitionProperty) {
782
+ name = a.transitionProperty || "";
783
+ type = "CSSTransition";
784
+ } else if (a.constructor.name === "CSSAnimation" || a.animationName) {
785
+ name = a.animationName || "";
786
+ type = "CSSAnimation";
787
+ }
788
+ const id = type + ":" + name + ":" + uniqueSelector;
789
+ if (!this.animations.has(id)) {
790
+ const keyframes2 = a.effect?.getKeyframes?.() || [];
791
+ let source = null;
792
+ if (type === "CSSAnimation" && keyframes2.length > 0) {
793
+ const kfLines = keyframes2.map((kf) => {
794
+ const offset = Math.round((kf.offset ?? 0) * 100) + "%";
795
+ const props = Object.entries(kf).filter(
796
+ ([k]) => !["offset", "easing", "composite", "computedOffset"].includes(k)
797
+ ).map(
798
+ ([k, v]) => k.replace(/([A-Z])/g, "-$1").toLowerCase() + ": " + v
799
+ ).join("; ");
800
+ return " " + offset + " { " + props + " }";
801
+ }).join("\n");
802
+ source = "@keyframes " + (name || "anonymous") + " {\n" + kfLines + "\n}";
803
+ } else if (type === "CSSTransition") {
804
+ source = "transition: " + name + " " + duration + "ms " + easing;
805
+ }
806
+ const resolvedVars = {};
807
+ try {
808
+ const cs = getComputedStyle(target);
809
+ const style = target.getAttribute("style") || "";
810
+ const allRules = [];
811
+ for (const sheet of document.styleSheets) {
812
+ try {
813
+ for (const rule of sheet.cssRules) {
814
+ if (rule.selectorText && target.matches(rule.selectorText)) {
815
+ allRules.push(rule.cssText);
816
+ }
817
+ }
818
+ } catch (_) {
819
+ }
820
+ }
821
+ const allCssText = allRules.join(" ") + " " + style;
822
+ const varRegex = /var\(\s*(--[a-zA-Z0-9-]+)/g;
823
+ let varMatch;
824
+ while ((varMatch = varRegex.exec(allCssText)) !== null) {
825
+ const varName = varMatch[1];
826
+ const resolved = cs.getPropertyValue(varName).trim();
827
+ if (resolved) {
828
+ resolvedVars[varName] = resolved;
829
+ }
830
+ }
831
+ } catch (_) {
832
+ }
833
+ let conflicts = null;
834
+ if (type === "CSSTransition") {
835
+ try {
836
+ const cs = getComputedStyle(target);
837
+ const tProps = cssSplit(cs.transitionProperty);
838
+ const tDurations = cssSplit(cs.transitionDuration).map(
839
+ (s) => parseFloat(s) * 1e3
840
+ );
841
+ const tEasings = cssSplit(cs.transitionTimingFunction);
842
+ const tDelays = cssSplit(cs.transitionDelay).map(
843
+ (s) => parseFloat(s) * 1e3
844
+ );
845
+ let idx = tProps.indexOf(name);
846
+ if (idx === -1 && tProps.includes("all"))
847
+ idx = tProps.indexOf("all");
848
+ if (idx >= 0) {
849
+ const declaredDuration = tDurations[idx % tDurations.length] || 0;
850
+ const declaredEasing = tEasings[idx % tEasings.length] || "ease";
851
+ const declaredDelay = tDelays[idx % tDelays.length] || 0;
852
+ const diffs = [];
853
+ if (Math.abs(declaredDuration - duration) > 1) {
854
+ diffs.push(
855
+ "duration: declared " + declaredDuration + "ms, actual " + duration + "ms"
856
+ );
857
+ }
858
+ if (declaredEasing !== easing) {
859
+ const normDeclared = declaredEasing.replace(/\s/g, "");
860
+ const normActual = easing.replace(/\s/g, "");
861
+ if (normDeclared !== normActual) {
862
+ diffs.push(
863
+ "easing: declared " + declaredEasing + ", actual " + easing
864
+ );
865
+ }
866
+ }
867
+ if (Math.abs(declaredDelay - delay) > 1) {
868
+ diffs.push(
869
+ "delay: declared " + declaredDelay + "ms, actual " + delay + "ms"
870
+ );
871
+ }
872
+ if (diffs.length > 0) {
873
+ conflicts = diffs;
874
+ }
875
+ }
876
+ } catch (_) {
877
+ }
878
+ }
879
+ const cleanedKeyframes = keyframes2.map((kf) => {
880
+ const clean = {};
881
+ for (const [k, v] of Object.entries(kf)) {
882
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
883
+ }
884
+ return clean;
885
+ });
886
+ this.animations.set(id, {
887
+ id,
888
+ name,
889
+ selector: readableSelector,
890
+ elementLabel: getElementLabel(target),
891
+ duration,
892
+ delay,
893
+ easing,
894
+ type,
895
+ source,
896
+ resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
897
+ conflicts,
898
+ rawKeyframes: cleanedKeyframes,
899
+ rawTiming: { ...timing, fill: "both" }
900
+ });
901
+ }
902
+ const keyframes = a.effect?.getKeyframes?.() || [];
903
+ const animatedProps = /* @__PURE__ */ new Set();
904
+ for (const kf of keyframes) {
905
+ for (const key of Object.keys(kf)) {
906
+ if (!["offset", "easing", "composite", "computedOffset"].includes(key)) {
907
+ animatedProps.add(key);
908
+ }
909
+ }
910
+ }
911
+ const computedStyle = getComputedStyle(target);
912
+ const properties = [];
913
+ for (const prop of animatedProps) {
914
+ const kebab = prop.replace(/([A-Z])/g, "-$1").toLowerCase();
915
+ const value = computedStyle.getPropertyValue(kebab) || "";
916
+ if (value) {
917
+ const from = keyframes[0]?.[prop] || null;
918
+ const to = keyframes[keyframes.length - 1]?.[prop] || null;
919
+ properties.push({ property: kebab, value, from, to });
920
+ }
921
+ }
922
+ const currentTime = a.currentTime ?? 0;
923
+ const progress = duration > 0 ? Math.max(0, Math.min(1, currentTime / duration)) : 0;
924
+ frameAnims.push({ animationId: id, currentTime, progress, properties });
925
+ }
926
+ const waapiAnimatedIds = /* @__PURE__ */ new Set();
927
+ for (const fa of frameAnims) {
928
+ const parts = fa.animationId.split(":");
929
+ waapiAnimatedIds.add(parts[parts.length - 1]);
930
+ }
931
+ const JS_TRACK_PROPS = ["transform", "opacity", "left", "top", "right", "bottom", "background"];
932
+ const waapiAnimatedProps = /* @__PURE__ */ new Map();
933
+ for (const fa of frameAnims) {
934
+ const parts = fa.animationId.split(":");
935
+ const elKey = parts[parts.length - 1];
936
+ if (!waapiAnimatedProps.has(elKey)) waapiAnimatedProps.set(elKey, /* @__PURE__ */ new Set());
937
+ const propName = parts[1];
938
+ if (propName) waapiAnimatedProps.get(elKey).add(propName);
939
+ }
940
+ for (const [elId, el] of this.elements) {
941
+ if (!el.isConnected) continue;
942
+ const hasWaapi = waapiAnimatedIds.has(elId);
943
+ const waapiProps = waapiAnimatedProps.get(elId);
944
+ const currentInline = {};
945
+ let hasAnyInline = false;
946
+ for (const prop of JS_TRACK_PROPS) {
947
+ const camel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
948
+ const val = el.style[camel];
949
+ if (val) {
950
+ currentInline[prop] = val;
951
+ hasAnyInline = true;
952
+ }
953
+ }
954
+ if (!hasAnyInline) {
955
+ this.prevInlineStyles.set(elId, currentInline);
956
+ continue;
957
+ }
958
+ const prevInline = this.prevInlineStyles.get(elId) || {};
959
+ this.prevInlineStyles.set(elId, currentInline);
960
+ for (const p of JS_TRACK_PROPS) {
961
+ const cur = currentInline[p] || "";
962
+ const prev = prevInline[p] || "";
963
+ const propCoveredByWaapi = waapiProps?.has(p) || waapiProps?.has(p.replace(/-([a-z])/g, (_, c) => c.toUpperCase()));
964
+ if (cur && cur !== prev && !propCoveredByWaapi) {
965
+ const jsId = "JSAnimation:" + p + ":" + elId;
966
+ this.jsAnimChangeCount.set(jsId, (this.jsAnimChangeCount.get(jsId) || 0) + 1);
967
+ if (!this.jsAnimStartTimes.has(jsId)) {
968
+ this.jsAnimStartTimes.set(jsId, time);
969
+ this.jsAnimFromValues.set(jsId, prev || cur);
970
+ }
971
+ this.jsAnimLastSeen.set(jsId, time);
972
+ if (this.jsAnimChangeCount.get(jsId) < 3) continue;
973
+ if (!this.animations.has(jsId)) {
974
+ const readableSel = getReadableSelector(el);
975
+ this.animations.set(jsId, {
976
+ id: jsId,
977
+ name: p,
978
+ selector: readableSel,
979
+ elementLabel: getElementLabel(el),
980
+ duration: 0,
981
+ delay: 0,
982
+ easing: "JS-driven",
983
+ type: "JSAnimation",
984
+ source: "Detected: inline style animation (JS library or requestAnimationFrame)",
985
+ resolvedVars: null,
986
+ conflicts: null
987
+ });
988
+ }
989
+ const startT = this.jsAnimStartTimes.get(jsId);
990
+ const anim = this.animations.get(jsId);
991
+ anim.duration = time - startT;
992
+ const fromVal = this.jsAnimFromValues.get(jsId) || "";
993
+ const elapsed = time - startT;
994
+ const estimatedDuration = Math.max(anim.duration, 300);
995
+ const prog = Math.min(1, elapsed / estimatedDuration);
996
+ frameAnims.push({
997
+ animationId: jsId,
998
+ currentTime: elapsed,
999
+ progress: prog,
1000
+ properties: [{
1001
+ property: p,
1002
+ value: cur,
1003
+ from: fromVal,
1004
+ to: cur
1005
+ }]
1006
+ });
1007
+ }
1008
+ }
1009
+ }
1010
+ const elementSnapshots = {};
1011
+ for (const [sel, el] of this.elements) {
1012
+ if (!el.isConnected) continue;
1013
+ const cs = getComputedStyle(el);
1014
+ const snap = { __styles: {}, __attrs: {} };
1015
+ for (const prop of SNAPSHOT_PROPS) {
1016
+ snap.__styles[prop] = cs.getPropertyValue(prop);
1017
+ }
1018
+ for (const attr of SNAPSHOT_ATTRS) {
1019
+ if (attr === "checked") {
1020
+ snap.__attrs[attr] = el.checked ? "true" : null;
1021
+ } else if (attr === "class") {
1022
+ snap.__attrs[attr] = el.className;
1023
+ } else if (attr === "style") {
1024
+ snap.__attrs[attr] = el.getAttribute("style") || "";
1025
+ } else if (attr === "value") {
1026
+ snap.__attrs[attr] = el.value !== void 0 ? String(el.value) : null;
1027
+ } else {
1028
+ snap.__attrs[attr] = el.getAttribute(attr);
1029
+ }
1030
+ }
1031
+ try {
1032
+ snap.__afterOpacity = getComputedStyle(el, "::after").getPropertyValue(
1033
+ "opacity"
1034
+ );
1035
+ snap.__beforeOpacity = getComputedStyle(
1036
+ el,
1037
+ "::before"
1038
+ ).getPropertyValue("opacity");
1039
+ } catch (_) {
1040
+ }
1041
+ elementSnapshots[sel] = snap;
1042
+ }
1043
+ for (const [id, portal] of this.activePortals) {
1044
+ if (portal.element?.isConnected && !portal.element.hasAttribute("data-lapse-portal-hidden")) {
1045
+ this.currentPortalIds.add(id);
1046
+ }
1047
+ }
1048
+ this.frames.push({
1049
+ time,
1050
+ animations: frameAnims,
1051
+ elementSnapshots,
1052
+ activePortalIds: [...this.currentPortalIds],
1053
+ hoveredSels: [...this.currentHoverEls],
1054
+ focusSel: this.currentFocusSel,
1055
+ pointer: { ...this.currentPointer },
1056
+ scrollPositions: {}
1057
+ });
1058
+ this._raf(captureFrame);
1059
+ };
1060
+ this._raf(captureFrame);
1061
+ }
1062
+ // =========================================================================
1063
+ // stopRecording
1064
+ // =========================================================================
1065
+ stopRecording() {
1066
+ if (!this.recording) {
1067
+ return {
1068
+ startTime: 0,
1069
+ endTime: 0,
1070
+ duration: 0,
1071
+ animations: [],
1072
+ frames: [],
1073
+ boundingBox: null
1074
+ };
1075
+ }
1076
+ this.recording = false;
1077
+ if (this._removeChild) Node.prototype.removeChild = this._removeChild;
1078
+ if (this._remove) Element.prototype.remove = this._remove;
1079
+ if (this._elementAnimate) Element.prototype.animate = this._elementAnimate;
1080
+ this.attrObserver?.disconnect();
1081
+ this.portalObserver?.disconnect();
1082
+ this.exitObserver?.disconnect();
1083
+ if (this.onVisibilityChange) {
1084
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
1085
+ this.onVisibilityChange = null;
1086
+ }
1087
+ if (this.onMouseOver)
1088
+ document.removeEventListener("mouseover", this.onMouseOver, true);
1089
+ if (this.onMouseOut)
1090
+ document.removeEventListener("mouseout", this.onMouseOut, true);
1091
+ if (this.onFocusIn)
1092
+ document.removeEventListener("focusin", this.onFocusIn, true);
1093
+ if (this.onFocusOut)
1094
+ document.removeEventListener("focusout", this.onFocusOut, true);
1095
+ if (this.onPointerMove)
1096
+ document.removeEventListener("pointermove", this.onPointerMove, true);
1097
+ if (this.onPointerDown)
1098
+ document.removeEventListener("pointerdown", this.onPointerDown, true);
1099
+ if (this.onPointerUp)
1100
+ document.removeEventListener("pointerup", this.onPointerUp, true);
1101
+ try {
1102
+ for (const anim of document.getAnimations()) {
1103
+ const target = anim.effect?.target;
1104
+ if (target?.closest?.("[data-lapse-panel]")) continue;
1105
+ anim.cancel();
1106
+ }
1107
+ } catch (_) {
1108
+ }
1109
+ const noTransitions = document.createElement("style");
1110
+ noTransitions.id = "__lapse-no-transitions";
1111
+ noTransitions.textContent = "*, *::before, *::after { transition: none !important; animation: none !important; }";
1112
+ document.head.appendChild(noTransitions);
1113
+ this.noTransitionsEl = noTransitions;
1114
+ const blocker = document.createElement("div");
1115
+ blocker.id = "__lapse-scrub-blocker";
1116
+ blocker.style.cssText = "position:fixed;inset:0;z-index:999999;cursor:not-allowed;background:transparent;";
1117
+ blocker.title = "Clear the timeline to interact with the page";
1118
+ document.body.appendChild(blocker);
1119
+ this.blockerEl = blocker;
1120
+ setTimeout(() => {
1121
+ try {
1122
+ const lapseStyle = document.createElement("style");
1123
+ lapseStyle.id = "__lapse-state-rules";
1124
+ const hoverFocusRules = [];
1125
+ for (const sheet of document.styleSheets) {
1126
+ try {
1127
+ const walk = (rules) => {
1128
+ for (const rule of rules) {
1129
+ if (rule.cssRules) {
1130
+ walk(rule.cssRules);
1131
+ continue;
1132
+ }
1133
+ const t = rule.cssText;
1134
+ if (t && (t.includes(":hover") || t.includes(":focus"))) {
1135
+ hoverFocusRules.push(t);
1136
+ }
1137
+ }
1138
+ };
1139
+ walk(sheet.cssRules);
1140
+ } catch (_) {
1141
+ }
1142
+ }
1143
+ const parts = [];
1144
+ for (const ruleText of hoverFocusRules) {
1145
+ const braceIdx = ruleText.indexOf("{");
1146
+ if (braceIdx === -1) continue;
1147
+ const selector = ruleText.slice(0, braceIdx).trim();
1148
+ const bodyEnd = ruleText.lastIndexOf("}");
1149
+ const body = ruleText.slice(braceIdx + 1, bodyEnd).trim();
1150
+ if (!body) continue;
1151
+ const newBody = body.replace(
1152
+ /([^;:]+):\s*([^;]+)(;|$)/g,
1153
+ (m, prop, val, end) => {
1154
+ if (val.includes("!important")) return m;
1155
+ return prop + ": " + val.trim() + " !important" + end;
1156
+ }
1157
+ );
1158
+ if (selector.includes(":hover")) {
1159
+ parts.push(selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }");
1160
+ }
1161
+ if (selector.includes(":focus-visible")) {
1162
+ parts.push(selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }");
1163
+ } else if (selector.includes(":focus-within")) {
1164
+ parts.push(selector.replace(/:focus-within/g, ":has([data-lapse-focus])") + " { " + newBody + " }");
1165
+ } else if (selector.includes(":focus")) {
1166
+ parts.push(selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }");
1167
+ }
1168
+ }
1169
+ lapseStyle.textContent = parts.join("\n");
1170
+ document.head.appendChild(lapseStyle);
1171
+ this.lapseStyleEl = lapseStyle;
1172
+ } catch (_) {
1173
+ }
1174
+ }, 0);
1175
+ this.seekableClones.clear();
1176
+ for (const [animId, animInfo] of this.animations) {
1177
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1178
+ if (animInfo.type === "JSAnimation") continue;
1179
+ const firstColon = animId.indexOf(":");
1180
+ const secondColon = animId.indexOf(":", firstColon + 1);
1181
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1182
+ const el = this.elements.get(elSelector);
1183
+ if (!el?.isConnected) continue;
1184
+ try {
1185
+ const clone = el.animate(animInfo.rawKeyframes, {
1186
+ ...animInfo.rawTiming,
1187
+ fill: "both"
1188
+ });
1189
+ clone.pause();
1190
+ clone.currentTime = 0;
1191
+ this.seekableClones.set(animId, {
1192
+ animation: clone,
1193
+ element: el,
1194
+ effect: clone.effect
1195
+ });
1196
+ } catch (_) {
1197
+ }
1198
+ }
1199
+ const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
1200
+ const capture = {
1201
+ startTime: this.startTime,
1202
+ endTime: performance.now(),
1203
+ duration,
1204
+ animations: Array.from(this.animations.values()),
1205
+ frames: this.frames,
1206
+ boundingBox: this.boundingBox
1207
+ };
1208
+ return capture;
1209
+ }
1210
+ };
1211
+ // ---- Recording limits ---------------------------------------------------
1212
+ _TimelineRecorder.MAX_DURATION_MS = 6e4;
1213
+ _TimelineRecorder.MAX_FRAMES = 3600;
1214
+ var TimelineRecorder = _TimelineRecorder;
1215
+
1216
+ // src/core/scrubber.ts
1217
+ var TimelineScrubber = class {
1218
+ constructor(state) {
1219
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1220
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1221
+ /** Saved originals for restore on release */
1222
+ this._originalAnimate = null;
1223
+ this._originalRaf = null;
1224
+ this._originalRemoveChild = null;
1225
+ this._originalRemove = null;
1226
+ this.elements = state.elements;
1227
+ this.frames = state.frames;
1228
+ this.capturedPortals = state.capturedPortals;
1229
+ this.interceptedAnimations = state.interceptedAnimations;
1230
+ this.seekableClones = state.seekableClones;
1231
+ this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1232
+ this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1233
+ this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1234
+ this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1235
+ for (let i = 0; i < this.frames.length; i++) {
1236
+ for (const fa of this.frames[i].animations) {
1237
+ const range = this.animFrameRanges.get(fa.animationId);
1238
+ if (!range) {
1239
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1240
+ } else {
1241
+ range.last = i;
1242
+ }
1243
+ }
1244
+ }
1245
+ }
1246
+ // ---------------------------------------------------------------------------
1247
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1248
+ // ---------------------------------------------------------------------------
1249
+ seekTo(timeMs) {
1250
+ if (!this.frames.length) return;
1251
+ let lo = 0;
1252
+ let hi = this.frames.length - 1;
1253
+ while (lo < hi) {
1254
+ const mid = lo + hi >> 1;
1255
+ if (this.frames[mid].time < timeMs) lo = mid + 1;
1256
+ else hi = mid;
1257
+ }
1258
+ const frame = this.frames[lo];
1259
+ if (!frame || !frame.elementSnapshots) return;
1260
+ document.querySelectorAll("[data-lapse-hover]").forEach((el) => {
1261
+ el.removeAttribute("data-lapse-hover");
1262
+ });
1263
+ document.querySelectorAll("[data-lapse-focus]").forEach((el) => {
1264
+ el.removeAttribute("data-lapse-focus");
1265
+ });
1266
+ const activeIds = new Set(frame.activePortalIds || []);
1267
+ for (const id of this.capturedPortals) {
1268
+ const portalEl = document.querySelector(
1269
+ `[data-lapse-portal-id="${id}"]`
1270
+ );
1271
+ if (!portalEl) continue;
1272
+ if (activeIds.has(id)) {
1273
+ portalEl.style.removeProperty("display");
1274
+ portalEl.removeAttribute("data-lapse-portal-hidden");
1275
+ } else {
1276
+ portalEl.style.setProperty("display", "none", "important");
1277
+ }
1278
+ }
1279
+ const hoveredSels = frame.hoveredSels || [];
1280
+ for (const sel of hoveredSels) {
1281
+ const el = this.elements.get(sel);
1282
+ if (el && el.isConnected) el.setAttribute("data-lapse-hover", "");
1283
+ }
1284
+ if (frame.focusSel) {
1285
+ const focusEl = this.elements.get(frame.focusSel);
1286
+ if (focusEl && focusEl.isConnected) {
1287
+ focusEl.setAttribute("data-lapse-focus", "");
1288
+ let parent = focusEl.parentElement;
1289
+ while (parent && parent !== document.body) {
1290
+ parent.setAttribute("data-lapse-focus", "");
1291
+ parent = parent.parentElement;
1292
+ }
1293
+ }
1294
+ }
1295
+ const activeAnimIds = /* @__PURE__ */ new Map();
1296
+ for (const fa of frame.animations || []) {
1297
+ activeAnimIds.set(fa.animationId, fa);
1298
+ }
1299
+ for (const [animId, clone] of this.seekableClones) {
1300
+ const frameAnim = activeAnimIds.get(animId);
1301
+ try {
1302
+ if (frameAnim) {
1303
+ if (!clone.animation.effect) {
1304
+ clone.animation.effect = clone.effect;
1305
+ }
1306
+ clone.animation.currentTime = frameAnim.currentTime;
1307
+ } else {
1308
+ const range = this.animFrameRanges.get(animId);
1309
+ if (!range || lo < range.first) {
1310
+ clone.animation.effect = null;
1311
+ } else {
1312
+ if (!clone.animation.effect) {
1313
+ clone.animation.effect = clone.effect;
1314
+ }
1315
+ const timing = clone.effect.getTiming();
1316
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1317
+ clone.animation.currentTime = endTime;
1318
+ }
1319
+ }
1320
+ } catch {
1321
+ }
1322
+ }
1323
+ for (const entry of this.interceptedAnimations) {
1324
+ try {
1325
+ const anim = entry.animation;
1326
+ if (anim.playState !== "finished") {
1327
+ anim.pause();
1328
+ }
1329
+ anim.currentTime = timeMs;
1330
+ } catch {
1331
+ }
1332
+ }
1333
+ for (const [sel, snap] of Object.entries(frame.elementSnapshots)) {
1334
+ const el = this.elements.get(sel);
1335
+ if (!el || !el.isConnected) continue;
1336
+ if (el.closest?.("[data-lapse-panel]")) continue;
1337
+ const snapTyped = snap;
1338
+ if (snapTyped.__attrs) {
1339
+ for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1340
+ if (attr === "class" || attr === "style") continue;
1341
+ if (attr === "checked") {
1342
+ ;
1343
+ el.checked = value === "true";
1344
+ } else if (attr === "value") {
1345
+ if (value != null) el.value = value;
1346
+ } else if (value == null) {
1347
+ el.removeAttribute(attr);
1348
+ } else {
1349
+ el.setAttribute(attr, value);
1350
+ }
1351
+ }
1352
+ }
1353
+ }
1354
+ for (const fa of frame.animations || []) {
1355
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1356
+ const firstColon = fa.animationId.indexOf(":");
1357
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1358
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1359
+ const el = this.elements.get(elSel);
1360
+ if (!el || !el.isConnected) continue;
1361
+ for (const prop of fa.properties) {
1362
+ if (prop.value) {
1363
+ el.style.setProperty(prop.property, prop.value, "important");
1364
+ }
1365
+ }
1366
+ }
1367
+ }
1368
+ // ---------------------------------------------------------------------------
1369
+ // release — tear down all scrub state and restore the page to normal
1370
+ // ---------------------------------------------------------------------------
1371
+ release() {
1372
+ for (const [, clone] of this.seekableClones) {
1373
+ try {
1374
+ clone.animation.cancel();
1375
+ } catch {
1376
+ }
1377
+ }
1378
+ this.seekableClones.clear();
1379
+ for (const entry of this.interceptedAnimations) {
1380
+ try {
1381
+ entry.animation.cancel();
1382
+ } catch {
1383
+ }
1384
+ }
1385
+ this.interceptedAnimations.length = 0;
1386
+ if (this._originalAnimate) {
1387
+ Element.prototype.animate = this._originalAnimate;
1388
+ this._originalAnimate = null;
1389
+ delete window.__LAPSE_ORIGINAL_ANIMATE__;
1390
+ }
1391
+ if (this._originalRaf) {
1392
+ window.requestAnimationFrame = this._originalRaf;
1393
+ this._originalRaf = null;
1394
+ delete window.__LAPSE_ORIGINAL_RAF__;
1395
+ }
1396
+ const tl = window.__LAPSE_TIMELINE__;
1397
+ if (tl?.noTransitionsEl) {
1398
+ tl.noTransitionsEl.remove();
1399
+ }
1400
+ const noTrans = document.getElementById("__lapse-no-transitions");
1401
+ if (noTrans) noTrans.remove();
1402
+ const blocker = document.getElementById("__lapse-scrub-blocker");
1403
+ if (blocker) blocker.remove();
1404
+ if (tl?.blockerEl) tl.blockerEl.remove();
1405
+ const stateRules = document.getElementById("__lapse-state-rules");
1406
+ if (stateRules) stateRules.remove();
1407
+ if (tl?.lapseStyleEl) tl.lapseStyleEl.remove();
1408
+ if (this._originalRemoveChild) {
1409
+ Node.prototype.removeChild = this._originalRemoveChild;
1410
+ this._originalRemoveChild = null;
1411
+ } else if (tl?._removeChild) {
1412
+ Node.prototype.removeChild = tl._removeChild;
1413
+ }
1414
+ if (this._originalRemove) {
1415
+ Element.prototype.remove = this._originalRemove;
1416
+ this._originalRemove = null;
1417
+ } else if (tl?._remove) {
1418
+ Element.prototype.remove = tl._remove;
1419
+ }
1420
+ document.querySelectorAll("[data-lapse-exit-captured]").forEach((el) => {
1421
+ el.remove();
1422
+ });
1423
+ document.querySelectorAll("[data-lapse-id]").forEach((el) => {
1424
+ el.removeAttribute("data-lapse-id");
1425
+ });
1426
+ document.querySelectorAll("[data-lapse-hover]").forEach((el) => {
1427
+ el.removeAttribute("data-lapse-hover");
1428
+ });
1429
+ document.querySelectorAll("[data-lapse-focus]").forEach((el) => {
1430
+ el.removeAttribute("data-lapse-focus");
1431
+ });
1432
+ document.querySelectorAll("[data-lapse-portal-id]").forEach((el) => {
1433
+ el.removeAttribute("data-lapse-portal-id");
1434
+ el.removeAttribute("data-lapse-portal-hidden");
1435
+ });
1436
+ delete window.__LAPSE_TIMELINE__;
1437
+ this.elements.clear();
1438
+ this.frames.length = 0;
1439
+ this.capturedPortals.clear();
1440
+ this.animFrameRanges.clear();
1441
+ }
1442
+ };
1443
+
1444
+ // src/core/export.ts
1445
+ function getFrameAtTime(frames, timeMs) {
1446
+ if (frames.length === 0) return null;
1447
+ let lo = 0;
1448
+ let hi = frames.length - 1;
1449
+ while (lo < hi) {
1450
+ const mid = lo + hi >> 1;
1451
+ if (frames[mid].time < timeMs) lo = mid + 1;
1452
+ else hi = mid;
1453
+ }
1454
+ return frames[lo];
1455
+ }
1456
+ function generateExport(animations, frames, timeMs, filter = "active") {
1457
+ const frame = getFrameAtTime(frames, timeMs);
1458
+ const duration = frames.at(-1)?.time ?? 0;
1459
+ const filteredAnims = animations.filter((anim) => {
1460
+ if (filter === "all-animations") return true;
1461
+ if (filter === "active") {
1462
+ const frameAnim = frame?.animations.find((a) => a.animationId === anim.id);
1463
+ if (!frameAnim || frameAnim.progress <= 0 || frameAnim.progress >= 1) return false;
1464
+ if (frameAnim.properties.length > 0) {
1465
+ const hasRealChange = frameAnim.properties.some(
1466
+ (p) => p.from && p.to && p.from !== p.to
1467
+ );
1468
+ if (!hasRealChange) return false;
1469
+ }
1470
+ return true;
1471
+ }
1472
+ return true;
1473
+ });
1474
+ const animExports = filteredAnims.map((anim) => {
1475
+ let frameAnim = frame?.animations.find((a) => a.animationId === anim.id);
1476
+ let status = frameAnim ? "active" : "completed";
1477
+ if (!frameAnim) {
1478
+ for (let i = frames.length - 1; i >= 0; i--) {
1479
+ if (frames[i].time > timeMs) continue;
1480
+ const found = frames[i].animations.find((a) => a.animationId === anim.id);
1481
+ if (found) {
1482
+ frameAnim = found;
1483
+ break;
1484
+ }
1485
+ }
1486
+ if (!frameAnim) {
1487
+ for (let i = 0; i < frames.length; i++) {
1488
+ if (frames[i].time < timeMs) continue;
1489
+ const found = frames[i].animations.find((a) => a.animationId === anim.id);
1490
+ if (found) {
1491
+ frameAnim = found;
1492
+ status = "upcoming";
1493
+ break;
1494
+ }
1495
+ }
1496
+ }
1497
+ }
1498
+ const progressLabel = status === "active" ? `${Math.round((frameAnim?.progress ?? 0) * 100)}%` : status === "completed" ? "done" : "upcoming";
1499
+ return {
1500
+ element: anim.selector,
1501
+ elementLabel: anim.elementLabel || anim.selector,
1502
+ name: anim.name || anim.type,
1503
+ type: anim.type,
1504
+ timing: `${Math.round(anim.duration)}ms ${anim.easing}${anim.delay ? ` (delay: ${Math.round(anim.delay)}ms)` : ""}`,
1505
+ progress: progressLabel,
1506
+ properties: (frameAnim?.properties ?? []).map((p) => ({
1507
+ property: p.property,
1508
+ value: p.value,
1509
+ range: p.from && p.to ? `${p.from} \u2192 ${p.to}` : ""
1510
+ })),
1511
+ source: anim.source,
1512
+ resolvedVars: anim.resolvedVars,
1513
+ conflicts: anim.conflicts
1514
+ };
1515
+ });
1516
+ const hoveredElements = Array.isArray(frame?.hoveredSels) ? frame.hoveredSels.map(String) : [];
1517
+ const focusedElement = frame?.focusSel ? String(frame.focusSel) : null;
1518
+ return {
1519
+ timestamp: `${Math.round(timeMs)}ms into ${Math.round(duration)}ms recording`,
1520
+ duration: `${Math.round(duration)}ms`,
1521
+ scrubPosition: timeMs,
1522
+ hoveredElements,
1523
+ focusedElement,
1524
+ animations: animExports
1525
+ };
1526
+ }
1527
+ function formatExportForLLM(exp, detail = "moderate") {
1528
+ const lines = [];
1529
+ const grouped = /* @__PURE__ */ new Map();
1530
+ for (const anim of exp.animations) {
1531
+ const key = (anim.elementLabel || "") + "|||" + anim.element;
1532
+ if (!grouped.has(key)) {
1533
+ grouped.set(key, { label: anim.elementLabel || "", selector: anim.element, anims: [] });
1534
+ }
1535
+ grouped.get(key).anims.push(anim);
1536
+ }
1537
+ function isRealChange(prop) {
1538
+ if (!prop.range) return true;
1539
+ const [from, to] = prop.range.split(" \u2192 ");
1540
+ return !(from && to && from.trim() === to.trim());
1541
+ }
1542
+ if (detail === "brief") {
1543
+ lines.push(`# Animation State at ${exp.timestamp}`);
1544
+ lines.push("");
1545
+ for (const [, group] of grouped) {
1546
+ const label = group.label && group.label !== group.selector ? `**${group.label}** \`${group.selector}\`` : `\`${group.selector}\``;
1547
+ const cssAnims = group.anims.filter((a) => a.type !== "JSAnimation");
1548
+ const jsAnims = group.anims.filter((a) => a.type === "JSAnimation");
1549
+ if (cssAnims.length > 0) {
1550
+ const props = /* @__PURE__ */ new Set();
1551
+ for (const a of cssAnims) a.properties.filter(isRealChange).forEach((p) => props.add(p.property));
1552
+ const timing = cssAnims[0]?.timing || "";
1553
+ const progress = cssAnims[0]?.progress || "";
1554
+ const progressStr = progress && progress !== "unknown" ? ` @ ${progress}` : "";
1555
+ lines.push(`- ${label}: ${[...props].join(", ")} (${timing}${progressStr})`);
1556
+ }
1557
+ if (jsAnims.length > 0) {
1558
+ const props = /* @__PURE__ */ new Set();
1559
+ for (const a of jsAnims) a.properties.filter(isRealChange).forEach((p) => props.add(p.property));
1560
+ if (props.size > 0) lines.push(`- ${label}: ${[...props].join(", ")} (JS)`);
1561
+ }
1562
+ }
1563
+ return lines.join("\n");
1564
+ }
1565
+ lines.push(`# Animation State at ${exp.timestamp}`);
1566
+ lines.push("");
1567
+ if (detail === "granular") {
1568
+ lines.push("**Environment:**");
1569
+ lines.push(`- Viewport: ${window.innerWidth}\xD7${window.innerHeight}`);
1570
+ lines.push(`- URL: ${window.location.href}`);
1571
+ lines.push(`- User Agent: ${navigator.userAgent}`);
1572
+ lines.push(`- Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}`);
1573
+ lines.push(`- Device Pixel Ratio: ${window.devicePixelRatio}`);
1574
+ lines.push("");
1575
+ }
1576
+ if (exp.hoveredElements.length > 0 || exp.focusedElement) {
1577
+ lines.push("**Interaction state:**");
1578
+ if (exp.hoveredElements.length > 0) {
1579
+ const deepest = exp.hoveredElements[exp.hoveredElements.length - 1] || exp.hoveredElements[0];
1580
+ lines.push(`- Hovered: \`${deepest}\``);
1581
+ }
1582
+ if (exp.focusedElement) {
1583
+ lines.push(`- Focused: \`${exp.focusedElement}\``);
1584
+ }
1585
+ lines.push("");
1586
+ }
1587
+ if (exp.animations.length === 0 && exp.hoveredElements.length === 0 && !exp.focusedElement) {
1588
+ lines.push("No active animations or interactions at this position.");
1589
+ return lines.join("\n");
1590
+ }
1591
+ if (exp.animations.length === 0) {
1592
+ return lines.join("\n");
1593
+ }
1594
+ for (const [, group] of grouped) {
1595
+ const label = group.label && group.label !== group.selector ? `"${group.label}" ` : "";
1596
+ const cssAnims = group.anims.filter((a) => a.type !== "JSAnimation");
1597
+ const jsAnims = group.anims.filter((a) => a.type === "JSAnimation");
1598
+ const cssPropLines = [];
1599
+ if (cssAnims.length > 0) {
1600
+ const seenProps = /* @__PURE__ */ new Set();
1601
+ for (const anim of cssAnims) {
1602
+ const progressStr = anim.progress !== "unknown" ? ` @ ${anim.progress}` : "";
1603
+ for (const prop of anim.properties) {
1604
+ if (seenProps.has(prop.property)) continue;
1605
+ if (!isRealChange(prop)) continue;
1606
+ seenProps.add(prop.property);
1607
+ let line = `- \`${prop.property}\`: ${prop.value}`;
1608
+ if (prop.range) line += ` [${prop.range}]`;
1609
+ line += ` (${anim.timing}${progressStr})`;
1610
+ cssPropLines.push(line);
1611
+ }
1612
+ }
1613
+ }
1614
+ const jsPropLines = [];
1615
+ if (jsAnims.length > 0) {
1616
+ const seenProps = /* @__PURE__ */ new Set();
1617
+ for (const anim of jsAnims) {
1618
+ for (const prop of anim.properties) {
1619
+ if (seenProps.has(prop.property)) continue;
1620
+ if (!isRealChange(prop)) continue;
1621
+ seenProps.add(prop.property);
1622
+ jsPropLines.push(`- \`${prop.property}\`: ${prop.value}`);
1623
+ }
1624
+ }
1625
+ }
1626
+ if (cssPropLines.length === 0 && jsPropLines.length === 0) continue;
1627
+ lines.push(`## ${label}\`${group.selector}\``);
1628
+ lines.push("");
1629
+ if (cssPropLines.length > 0) {
1630
+ const transitionSet = new Set(cssAnims.map((a) => `${a.name} ${a.timing}`));
1631
+ lines.push(`Transitions: ${[...transitionSet].join(", ")}`);
1632
+ lines.push("");
1633
+ for (const line of cssPropLines) lines.push(line);
1634
+ if (detail === "detailed" || detail === "granular") {
1635
+ const allVars = {};
1636
+ for (const anim of cssAnims) {
1637
+ if (anim.resolvedVars) Object.assign(allVars, anim.resolvedVars);
1638
+ }
1639
+ if (Object.keys(allVars).length > 0) {
1640
+ lines.push("");
1641
+ lines.push("CSS variables:");
1642
+ for (const [name, value] of Object.entries(allVars)) {
1643
+ lines.push(`- \`${name}\`: ${value}`);
1644
+ }
1645
+ }
1646
+ for (const anim of cssAnims) {
1647
+ if (anim.source) {
1648
+ lines.push("");
1649
+ lines.push("```css");
1650
+ lines.push(anim.source);
1651
+ lines.push("```");
1652
+ break;
1653
+ }
1654
+ }
1655
+ }
1656
+ }
1657
+ if (jsPropLines.length > 0) {
1658
+ if (cssPropLines.length > 0) lines.push("");
1659
+ for (const line of jsPropLines) lines.push(line);
1660
+ }
1661
+ lines.push("");
1662
+ }
1663
+ return lines.join("\n");
1664
+ }
1665
+
1666
+ // src/core/engine.ts
1667
+ var SaccadeEngine = class {
1668
+ constructor() {
1669
+ this.timing = new TimingController();
1670
+ this.recorder = new TimelineRecorder();
1671
+ this.scrubber = null;
1672
+ this.capture = null;
1673
+ this._state = "idle";
1674
+ this.listeners = /* @__PURE__ */ new Set();
1675
+ }
1676
+ get state() {
1677
+ return this._state;
1678
+ }
1679
+ getCapture() {
1680
+ return this.capture;
1681
+ }
1682
+ // -- Speed control --------------------------------------------------------
1683
+ setSpeed(speed) {
1684
+ this.timing.setSpeed(speed);
1685
+ }
1686
+ getSpeed() {
1687
+ return this.timing.getSpeed();
1688
+ }
1689
+ /**
1690
+ * Install the timing patches immediately, without changing speed.
1691
+ *
1692
+ * Call this as early as possible (before app code, GSAP, or Framer Motion
1693
+ * run) so they capture the patched time functions rather than the originals.
1694
+ * Idempotent and harmless to call more than once. `setSpeed` and
1695
+ * `startRecording` also install on demand, so this is only needed to win the
1696
+ * early-load race against libraries that cache `Date.now`/`performance.now`.
1697
+ */
1698
+ install() {
1699
+ this.timing.install();
1700
+ }
1701
+ /** Register a module-imported GSAP instance so saccade can slow it. */
1702
+ registerGSAP(gsap) {
1703
+ this.timing.registerGSAP(gsap);
1704
+ }
1705
+ // -- Timeline recording ---------------------------------------------------
1706
+ startRecording(boundingBox) {
1707
+ if (this._state !== "idle") return;
1708
+ this.timing.install();
1709
+ this.recorder.onAutoStop = () => this.stopRecording();
1710
+ this.recorder.startRecording(boundingBox);
1711
+ this._state = "recording";
1712
+ this.notify();
1713
+ }
1714
+ stopRecording() {
1715
+ if (this._state !== "recording") {
1716
+ return {
1717
+ startTime: 0,
1718
+ endTime: 0,
1719
+ duration: 0,
1720
+ animations: [],
1721
+ frames: [],
1722
+ boundingBox: null
1723
+ };
1724
+ }
1725
+ let capture;
1726
+ try {
1727
+ capture = this.recorder.stopRecording();
1728
+ } catch (e) {
1729
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1730
+ document.getElementById("__lapse-scrub-blocker")?.remove();
1731
+ document.getElementById("__lapse-no-transitions")?.remove();
1732
+ document.getElementById("__lapse-state-rules")?.remove();
1733
+ this._state = "idle";
1734
+ this.notify();
1735
+ return {
1736
+ startTime: 0,
1737
+ endTime: 0,
1738
+ duration: 0,
1739
+ animations: [],
1740
+ frames: [],
1741
+ boundingBox: null
1742
+ };
1743
+ }
1744
+ this.capture = capture;
1745
+ const scrubberState = {
1746
+ elements: this.recorder.elements,
1747
+ frames: capture.frames,
1748
+ capturedPortals: this.recorder.capturedPortalIds,
1749
+ interceptedAnimations: this.recorder.interceptedAnimations,
1750
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1751
+ seekableClones: this.recorder.seekableClones
1752
+ };
1753
+ this.scrubber = new TimelineScrubber(scrubberState);
1754
+ this._state = "scrubbing";
1755
+ this.notify();
1756
+ return capture;
1757
+ }
1758
+ // -- Scrubbing ------------------------------------------------------------
1759
+ seekTo(timeMs) {
1760
+ this.scrubber?.seekTo(timeMs);
1761
+ }
1762
+ release() {
1763
+ this.scrubber?.release();
1764
+ this.scrubber = null;
1765
+ this.capture = null;
1766
+ this._state = "idle";
1767
+ this.notify();
1768
+ }
1769
+ // -- Export ---------------------------------------------------------------
1770
+ generateExport(timeMs, filter = "active") {
1771
+ if (!this.capture) return null;
1772
+ return generateExport(
1773
+ this.capture.animations,
1774
+ this.capture.frames,
1775
+ timeMs,
1776
+ filter
1777
+ );
1778
+ }
1779
+ exportForLLM(timeMs, filter = "active", detail = "moderate") {
1780
+ const exp = this.generateExport(timeMs, filter);
1781
+ if (!exp) return "";
1782
+ return formatExportForLLM(exp, detail);
1783
+ }
1784
+ // -- State subscription ---------------------------------------------------
1785
+ subscribe(listener) {
1786
+ this.listeners.add(listener);
1787
+ return () => this.listeners.delete(listener);
1788
+ }
1789
+ notify() {
1790
+ for (const listener of this.listeners) {
1791
+ listener();
1792
+ }
1793
+ }
1794
+ // -- Cleanup --------------------------------------------------------------
1795
+ destroy() {
1796
+ this.scrubber?.release();
1797
+ this.scrubber = null;
1798
+ this.capture = null;
1799
+ this.timing.destroy();
1800
+ this._state = "idle";
1801
+ }
1802
+ };
1803
+
1804
+ // src/core/shared.ts
1805
+ var KEY = "__saccadeSharedEngine__";
1806
+ function getSharedEngine() {
1807
+ const g = globalThis;
1808
+ if (!g[KEY]) g[KEY] = new SaccadeEngine();
1809
+ return g[KEY];
1810
+ }
1811
+
1812
+ // src/install.ts
1813
+ if (typeof window !== "undefined") {
1814
+ getSharedEngine().install();
1815
+ }
1816
+ export {
1817
+ getSharedEngine
1818
+ };
1819
+ //# sourceMappingURL=install.mjs.map