saccade 0.0.1

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/dist/index.mjs ADDED
@@ -0,0 +1,2791 @@
1
+ "use client";
2
+
3
+ // src/react/Lapse.tsx
4
+ import { useRef as useRef6, useState as useState4, useEffect as useEffect4 } from "react";
5
+ import { createPortal } from "react-dom";
6
+
7
+ // src/react/LapseContext.tsx
8
+ import { createContext, useContext, useRef } from "react";
9
+
10
+ // src/core/timing.ts
11
+ var TimingController = class {
12
+ constructor() {
13
+ this.speed = 1;
14
+ this.virtualBaseline = 0;
15
+ this.intervalMap = /* @__PURE__ */ new Map();
16
+ this.nextIntervalId = 1e6;
17
+ this.mediaObserver = null;
18
+ this.animObserver = null;
19
+ this._origAnimate = null;
20
+ this.installed = false;
21
+ this._raf = requestAnimationFrame.bind(window);
22
+ this._caf = cancelAnimationFrame.bind(window);
23
+ this._setTimeout = setTimeout.bind(window);
24
+ this._clearTimeout = clearTimeout.bind(window);
25
+ this._setInterval = setInterval.bind(window);
26
+ this._clearInterval = clearInterval.bind(window);
27
+ this._perfNow = performance.now.bind(performance);
28
+ this._dateNow = Date.now;
29
+ this.realBaseline = this._perfNow();
30
+ }
31
+ getVirtualTime() {
32
+ const realElapsed = this._perfNow() - this.realBaseline;
33
+ return this.virtualBaseline + realElapsed * this.speed;
34
+ }
35
+ reanchor() {
36
+ const virtualNow = this.getVirtualTime();
37
+ this.realBaseline = this._perfNow();
38
+ this.virtualBaseline = virtualNow;
39
+ }
40
+ /** Install timing patches. Safe to call multiple times. */
41
+ install() {
42
+ if (this.installed) return;
43
+ this.installed = true;
44
+ const self = this;
45
+ window.__LAPSE_ORIGINAL_RAF__ = this._raf;
46
+ const origAnimate = Element.prototype.animate;
47
+ this._origAnimate = origAnimate;
48
+ Element.prototype.animate = function(...args) {
49
+ const anim = origAnimate.apply(this, args);
50
+ if (self.speed !== 1) {
51
+ anim.playbackRate = self.speed || 1e-3;
52
+ }
53
+ return anim;
54
+ };
55
+ performance.now = () => self.getVirtualTime();
56
+ const dateBaseline = this._dateNow();
57
+ Date.now = () => dateBaseline + self.getVirtualTime();
58
+ window.requestAnimationFrame = (callback) => {
59
+ return self._raf(() => {
60
+ callback(self.getVirtualTime());
61
+ });
62
+ };
63
+ window.cancelAnimationFrame = this._caf;
64
+ window.setTimeout = ((handler, delay, ...args) => {
65
+ const scaledDelay = (delay ?? 0) / (self.speed || 1);
66
+ return self._setTimeout(handler, scaledDelay, ...args);
67
+ });
68
+ window.clearTimeout = this._clearTimeout;
69
+ window.setInterval = ((handler, delay, ...args) => {
70
+ const id = self.nextIntervalId++;
71
+ const baseDelay = delay ?? 0;
72
+ function tick() {
73
+ const scaledDelay = baseDelay / (self.speed || 1);
74
+ const realId = self._setTimeout(() => {
75
+ if (typeof handler === "function") {
76
+ ;
77
+ handler(...args);
78
+ }
79
+ if (self.intervalMap.has(id)) tick();
80
+ }, scaledDelay);
81
+ const entry = self.intervalMap.get(id);
82
+ if (entry) entry.realId = realId;
83
+ }
84
+ self.intervalMap.set(id, { handler, delay: baseDelay, realId: 0 });
85
+ tick();
86
+ return id;
87
+ });
88
+ window.clearInterval = ((id) => {
89
+ if (id == null) return;
90
+ const entry = self.intervalMap.get(id);
91
+ if (entry) {
92
+ self._clearTimeout(entry.realId);
93
+ self.intervalMap.delete(id);
94
+ } else {
95
+ self._clearInterval(id);
96
+ }
97
+ });
98
+ this.mediaObserver = new MutationObserver((mutations) => {
99
+ for (const mutation of mutations) {
100
+ for (const node of mutation.addedNodes) {
101
+ if (node instanceof HTMLVideoElement || node instanceof HTMLAudioElement) {
102
+ node.playbackRate = self.speed;
103
+ }
104
+ }
105
+ }
106
+ });
107
+ if (document.body) {
108
+ this.mediaObserver.observe(document.body, { childList: true, subtree: true });
109
+ }
110
+ }
111
+ /** Set playback speed. Requires install() first. */
112
+ setSpeed(newSpeed) {
113
+ if (!this.installed) this.install();
114
+ this.reanchor();
115
+ this.speed = newSpeed;
116
+ document.querySelectorAll("video, audio").forEach((el) => {
117
+ ;
118
+ el.playbackRate = newSpeed;
119
+ });
120
+ this.patchAnimations();
121
+ }
122
+ /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
123
+ patchAnimations() {
124
+ try {
125
+ const anims = document.getAnimations();
126
+ for (const anim of anims) {
127
+ const target = anim.effect?.target;
128
+ if (target?.closest?.("[data-lapse-panel]")) continue;
129
+ anim.playbackRate = this.speed || 1e-3;
130
+ }
131
+ } catch {
132
+ }
133
+ if (!this.animObserver) {
134
+ this.animObserver = this._setInterval(() => {
135
+ if (!this.installed) return;
136
+ try {
137
+ const anims = document.getAnimations();
138
+ for (const anim of anims) {
139
+ const target = anim.effect?.target;
140
+ if (target?.closest?.("[data-lapse-panel]")) continue;
141
+ if (anim.playbackRate !== this.speed) {
142
+ anim.playbackRate = this.speed || 1e-3;
143
+ }
144
+ }
145
+ } catch {
146
+ }
147
+ }, 100);
148
+ }
149
+ }
150
+ getSpeed() {
151
+ return this.speed;
152
+ }
153
+ /** Restore all patched APIs to originals. */
154
+ destroy() {
155
+ if (!this.installed) return;
156
+ performance.now = this._perfNow;
157
+ Date.now = this._dateNow;
158
+ window.requestAnimationFrame = this._raf;
159
+ window.cancelAnimationFrame = this._caf;
160
+ window.setTimeout = this._setTimeout;
161
+ window.clearTimeout = this._clearTimeout;
162
+ window.setInterval = this._setInterval;
163
+ window.clearInterval = this._clearInterval;
164
+ for (const [, entry] of this.intervalMap) {
165
+ this._clearTimeout(entry.realId);
166
+ }
167
+ this.intervalMap.clear();
168
+ if (this._origAnimate) {
169
+ Element.prototype.animate = this._origAnimate;
170
+ this._origAnimate = null;
171
+ }
172
+ this.mediaObserver?.disconnect();
173
+ this.mediaObserver = null;
174
+ if (this.animObserver != null) {
175
+ this._clearInterval(this.animObserver);
176
+ this.animObserver = null;
177
+ }
178
+ try {
179
+ for (const anim of document.getAnimations()) {
180
+ anim.playbackRate = 1;
181
+ }
182
+ } catch {
183
+ }
184
+ delete window.__LAPSE_ORIGINAL_RAF__;
185
+ this.installed = false;
186
+ }
187
+ };
188
+
189
+ // src/core/recorder.ts
190
+ var SAFE_PROPS = [
191
+ "opacity",
192
+ "transform",
193
+ "background-color",
194
+ "color",
195
+ "border-color",
196
+ "box-shadow",
197
+ "border-radius",
198
+ "filter",
199
+ "clip-path",
200
+ "scale",
201
+ "rotate",
202
+ "translate",
203
+ "outline-color",
204
+ "outline-width",
205
+ "outline-offset",
206
+ "outline-style",
207
+ "text-decoration-color",
208
+ "fill",
209
+ "stroke",
210
+ "stroke-dasharray",
211
+ "stroke-dashoffset",
212
+ "visibility",
213
+ "pointer-events"
214
+ ];
215
+ var LAYOUT_PROPS = [
216
+ "width",
217
+ "height",
218
+ "top",
219
+ "left",
220
+ "right",
221
+ "bottom",
222
+ "margin-top",
223
+ "margin-right",
224
+ "margin-bottom",
225
+ "margin-left",
226
+ "padding-top",
227
+ "padding-right",
228
+ "padding-bottom",
229
+ "padding-left",
230
+ "max-height",
231
+ "max-width",
232
+ "min-height",
233
+ "min-width",
234
+ "display"
235
+ ];
236
+ var SNAPSHOT_PROPS = [...SAFE_PROPS, ...LAYOUT_PROPS];
237
+ var SAFE_PROPS_SET = new Set(SAFE_PROPS);
238
+ var SNAPSHOT_ATTRS = [
239
+ "data-state",
240
+ "data-checked",
241
+ "data-disabled",
242
+ "data-highlighted",
243
+ "data-orientation",
244
+ "data-active",
245
+ "data-selected",
246
+ "data-open",
247
+ "data-closed",
248
+ "data-side",
249
+ "data-align",
250
+ "data-focus",
251
+ "data-hover",
252
+ "data-at-boundary",
253
+ "data-scrubbing",
254
+ "aria-checked",
255
+ "aria-selected",
256
+ "aria-expanded",
257
+ "aria-pressed",
258
+ "aria-hidden",
259
+ "aria-disabled",
260
+ "aria-valuenow",
261
+ "aria-valuemin",
262
+ "aria-valuemax",
263
+ "aria-invalid",
264
+ "checked",
265
+ "disabled",
266
+ "hidden",
267
+ "value",
268
+ "class",
269
+ "style"
270
+ ];
271
+ var STATE_SELECTORS = [
272
+ "[data-state]",
273
+ "[aria-checked]",
274
+ "[aria-selected]",
275
+ "[aria-expanded]",
276
+ "[aria-pressed]",
277
+ '[role="radio"]',
278
+ '[role="checkbox"]',
279
+ '[role="switch"]',
280
+ '[role="tab"]',
281
+ '[role="tabpanel"]',
282
+ '[role="option"]',
283
+ '[role="slider"]',
284
+ '[role="menuitem"]',
285
+ '[role="menuitemcheckbox"]',
286
+ '[role="menuitemradio"]',
287
+ '[type="radio"]',
288
+ '[type="checkbox"]',
289
+ '[type="range"]',
290
+ "input",
291
+ "button",
292
+ "select",
293
+ "textarea",
294
+ "[data-radix-collection-item]",
295
+ '[data-slot="slider-thumb"]',
296
+ '[data-slot="slider-track"]'
297
+ ].join(",");
298
+ function getSelector(el) {
299
+ if (!el || !el.tagName) return null;
300
+ const parts = [];
301
+ let current = el;
302
+ for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
303
+ const tag = current.tagName.toLowerCase();
304
+ const parent = current.parentElement;
305
+ if (parent) {
306
+ const siblings = Array.from(parent.children);
307
+ const idx = siblings.indexOf(current) + 1;
308
+ parts.unshift(tag + ":nth-child(" + idx + ")");
309
+ } else {
310
+ parts.unshift(tag);
311
+ }
312
+ current = parent;
313
+ }
314
+ return parts.join(" > ");
315
+ }
316
+ function getReadableSelector(el) {
317
+ if (!el || !el.tagName) return "unknown";
318
+ if (el.id) return "#" + el.id;
319
+ const tag = el.tagName.toLowerCase();
320
+ const classes = Array.from(el.classList || []).slice(0, 3).map((c) => "." + c).join("");
321
+ return tag + classes;
322
+ }
323
+ function getElementLabel(el) {
324
+ if (!el) return "unknown";
325
+ const ariaLabel = el.getAttribute("aria-label");
326
+ if (ariaLabel) return ariaLabel;
327
+ const text = (el.textContent || "").trim();
328
+ if (text && text.length < 40 && text.length > 0) return text;
329
+ const label = el.closest("label") || el.closest("[aria-label]");
330
+ if (label) {
331
+ const lt = label.getAttribute("aria-label") || (label.textContent || "").trim();
332
+ if (lt && lt.length < 40) return lt;
333
+ }
334
+ const state = el.getAttribute("data-state");
335
+ const role = el.getAttribute("role");
336
+ if (role && state) return role + " (" + state + ")";
337
+ if (role) return role;
338
+ return getReadableSelector(el);
339
+ }
340
+ function cssSplit(str) {
341
+ const parts = [];
342
+ let depth = 0;
343
+ let start = 0;
344
+ for (let i = 0; i < str.length; i++) {
345
+ if (str[i] === "(") depth++;
346
+ else if (str[i] === ")") depth--;
347
+ else if (str[i] === "," && depth === 0) {
348
+ parts.push(str.slice(start, i).trim());
349
+ start = i + 1;
350
+ }
351
+ }
352
+ parts.push(str.slice(start).trim());
353
+ return parts;
354
+ }
355
+ var _TimelineRecorder = class _TimelineRecorder {
356
+ constructor() {
357
+ // ---- Recording state ----------------------------------------------------
358
+ this.recording = false;
359
+ this.startTime = 0;
360
+ this.boundingBox = null;
361
+ /** Structural-selector -> live DOM element. */
362
+ this.elements = /* @__PURE__ */ new Map();
363
+ /** Animation id -> info. */
364
+ this.animations = /* @__PURE__ */ new Map();
365
+ /** Original inline style per element (captured on first encounter). */
366
+ this.originalStyles = /* @__PURE__ */ new Map();
367
+ /** Captured frames. */
368
+ this.frames = [];
369
+ // ---- Portal management --------------------------------------------------
370
+ this.activePortals = /* @__PURE__ */ new Map();
371
+ this.portalIdCounter = 0;
372
+ this.currentPortalIds = /* @__PURE__ */ new Set();
373
+ this.capturedPortals = /* @__PURE__ */ new Set();
374
+ // ---- JS animation detection (Phase 2) -----------------------------------
375
+ this.prevInlineStyles = /* @__PURE__ */ new Map();
376
+ this.jsAnimStartTimes = /* @__PURE__ */ new Map();
377
+ this.jsAnimLastSeen = /* @__PURE__ */ new Map();
378
+ this.jsAnimFromValues = /* @__PURE__ */ new Map();
379
+ this.jsAnimChangeCount = /* @__PURE__ */ new Map();
380
+ // ---- Interaction state --------------------------------------------------
381
+ this.currentHoverEls = /* @__PURE__ */ new Set();
382
+ this.currentFocusSel = null;
383
+ this.currentPointer = { x: 0, y: 0, buttons: 0 };
384
+ // ---- Observers & listeners ----------------------------------------------
385
+ this.attrObserver = null;
386
+ this.portalObserver = null;
387
+ this.exitObserver = null;
388
+ this.onMouseOver = null;
389
+ this.onMouseOut = null;
390
+ this.onFocusIn = null;
391
+ this.onFocusOut = null;
392
+ this.onPointerMove = null;
393
+ this.onPointerDown = null;
394
+ this.onPointerUp = null;
395
+ // ---- WAAPI interception --------------------------------------------------
396
+ /** Animations captured via Element.prototype.animate monkey-patch. */
397
+ this.interceptedAnimations = [];
398
+ this.hiddenSince = null;
399
+ this.onVisibilityChange = null;
400
+ /** Set to true when the capture loop self-terminates due to limits. */
401
+ this.autoStopped = false;
402
+ /** Called when recording auto-stops. Set by the engine. */
403
+ this.onAutoStop = null;
404
+ // ---- Saved originals (for restoration) ----------------------------------
405
+ this._removeChild = null;
406
+ this._remove = null;
407
+ this._elementAnimate = null;
408
+ // ---- DOM elements injected during stop ----------------------------------
409
+ /** `<style>` that disables all transitions/animations after recording. */
410
+ this.noTransitionsEl = null;
411
+ /** Full-screen overlay blocking interaction during scrub. */
412
+ this.blockerEl = null;
413
+ /** `<style>` with cloned :hover / :focus rules rewritten as `[data-lapse-*]`. */
414
+ this.lapseStyleEl = null;
415
+ // =========================================================================
416
+ // Public API — helpers exposed for the scrubber
417
+ // =========================================================================
418
+ this.getSelector = getSelector;
419
+ }
420
+ // ---- Unpatched rAF reference --------------------------------------------
421
+ get _raf() {
422
+ return window.__LAPSE_ORIGINAL_RAF__ || requestAnimationFrame;
423
+ }
424
+ get SAFE_PROPS_SET() {
425
+ return SAFE_PROPS_SET;
426
+ }
427
+ get capturedPortalIds() {
428
+ return this.capturedPortals;
429
+ }
430
+ // =========================================================================
431
+ // startRecording
432
+ // =========================================================================
433
+ startRecording(boundingBox) {
434
+ if (this.recording) return;
435
+ this.recording = true;
436
+ this.autoStopped = false;
437
+ this.hiddenSince = null;
438
+ this.startTime = performance.now();
439
+ this.frames = [];
440
+ this.animations.clear();
441
+ this.elements.clear();
442
+ this.originalStyles.clear();
443
+ this.boundingBox = boundingBox || null;
444
+ this.activePortals.clear();
445
+ this.portalIdCounter = 0;
446
+ this.currentPortalIds.clear();
447
+ this.capturedPortals.clear();
448
+ this.prevInlineStyles.clear();
449
+ this.jsAnimStartTimes.clear();
450
+ this.jsAnimLastSeen.clear();
451
+ this.jsAnimFromValues.clear();
452
+ this.jsAnimChangeCount.clear();
453
+ this.currentHoverEls.clear();
454
+ this.currentFocusSel = null;
455
+ this.currentPointer = { x: 0, y: 0, buttons: 0 };
456
+ this.onVisibilityChange = () => {
457
+ if (!this.recording) return;
458
+ if (document.hidden) {
459
+ this.hiddenSince = performance.now();
460
+ } else {
461
+ this.hiddenSince = null;
462
+ }
463
+ };
464
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
465
+ const trackElement = (el) => {
466
+ if (!el || !el.tagName) return;
467
+ if (el.closest?.("[data-lapse-panel]")) return;
468
+ const sel = getSelector(el);
469
+ if (!sel) return;
470
+ const isNew = !this.elements.has(sel);
471
+ this.elements.set(sel, el);
472
+ if (isNew) {
473
+ this.originalStyles.set(sel, el.getAttribute("style") || "");
474
+ }
475
+ };
476
+ const trackTree = (root) => {
477
+ if (!root || !root.querySelectorAll) return;
478
+ trackElement(root);
479
+ for (const child of root.querySelectorAll("*")) {
480
+ trackElement(child);
481
+ }
482
+ };
483
+ const isInBounds = (el) => {
484
+ if (el.closest?.("[data-lapse-panel]")) return false;
485
+ if (!this.boundingBox) return true;
486
+ const r = el.getBoundingClientRect();
487
+ const bb = this.boundingBox;
488
+ 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;
489
+ };
490
+ try {
491
+ const stateEls = document.querySelectorAll(STATE_SELECTORS);
492
+ for (const el of stateEls) {
493
+ if (!isInBounds(el)) continue;
494
+ trackElement(el);
495
+ for (const child of el.querySelectorAll("*")) {
496
+ trackElement(child);
497
+ }
498
+ const parent = el.parentElement;
499
+ if (parent) {
500
+ trackElement(parent);
501
+ for (const sibling of parent.children) {
502
+ trackElement(sibling);
503
+ for (const child of sibling.querySelectorAll("*")) {
504
+ trackElement(child);
505
+ }
506
+ }
507
+ }
508
+ }
509
+ } catch (_) {
510
+ }
511
+ this.attrObserver = new MutationObserver((mutations) => {
512
+ for (const m of mutations) {
513
+ if (m.target instanceof HTMLElement) {
514
+ trackElement(m.target);
515
+ const parent = m.target.parentElement;
516
+ if (parent) {
517
+ for (const sibling of parent.children) {
518
+ trackElement(sibling);
519
+ for (const child of sibling.querySelectorAll("*")) {
520
+ trackElement(child);
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }
526
+ });
527
+ this.attrObserver.observe(document.body, {
528
+ attributes: true,
529
+ attributeFilter: [
530
+ "data-state",
531
+ "data-highlighted",
532
+ "data-hover",
533
+ "data-focus",
534
+ "data-active",
535
+ "data-selected",
536
+ "data-open",
537
+ "data-closed",
538
+ "data-at-boundary",
539
+ "data-scrubbing",
540
+ "aria-checked",
541
+ "aria-selected",
542
+ "aria-expanded",
543
+ "aria-valuenow",
544
+ "aria-invalid",
545
+ "class",
546
+ "style"
547
+ ],
548
+ subtree: true
549
+ });
550
+ this._removeChild = Node.prototype.removeChild;
551
+ const savedRemoveChild = this._removeChild;
552
+ const self = this;
553
+ Node.prototype.removeChild = function(child) {
554
+ if (self.recording && child instanceof HTMLElement && child.hasAttribute("data-lapse-portal-id")) {
555
+ child.style.display = "none";
556
+ child.setAttribute("data-lapse-portal-hidden", "");
557
+ const id = child.getAttribute("data-lapse-portal-id");
558
+ if (id) self.currentPortalIds.delete(id);
559
+ return child;
560
+ }
561
+ return savedRemoveChild.call(this, child);
562
+ };
563
+ this._remove = Element.prototype.remove;
564
+ const savedRemove = this._remove;
565
+ Element.prototype.remove = function() {
566
+ if (self.recording && this instanceof HTMLElement && this.hasAttribute("data-lapse-portal-id")) {
567
+ this.style.display = "none";
568
+ this.setAttribute("data-lapse-portal-hidden", "");
569
+ const id = this.getAttribute("data-lapse-portal-id");
570
+ if (id) self.currentPortalIds.delete(id);
571
+ return;
572
+ }
573
+ return savedRemove.call(this);
574
+ };
575
+ this._elementAnimate = Element.prototype.animate;
576
+ const savedAnimate = this._elementAnimate;
577
+ Element.prototype.animate = function(keyframes, options) {
578
+ const anim = savedAnimate.call(this, keyframes, options);
579
+ if (self.recording && this instanceof HTMLElement && isInBounds(this)) {
580
+ trackElement(this);
581
+ self.interceptedAnimations.push({ animation: anim, target: this });
582
+ }
583
+ return anim;
584
+ };
585
+ this.portalObserver = new MutationObserver((mutations) => {
586
+ for (const m of mutations) {
587
+ for (const node of m.addedNodes) {
588
+ if (!(node instanceof HTMLElement)) continue;
589
+ if (node.parentElement !== document.body) continue;
590
+ if (node.hasAttribute("data-lapse-portal-id")) continue;
591
+ const isPortal = node.hasAttribute("data-radix-popper-content-wrapper") || node.hasAttribute("data-radix-portal") || node.getAttribute("role") === "dialog" || node.getAttribute("role") === "alertdialog" || node.querySelector(
592
+ '[role="menu"], [role="dialog"], [role="alertdialog"], [role="listbox"], [role="tooltip"], [data-radix-popper-content-wrapper]'
593
+ ) || node.hasAttribute("data-state") || node.style && (node.style.position === "fixed" || node.style.position === "absolute") || getComputedStyle(node).position === "fixed";
594
+ if (!isPortal) continue;
595
+ const id = "__lapse_portal_" + this.portalIdCounter++;
596
+ node.setAttribute("data-lapse-portal-id", id);
597
+ this.activePortals.set(id, { element: node });
598
+ this.currentPortalIds.add(id);
599
+ this.capturedPortals.add(id);
600
+ trackTree(node);
601
+ }
602
+ }
603
+ });
604
+ this.portalObserver.observe(document.body, { childList: true });
605
+ this.exitObserver = new MutationObserver((mutations) => {
606
+ if (!this.recording) return;
607
+ for (const m of mutations) {
608
+ for (const node of m.removedNodes) {
609
+ if (!(node instanceof HTMLElement)) continue;
610
+ const sel = getSelector(node);
611
+ if (sel && this.elements.has(sel)) {
612
+ if (!node.isConnected) {
613
+ node.style.display = "none";
614
+ node.setAttribute("data-lapse-exit-captured", "");
615
+ (m.target || document.body).appendChild(node);
616
+ }
617
+ }
618
+ }
619
+ }
620
+ });
621
+ this.exitObserver.observe(document.body, { childList: true, subtree: true });
622
+ this.onMouseOver = (e) => {
623
+ if (!this.recording) return;
624
+ this.currentHoverEls.clear();
625
+ let el = e.target;
626
+ while (el && el !== document.body) {
627
+ const sel = getSelector(el);
628
+ if (sel) {
629
+ this.currentHoverEls.add(sel);
630
+ trackElement(el);
631
+ }
632
+ el = el.parentElement;
633
+ }
634
+ };
635
+ this.onMouseOut = (e) => {
636
+ if (!this.recording) return;
637
+ if (!e.relatedTarget || !document.body.contains(e.relatedTarget)) {
638
+ this.currentHoverEls.clear();
639
+ }
640
+ };
641
+ document.addEventListener("mouseover", this.onMouseOver, true);
642
+ document.addEventListener("mouseout", this.onMouseOut, true);
643
+ this.onFocusIn = (e) => {
644
+ if (!this.recording) return;
645
+ const el = e.target;
646
+ if (el && el !== document.body) {
647
+ const sel = getSelector(el);
648
+ this.currentFocusSel = sel;
649
+ trackElement(el);
650
+ let parent = el.parentElement;
651
+ while (parent && parent !== document.body) {
652
+ trackElement(parent);
653
+ parent = parent.parentElement;
654
+ }
655
+ }
656
+ };
657
+ this.onFocusOut = () => {
658
+ if (!this.recording) return;
659
+ this.currentFocusSel = null;
660
+ };
661
+ document.addEventListener("focusin", this.onFocusIn, true);
662
+ document.addEventListener("focusout", this.onFocusOut, true);
663
+ this.onPointerMove = (e) => {
664
+ if (!this.recording) return;
665
+ this.currentPointer = { x: e.clientX, y: e.clientY, buttons: e.buttons };
666
+ };
667
+ this.onPointerDown = (e) => {
668
+ if (!this.recording) return;
669
+ this.currentPointer = { x: e.clientX, y: e.clientY, buttons: e.buttons };
670
+ };
671
+ this.onPointerUp = (e) => {
672
+ if (!this.recording) return;
673
+ this.currentPointer = { x: e.clientX, y: e.clientY, buttons: 0 };
674
+ };
675
+ document.addEventListener("pointermove", this.onPointerMove, true);
676
+ document.addEventListener("pointerdown", this.onPointerDown, true);
677
+ document.addEventListener("pointerup", this.onPointerUp, true);
678
+ const captureFrame = () => {
679
+ if (!this.recording) return;
680
+ const time = performance.now() - this.startTime;
681
+ if (time >= _TimelineRecorder.MAX_DURATION_MS || this.frames.length >= _TimelineRecorder.MAX_FRAMES || this.hiddenSince && performance.now() - this.hiddenSince > 5e3) {
682
+ this.recording = false;
683
+ this.autoStopped = true;
684
+ this.onAutoStop?.();
685
+ return;
686
+ }
687
+ const anims = document.getAnimations();
688
+ const frameAnims = [];
689
+ for (const a of anims) {
690
+ const target = a.effect?.target;
691
+ if (!target) continue;
692
+ if (!isInBounds(target)) continue;
693
+ const uniqueSelector = getSelector(target);
694
+ if (!uniqueSelector) continue;
695
+ const readableSelector = getReadableSelector(target);
696
+ trackElement(target);
697
+ const timing = a.effect?.getTiming?.() || {};
698
+ const duration = typeof timing.duration === "number" ? timing.duration : 0;
699
+ const delay = timing.delay || 0;
700
+ const easing = timing.easing || "linear";
701
+ let name = "";
702
+ let type = "WebAnimation";
703
+ if (a.constructor.name === "CSSTransition" || a.transitionProperty) {
704
+ name = a.transitionProperty || "";
705
+ type = "CSSTransition";
706
+ } else if (a.constructor.name === "CSSAnimation" || a.animationName) {
707
+ name = a.animationName || "";
708
+ type = "CSSAnimation";
709
+ }
710
+ const id = type + ":" + name + ":" + uniqueSelector;
711
+ if (!this.animations.has(id)) {
712
+ const keyframes2 = a.effect?.getKeyframes?.() || [];
713
+ let source = null;
714
+ if (type === "CSSAnimation" && keyframes2.length > 0) {
715
+ const kfLines = keyframes2.map((kf) => {
716
+ const offset = Math.round((kf.offset ?? 0) * 100) + "%";
717
+ const props = Object.entries(kf).filter(
718
+ ([k]) => !["offset", "easing", "composite", "computedOffset"].includes(k)
719
+ ).map(
720
+ ([k, v]) => k.replace(/([A-Z])/g, "-$1").toLowerCase() + ": " + v
721
+ ).join("; ");
722
+ return " " + offset + " { " + props + " }";
723
+ }).join("\n");
724
+ source = "@keyframes " + (name || "anonymous") + " {\n" + kfLines + "\n}";
725
+ } else if (type === "CSSTransition") {
726
+ source = "transition: " + name + " " + duration + "ms " + easing;
727
+ }
728
+ const resolvedVars = {};
729
+ try {
730
+ const cs = getComputedStyle(target);
731
+ const style = target.getAttribute("style") || "";
732
+ const allRules = [];
733
+ for (const sheet of document.styleSheets) {
734
+ try {
735
+ for (const rule of sheet.cssRules) {
736
+ if (rule.selectorText && target.matches(rule.selectorText)) {
737
+ allRules.push(rule.cssText);
738
+ }
739
+ }
740
+ } catch (_) {
741
+ }
742
+ }
743
+ const allCssText = allRules.join(" ") + " " + style;
744
+ const varRegex = /var\(\s*(--[a-zA-Z0-9-]+)/g;
745
+ let varMatch;
746
+ while ((varMatch = varRegex.exec(allCssText)) !== null) {
747
+ const varName = varMatch[1];
748
+ const resolved = cs.getPropertyValue(varName).trim();
749
+ if (resolved) {
750
+ resolvedVars[varName] = resolved;
751
+ }
752
+ }
753
+ } catch (_) {
754
+ }
755
+ let conflicts = null;
756
+ if (type === "CSSTransition") {
757
+ try {
758
+ const cs = getComputedStyle(target);
759
+ const tProps = cssSplit(cs.transitionProperty);
760
+ const tDurations = cssSplit(cs.transitionDuration).map(
761
+ (s) => parseFloat(s) * 1e3
762
+ );
763
+ const tEasings = cssSplit(cs.transitionTimingFunction);
764
+ const tDelays = cssSplit(cs.transitionDelay).map(
765
+ (s) => parseFloat(s) * 1e3
766
+ );
767
+ let idx = tProps.indexOf(name);
768
+ if (idx === -1 && tProps.includes("all"))
769
+ idx = tProps.indexOf("all");
770
+ if (idx >= 0) {
771
+ const declaredDuration = tDurations[idx % tDurations.length] || 0;
772
+ const declaredEasing = tEasings[idx % tEasings.length] || "ease";
773
+ const declaredDelay = tDelays[idx % tDelays.length] || 0;
774
+ const diffs = [];
775
+ if (Math.abs(declaredDuration - duration) > 1) {
776
+ diffs.push(
777
+ "duration: declared " + declaredDuration + "ms, actual " + duration + "ms"
778
+ );
779
+ }
780
+ if (declaredEasing !== easing) {
781
+ const normDeclared = declaredEasing.replace(/\s/g, "");
782
+ const normActual = easing.replace(/\s/g, "");
783
+ if (normDeclared !== normActual) {
784
+ diffs.push(
785
+ "easing: declared " + declaredEasing + ", actual " + easing
786
+ );
787
+ }
788
+ }
789
+ if (Math.abs(declaredDelay - delay) > 1) {
790
+ diffs.push(
791
+ "delay: declared " + declaredDelay + "ms, actual " + delay + "ms"
792
+ );
793
+ }
794
+ if (diffs.length > 0) {
795
+ conflicts = diffs;
796
+ }
797
+ }
798
+ } catch (_) {
799
+ }
800
+ }
801
+ this.animations.set(id, {
802
+ id,
803
+ name,
804
+ selector: readableSelector,
805
+ elementLabel: getElementLabel(target),
806
+ duration,
807
+ delay,
808
+ easing,
809
+ type,
810
+ source,
811
+ resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
812
+ conflicts
813
+ });
814
+ }
815
+ const keyframes = a.effect?.getKeyframes?.() || [];
816
+ const animatedProps = /* @__PURE__ */ new Set();
817
+ for (const kf of keyframes) {
818
+ for (const key of Object.keys(kf)) {
819
+ if (!["offset", "easing", "composite", "computedOffset"].includes(key)) {
820
+ animatedProps.add(key);
821
+ }
822
+ }
823
+ }
824
+ const computedStyle = getComputedStyle(target);
825
+ const properties = [];
826
+ for (const prop of animatedProps) {
827
+ const kebab = prop.replace(/([A-Z])/g, "-$1").toLowerCase();
828
+ const value = computedStyle.getPropertyValue(kebab) || "";
829
+ if (value) {
830
+ const from = keyframes[0]?.[prop] || null;
831
+ const to = keyframes[keyframes.length - 1]?.[prop] || null;
832
+ properties.push({ property: kebab, value, from, to });
833
+ }
834
+ }
835
+ const currentTime = a.currentTime ?? 0;
836
+ const progress = duration > 0 ? Math.max(0, Math.min(1, currentTime / duration)) : 0;
837
+ frameAnims.push({ animationId: id, currentTime, progress, properties });
838
+ }
839
+ const waapiAnimatedIds = /* @__PURE__ */ new Set();
840
+ for (const fa of frameAnims) {
841
+ const parts = fa.animationId.split(":");
842
+ waapiAnimatedIds.add(parts[parts.length - 1]);
843
+ }
844
+ const JS_TRACK_PROPS = ["transform", "opacity", "left", "top", "right", "bottom", "background"];
845
+ const waapiAnimatedProps = /* @__PURE__ */ new Map();
846
+ for (const fa of frameAnims) {
847
+ const parts = fa.animationId.split(":");
848
+ const elKey = parts[parts.length - 1];
849
+ if (!waapiAnimatedProps.has(elKey)) waapiAnimatedProps.set(elKey, /* @__PURE__ */ new Set());
850
+ const propName = parts[1];
851
+ if (propName) waapiAnimatedProps.get(elKey).add(propName);
852
+ }
853
+ for (const [elId, el] of this.elements) {
854
+ if (!el.isConnected) continue;
855
+ const hasWaapi = waapiAnimatedIds.has(elId);
856
+ const waapiProps = waapiAnimatedProps.get(elId);
857
+ const currentInline = {};
858
+ let hasAnyInline = false;
859
+ for (const prop of JS_TRACK_PROPS) {
860
+ const camel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
861
+ const val = el.style[camel];
862
+ if (val) {
863
+ currentInline[prop] = val;
864
+ hasAnyInline = true;
865
+ }
866
+ }
867
+ if (!hasAnyInline) {
868
+ this.prevInlineStyles.set(elId, currentInline);
869
+ continue;
870
+ }
871
+ const prevInline = this.prevInlineStyles.get(elId) || {};
872
+ this.prevInlineStyles.set(elId, currentInline);
873
+ for (const p of JS_TRACK_PROPS) {
874
+ const cur = currentInline[p] || "";
875
+ const prev = prevInline[p] || "";
876
+ const propCoveredByWaapi = waapiProps?.has(p) || waapiProps?.has(p.replace(/-([a-z])/g, (_, c) => c.toUpperCase()));
877
+ if (cur && cur !== prev && !propCoveredByWaapi) {
878
+ const jsId = "JSAnimation:" + p + ":" + elId;
879
+ this.jsAnimChangeCount.set(jsId, (this.jsAnimChangeCount.get(jsId) || 0) + 1);
880
+ if (!this.jsAnimStartTimes.has(jsId)) {
881
+ this.jsAnimStartTimes.set(jsId, time);
882
+ this.jsAnimFromValues.set(jsId, prev || cur);
883
+ }
884
+ this.jsAnimLastSeen.set(jsId, time);
885
+ if (this.jsAnimChangeCount.get(jsId) < 3) continue;
886
+ if (!this.animations.has(jsId)) {
887
+ const readableSel = getReadableSelector(el);
888
+ this.animations.set(jsId, {
889
+ id: jsId,
890
+ name: p,
891
+ selector: readableSel,
892
+ elementLabel: getElementLabel(el),
893
+ duration: 0,
894
+ delay: 0,
895
+ easing: "JS-driven",
896
+ type: "JSAnimation",
897
+ source: "Detected: inline style animation (JS library or requestAnimationFrame)",
898
+ resolvedVars: null,
899
+ conflicts: null
900
+ });
901
+ }
902
+ const startT = this.jsAnimStartTimes.get(jsId);
903
+ const anim = this.animations.get(jsId);
904
+ anim.duration = time - startT;
905
+ const fromVal = this.jsAnimFromValues.get(jsId) || "";
906
+ const elapsed = time - startT;
907
+ const estimatedDuration = Math.max(anim.duration, 300);
908
+ const prog = Math.min(1, elapsed / estimatedDuration);
909
+ frameAnims.push({
910
+ animationId: jsId,
911
+ currentTime: elapsed,
912
+ progress: prog,
913
+ properties: [{
914
+ property: p,
915
+ value: cur,
916
+ from: fromVal,
917
+ to: cur
918
+ }]
919
+ });
920
+ }
921
+ }
922
+ }
923
+ const elementSnapshots = {};
924
+ for (const [sel, el] of this.elements) {
925
+ if (!el.isConnected) continue;
926
+ const cs = getComputedStyle(el);
927
+ const snap = { __styles: {}, __attrs: {} };
928
+ for (const prop of SNAPSHOT_PROPS) {
929
+ snap.__styles[prop] = cs.getPropertyValue(prop);
930
+ }
931
+ for (const attr of SNAPSHOT_ATTRS) {
932
+ if (attr === "checked") {
933
+ snap.__attrs[attr] = el.checked ? "true" : null;
934
+ } else if (attr === "class") {
935
+ snap.__attrs[attr] = el.className;
936
+ } else if (attr === "style") {
937
+ snap.__attrs[attr] = el.getAttribute("style") || "";
938
+ } else if (attr === "value") {
939
+ snap.__attrs[attr] = el.value !== void 0 ? String(el.value) : null;
940
+ } else {
941
+ snap.__attrs[attr] = el.getAttribute(attr);
942
+ }
943
+ }
944
+ try {
945
+ snap.__afterOpacity = getComputedStyle(el, "::after").getPropertyValue(
946
+ "opacity"
947
+ );
948
+ snap.__beforeOpacity = getComputedStyle(
949
+ el,
950
+ "::before"
951
+ ).getPropertyValue("opacity");
952
+ } catch (_) {
953
+ }
954
+ elementSnapshots[sel] = snap;
955
+ }
956
+ for (const [id, portal] of this.activePortals) {
957
+ if (portal.element?.isConnected && !portal.element.hasAttribute("data-lapse-portal-hidden")) {
958
+ this.currentPortalIds.add(id);
959
+ }
960
+ }
961
+ this.frames.push({
962
+ time,
963
+ animations: frameAnims,
964
+ elementSnapshots,
965
+ activePortalIds: [...this.currentPortalIds],
966
+ hoveredSels: [...this.currentHoverEls],
967
+ focusSel: this.currentFocusSel,
968
+ pointer: { ...this.currentPointer },
969
+ scrollPositions: {}
970
+ });
971
+ this._raf(captureFrame);
972
+ };
973
+ this._raf(captureFrame);
974
+ }
975
+ // =========================================================================
976
+ // stopRecording
977
+ // =========================================================================
978
+ stopRecording() {
979
+ if (!this.recording) {
980
+ return {
981
+ startTime: 0,
982
+ endTime: 0,
983
+ duration: 0,
984
+ animations: [],
985
+ frames: [],
986
+ boundingBox: null
987
+ };
988
+ }
989
+ this.recording = false;
990
+ if (this._removeChild) Node.prototype.removeChild = this._removeChild;
991
+ if (this._remove) Element.prototype.remove = this._remove;
992
+ if (this._elementAnimate) Element.prototype.animate = this._elementAnimate;
993
+ this.attrObserver?.disconnect();
994
+ this.portalObserver?.disconnect();
995
+ this.exitObserver?.disconnect();
996
+ if (this.onVisibilityChange) {
997
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
998
+ this.onVisibilityChange = null;
999
+ }
1000
+ if (this.onMouseOver)
1001
+ document.removeEventListener("mouseover", this.onMouseOver, true);
1002
+ if (this.onMouseOut)
1003
+ document.removeEventListener("mouseout", this.onMouseOut, true);
1004
+ if (this.onFocusIn)
1005
+ document.removeEventListener("focusin", this.onFocusIn, true);
1006
+ if (this.onFocusOut)
1007
+ document.removeEventListener("focusout", this.onFocusOut, true);
1008
+ if (this.onPointerMove)
1009
+ document.removeEventListener("pointermove", this.onPointerMove, true);
1010
+ if (this.onPointerDown)
1011
+ document.removeEventListener("pointerdown", this.onPointerDown, true);
1012
+ if (this.onPointerUp)
1013
+ document.removeEventListener("pointerup", this.onPointerUp, true);
1014
+ try {
1015
+ for (const anim of document.getAnimations()) {
1016
+ anim.cancel();
1017
+ }
1018
+ } catch (_) {
1019
+ }
1020
+ window.requestAnimationFrame = () => 0;
1021
+ const noTransitions = document.createElement("style");
1022
+ noTransitions.id = "__lapse-no-transitions";
1023
+ noTransitions.textContent = "*, *::before, *::after { transition: none !important; animation: none !important; }";
1024
+ document.head.appendChild(noTransitions);
1025
+ this.noTransitionsEl = noTransitions;
1026
+ const blocker = document.createElement("div");
1027
+ blocker.id = "__lapse-scrub-blocker";
1028
+ blocker.style.cssText = "position:fixed;inset:0;z-index:999999;cursor:not-allowed;background:transparent;";
1029
+ blocker.title = "Clear the timeline to interact with the page";
1030
+ document.body.appendChild(blocker);
1031
+ this.blockerEl = blocker;
1032
+ try {
1033
+ const lapseStyle = document.createElement("style");
1034
+ lapseStyle.id = "__lapse-state-rules";
1035
+ let allCss = "";
1036
+ for (const style of document.querySelectorAll("style")) {
1037
+ if (style.id === "__lapse-state-rules") continue;
1038
+ allCss += style.textContent + "\n";
1039
+ }
1040
+ for (const sheet of document.styleSheets) {
1041
+ try {
1042
+ let walk2 = function(rules) {
1043
+ for (const rule of rules) {
1044
+ if (rule.cssRules) {
1045
+ walk2(rule.cssRules);
1046
+ continue;
1047
+ }
1048
+ const t = rule.cssText;
1049
+ if (t && (t.includes(":hover") || t.includes(":focus"))) {
1050
+ allCss += t + "\n";
1051
+ }
1052
+ }
1053
+ };
1054
+ var walk = walk2;
1055
+ walk2(sheet.cssRules);
1056
+ } catch (_) {
1057
+ }
1058
+ }
1059
+ const stateRegex = /([^{}]*(?::hover|:focus-visible|:focus-within|:focus(?!-))[^{}]*)\{([^{}]*)\}/g;
1060
+ let match;
1061
+ while ((match = stateRegex.exec(allCss)) !== null) {
1062
+ const selector = match[1].trim();
1063
+ const body = match[2].trim();
1064
+ if (!body) continue;
1065
+ const newBody = body.replace(
1066
+ /([^;:]+):\s*([^;]+)(;|$)/g,
1067
+ (m, prop, val, end) => {
1068
+ if (val.includes("!important")) return m;
1069
+ return prop + ": " + val.trim() + " !important" + end;
1070
+ }
1071
+ );
1072
+ if (selector.includes(":hover")) {
1073
+ lapseStyle.textContent += selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }\n";
1074
+ }
1075
+ if (selector.includes(":focus-visible")) {
1076
+ lapseStyle.textContent += selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }\n";
1077
+ } else if (selector.includes(":focus-within")) {
1078
+ lapseStyle.textContent += selector.replace(
1079
+ /:focus-within/g,
1080
+ ":has([data-lapse-focus])"
1081
+ ) + " { " + newBody + " }\n";
1082
+ } else if (selector.includes(":focus")) {
1083
+ lapseStyle.textContent += selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }\n";
1084
+ }
1085
+ }
1086
+ document.head.appendChild(lapseStyle);
1087
+ this.lapseStyleEl = lapseStyle;
1088
+ } catch (_) {
1089
+ }
1090
+ if (this.frames.length > 0) {
1091
+ const frame0 = this.frames[0];
1092
+ if (frame0.elementSnapshots) {
1093
+ for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
1094
+ const el = this.elements.get(sel);
1095
+ if (!el || !el.isConnected) continue;
1096
+ if (snap.__styles) {
1097
+ for (const [prop, value] of Object.entries(snap.__styles)) {
1098
+ if (SAFE_PROPS_SET.has(prop)) {
1099
+ el.style.setProperty(prop, value, "important");
1100
+ }
1101
+ }
1102
+ }
1103
+ if (snap.__attrs) {
1104
+ for (const [attr, value] of Object.entries(snap.__attrs)) {
1105
+ if (attr === "checked") {
1106
+ ;
1107
+ el.checked = value === "true";
1108
+ } else if (attr === "class" && value != null) {
1109
+ el.className = value;
1110
+ } else if (attr === "style") {
1111
+ } else if (attr === "value" && value != null) {
1112
+ ;
1113
+ el.value = value;
1114
+ } else if (value == null) {
1115
+ el.removeAttribute(attr);
1116
+ } else {
1117
+ el.setAttribute(attr, value);
1118
+ }
1119
+ }
1120
+ }
1121
+ }
1122
+ }
1123
+ }
1124
+ const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
1125
+ const capture = {
1126
+ startTime: this.startTime,
1127
+ endTime: performance.now(),
1128
+ duration,
1129
+ animations: Array.from(this.animations.values()),
1130
+ frames: this.frames,
1131
+ boundingBox: this.boundingBox
1132
+ };
1133
+ return capture;
1134
+ }
1135
+ };
1136
+ // ---- Recording limits ---------------------------------------------------
1137
+ _TimelineRecorder.MAX_DURATION_MS = 6e4;
1138
+ _TimelineRecorder.MAX_FRAMES = 3600;
1139
+ var TimelineRecorder = _TimelineRecorder;
1140
+
1141
+ // src/core/scrubber.ts
1142
+ var TimelineScrubber = class {
1143
+ constructor(state) {
1144
+ /** Saved originals for restore on release */
1145
+ this._originalAnimate = null;
1146
+ this._originalRaf = null;
1147
+ this._originalRemoveChild = null;
1148
+ this._originalRemove = null;
1149
+ this.elements = state.elements;
1150
+ this.frames = state.frames;
1151
+ this.capturedPortals = state.capturedPortals;
1152
+ this.interceptedAnimations = state.interceptedAnimations;
1153
+ this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1154
+ this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1155
+ this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1156
+ this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1157
+ this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1158
+ }
1159
+ // ---------------------------------------------------------------------------
1160
+ // Selector helper — mirrors the recorder's getSelector so we can look up
1161
+ // elements by the same key the recorder used in frame.animations[].animationId
1162
+ // ---------------------------------------------------------------------------
1163
+ getSelector(el) {
1164
+ if (!el || !el.tagName) return null;
1165
+ const parts = [];
1166
+ let current = el;
1167
+ for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
1168
+ const tag = current.tagName.toLowerCase();
1169
+ const parent = current.parentElement;
1170
+ if (parent) {
1171
+ const siblings = Array.from(parent.children);
1172
+ const idx = siblings.indexOf(current) + 1;
1173
+ parts.unshift(`${tag}:nth-child(${idx})`);
1174
+ } else {
1175
+ parts.unshift(tag);
1176
+ }
1177
+ current = parent;
1178
+ }
1179
+ return parts.join(" > ");
1180
+ }
1181
+ // ---------------------------------------------------------------------------
1182
+ // seekTo — scrub the DOM to match a specific timestamp
1183
+ // ---------------------------------------------------------------------------
1184
+ seekTo(timeMs) {
1185
+ if (!this.frames.length) return;
1186
+ let lo = 0;
1187
+ let hi = this.frames.length - 1;
1188
+ while (lo < hi) {
1189
+ const mid = lo + hi >> 1;
1190
+ if (this.frames[mid].time < timeMs) lo = mid + 1;
1191
+ else hi = mid;
1192
+ }
1193
+ const frame = this.frames[lo];
1194
+ if (!frame || !frame.elementSnapshots) return;
1195
+ document.querySelectorAll("[data-lapse-hover]").forEach((el) => {
1196
+ el.removeAttribute("data-lapse-hover");
1197
+ });
1198
+ document.querySelectorAll("[data-lapse-focus]").forEach((el) => {
1199
+ el.removeAttribute("data-lapse-focus");
1200
+ });
1201
+ const activeIds = new Set(frame.activePortalIds || []);
1202
+ for (const id of this.capturedPortals) {
1203
+ const portalEl = document.querySelector(
1204
+ `[data-lapse-portal-id="${id}"]`
1205
+ );
1206
+ if (!portalEl) continue;
1207
+ if (activeIds.has(id)) {
1208
+ portalEl.style.removeProperty("display");
1209
+ portalEl.removeAttribute("data-lapse-portal-hidden");
1210
+ } else {
1211
+ portalEl.style.setProperty("display", "none", "important");
1212
+ }
1213
+ }
1214
+ const hoveredSels = frame.hoveredSels || [];
1215
+ for (const sel of hoveredSels) {
1216
+ const el = this.elements.get(sel);
1217
+ if (el && el.isConnected) el.setAttribute("data-lapse-hover", "");
1218
+ }
1219
+ if (frame.focusSel) {
1220
+ const focusEl = this.elements.get(frame.focusSel);
1221
+ if (focusEl && focusEl.isConnected) {
1222
+ focusEl.setAttribute("data-lapse-focus", "");
1223
+ let parent = focusEl.parentElement;
1224
+ while (parent && parent !== document.body) {
1225
+ parent.setAttribute("data-lapse-focus", "");
1226
+ parent = parent.parentElement;
1227
+ }
1228
+ }
1229
+ }
1230
+ for (const entry of this.interceptedAnimations) {
1231
+ try {
1232
+ const anim = entry.animation;
1233
+ if (anim.playState !== "finished") {
1234
+ anim.pause();
1235
+ }
1236
+ anim.currentTime = timeMs;
1237
+ } catch {
1238
+ }
1239
+ }
1240
+ for (const [sel, snap] of Object.entries(frame.elementSnapshots)) {
1241
+ const el = this.elements.get(sel);
1242
+ if (!el || !el.isConnected) continue;
1243
+ if (el.closest?.("[data-lapse-panel]")) continue;
1244
+ const hasAnimation = (frame.animations || []).some(
1245
+ (a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
1246
+ );
1247
+ const snapTyped = snap;
1248
+ if (snapTyped.__styles) {
1249
+ for (const [prop, value] of Object.entries(snapTyped.__styles)) {
1250
+ if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
1251
+ el.style.setProperty(prop, value, "important");
1252
+ }
1253
+ }
1254
+ }
1255
+ if (snapTyped.__attrs) {
1256
+ for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1257
+ if (attr === "checked") {
1258
+ ;
1259
+ el.checked = value === "true";
1260
+ } else if (attr === "class") {
1261
+ if (value != null) el.className = value;
1262
+ } else if (attr === "style") {
1263
+ if (value) {
1264
+ el.setAttribute("style", value);
1265
+ el.style.transition = "none";
1266
+ if (snapTyped.__styles) {
1267
+ for (const [prop, val] of Object.entries(snapTyped.__styles)) {
1268
+ el.style.setProperty(prop, val, "important");
1269
+ }
1270
+ }
1271
+ }
1272
+ } else if (attr === "value") {
1273
+ if (value != null) el.value = value;
1274
+ } else if (value == null) {
1275
+ el.removeAttribute(attr);
1276
+ } else {
1277
+ el.setAttribute(attr, value);
1278
+ }
1279
+ }
1280
+ }
1281
+ }
1282
+ for (const anim of frame.animations || []) {
1283
+ const firstColon = anim.animationId.indexOf(":");
1284
+ const secondColon = anim.animationId.indexOf(":", firstColon + 1);
1285
+ const animSel = secondColon >= 0 ? anim.animationId.substring(secondColon + 1) : "";
1286
+ const animEl = this.elements.get(animSel);
1287
+ if (!animEl || !animEl.isConnected) continue;
1288
+ for (const prop of anim.properties || []) {
1289
+ if (prop.value) {
1290
+ animEl.style.setProperty(prop.property, prop.value, "important");
1291
+ }
1292
+ }
1293
+ }
1294
+ const animatedSels = /* @__PURE__ */ new Set();
1295
+ for (const anim of frame.animations || []) {
1296
+ const fc = anim.animationId.indexOf(":");
1297
+ const sc = anim.animationId.indexOf(":", fc + 1);
1298
+ if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
1299
+ }
1300
+ document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
1301
+ const el = rawEl;
1302
+ const sel = this.getSelector(el);
1303
+ if (sel && !animatedSels.has(sel)) {
1304
+ el.style.removeProperty("opacity");
1305
+ el.style.removeProperty("transform");
1306
+ el.style.removeProperty("filter");
1307
+ el.style.removeProperty("stroke-dashoffset");
1308
+ }
1309
+ });
1310
+ }
1311
+ // ---------------------------------------------------------------------------
1312
+ // release — tear down all scrub state and restore the page to normal
1313
+ // ---------------------------------------------------------------------------
1314
+ release() {
1315
+ for (const entry of this.interceptedAnimations) {
1316
+ try {
1317
+ entry.animation.cancel();
1318
+ } catch {
1319
+ }
1320
+ }
1321
+ this.interceptedAnimations.length = 0;
1322
+ if (this._originalAnimate) {
1323
+ Element.prototype.animate = this._originalAnimate;
1324
+ this._originalAnimate = null;
1325
+ delete window.__LAPSE_ORIGINAL_ANIMATE__;
1326
+ }
1327
+ if (this._originalRaf) {
1328
+ window.requestAnimationFrame = this._originalRaf;
1329
+ this._originalRaf = null;
1330
+ delete window.__LAPSE_ORIGINAL_RAF__;
1331
+ }
1332
+ const tl = window.__LAPSE_TIMELINE__;
1333
+ if (tl?.noTransitionsEl) {
1334
+ tl.noTransitionsEl.remove();
1335
+ }
1336
+ const noTrans = document.getElementById("__lapse-no-transitions");
1337
+ if (noTrans) noTrans.remove();
1338
+ const blocker = document.getElementById("__lapse-scrub-blocker");
1339
+ if (blocker) blocker.remove();
1340
+ if (tl?.blockerEl) tl.blockerEl.remove();
1341
+ const stateRules = document.getElementById("__lapse-state-rules");
1342
+ if (stateRules) stateRules.remove();
1343
+ if (tl?.lapseStyleEl) tl.lapseStyleEl.remove();
1344
+ if (this._originalRemoveChild) {
1345
+ Node.prototype.removeChild = this._originalRemoveChild;
1346
+ this._originalRemoveChild = null;
1347
+ } else if (tl?._removeChild) {
1348
+ Node.prototype.removeChild = tl._removeChild;
1349
+ }
1350
+ if (this._originalRemove) {
1351
+ Element.prototype.remove = this._originalRemove;
1352
+ this._originalRemove = null;
1353
+ } else if (tl?._remove) {
1354
+ Element.prototype.remove = tl._remove;
1355
+ }
1356
+ document.querySelectorAll("[data-lapse-exit-captured]").forEach((el) => {
1357
+ el.remove();
1358
+ });
1359
+ document.querySelectorAll("[data-lapse-id]").forEach((el) => {
1360
+ el.removeAttribute("data-lapse-id");
1361
+ });
1362
+ document.querySelectorAll("[data-lapse-hover]").forEach((el) => {
1363
+ el.removeAttribute("data-lapse-hover");
1364
+ });
1365
+ document.querySelectorAll("[data-lapse-focus]").forEach((el) => {
1366
+ el.removeAttribute("data-lapse-focus");
1367
+ });
1368
+ document.querySelectorAll("[data-lapse-portal-id]").forEach((el) => {
1369
+ el.removeAttribute("data-lapse-portal-id");
1370
+ el.removeAttribute("data-lapse-portal-hidden");
1371
+ });
1372
+ delete window.__LAPSE_TIMELINE__;
1373
+ this.elements.clear();
1374
+ this.frames.length = 0;
1375
+ this.capturedPortals.clear();
1376
+ }
1377
+ };
1378
+
1379
+ // src/core/export.ts
1380
+ function getFrameAtTime(frames, timeMs) {
1381
+ if (frames.length === 0) return null;
1382
+ let lo = 0;
1383
+ let hi = frames.length - 1;
1384
+ while (lo < hi) {
1385
+ const mid = lo + hi >> 1;
1386
+ if (frames[mid].time < timeMs) lo = mid + 1;
1387
+ else hi = mid;
1388
+ }
1389
+ return frames[lo];
1390
+ }
1391
+ function generateExport(animations, frames, timeMs, filter = "active") {
1392
+ const frame = getFrameAtTime(frames, timeMs);
1393
+ const duration = frames.at(-1)?.time ?? 0;
1394
+ const filteredAnims = animations.filter((anim) => {
1395
+ if (filter === "all-animations") return true;
1396
+ if (filter === "active") {
1397
+ const frameAnim = frame?.animations.find((a) => a.animationId === anim.id);
1398
+ if (!frameAnim || frameAnim.progress <= 0 || frameAnim.progress >= 1) return false;
1399
+ if (frameAnim.properties.length > 0) {
1400
+ const hasRealChange = frameAnim.properties.some(
1401
+ (p) => p.from && p.to && p.from !== p.to
1402
+ );
1403
+ if (!hasRealChange) return false;
1404
+ }
1405
+ return true;
1406
+ }
1407
+ return true;
1408
+ });
1409
+ const animExports = filteredAnims.map((anim) => {
1410
+ let frameAnim = frame?.animations.find((a) => a.animationId === anim.id);
1411
+ let status = frameAnim ? "active" : "completed";
1412
+ if (!frameAnim) {
1413
+ for (let i = frames.length - 1; i >= 0; i--) {
1414
+ if (frames[i].time > timeMs) continue;
1415
+ const found = frames[i].animations.find((a) => a.animationId === anim.id);
1416
+ if (found) {
1417
+ frameAnim = found;
1418
+ break;
1419
+ }
1420
+ }
1421
+ if (!frameAnim) {
1422
+ for (let i = 0; i < frames.length; i++) {
1423
+ if (frames[i].time < timeMs) continue;
1424
+ const found = frames[i].animations.find((a) => a.animationId === anim.id);
1425
+ if (found) {
1426
+ frameAnim = found;
1427
+ status = "upcoming";
1428
+ break;
1429
+ }
1430
+ }
1431
+ }
1432
+ }
1433
+ const progressLabel = status === "active" ? `${Math.round((frameAnim?.progress ?? 0) * 100)}%` : status === "completed" ? "done" : "upcoming";
1434
+ return {
1435
+ element: anim.selector,
1436
+ elementLabel: anim.elementLabel || anim.selector,
1437
+ name: anim.name || anim.type,
1438
+ type: anim.type,
1439
+ timing: `${Math.round(anim.duration)}ms ${anim.easing}${anim.delay ? ` (delay: ${Math.round(anim.delay)}ms)` : ""}`,
1440
+ progress: progressLabel,
1441
+ properties: (frameAnim?.properties ?? []).map((p) => ({
1442
+ property: p.property,
1443
+ value: p.value,
1444
+ range: p.from && p.to ? `${p.from} \u2192 ${p.to}` : ""
1445
+ })),
1446
+ source: anim.source,
1447
+ resolvedVars: anim.resolvedVars,
1448
+ conflicts: anim.conflicts
1449
+ };
1450
+ });
1451
+ const hoveredElements = Array.isArray(frame?.hoveredSels) ? frame.hoveredSels.map(String) : [];
1452
+ const focusedElement = frame?.focusSel ? String(frame.focusSel) : null;
1453
+ return {
1454
+ timestamp: `${Math.round(timeMs)}ms into ${Math.round(duration)}ms recording`,
1455
+ duration: `${Math.round(duration)}ms`,
1456
+ scrubPosition: timeMs,
1457
+ hoveredElements,
1458
+ focusedElement,
1459
+ animations: animExports
1460
+ };
1461
+ }
1462
+ function formatExportForLLM(exp, detail = "standard") {
1463
+ const lines = [];
1464
+ const grouped = /* @__PURE__ */ new Map();
1465
+ for (const anim of exp.animations) {
1466
+ const key = (anim.elementLabel || "") + "|||" + anim.element;
1467
+ if (!grouped.has(key)) {
1468
+ grouped.set(key, { label: anim.elementLabel || "", selector: anim.element, anims: [] });
1469
+ }
1470
+ grouped.get(key).anims.push(anim);
1471
+ }
1472
+ function isRealChange(prop) {
1473
+ if (!prop.range) return true;
1474
+ const [from, to] = prop.range.split(" \u2192 ");
1475
+ return !(from && to && from.trim() === to.trim());
1476
+ }
1477
+ if (detail === "compact") {
1478
+ lines.push(`# Animation State at ${exp.timestamp}`);
1479
+ lines.push("");
1480
+ for (const [, group] of grouped) {
1481
+ const label = group.label && group.label !== group.selector ? `**${group.label}** \`${group.selector}\`` : `\`${group.selector}\``;
1482
+ const cssAnims = group.anims.filter((a) => a.type !== "JSAnimation");
1483
+ const jsAnims = group.anims.filter((a) => a.type === "JSAnimation");
1484
+ if (cssAnims.length > 0) {
1485
+ const props = /* @__PURE__ */ new Set();
1486
+ for (const a of cssAnims) a.properties.filter(isRealChange).forEach((p) => props.add(p.property));
1487
+ const timing = cssAnims[0]?.timing || "";
1488
+ const progress = cssAnims[0]?.progress || "";
1489
+ const progressStr = progress && progress !== "unknown" ? ` @ ${progress}` : "";
1490
+ lines.push(`- ${label}: ${[...props].join(", ")} (${timing}${progressStr})`);
1491
+ }
1492
+ if (jsAnims.length > 0) {
1493
+ const props = /* @__PURE__ */ new Set();
1494
+ for (const a of jsAnims) a.properties.filter(isRealChange).forEach((p) => props.add(p.property));
1495
+ if (props.size > 0) lines.push(`- ${label}: ${[...props].join(", ")} (JS)`);
1496
+ }
1497
+ }
1498
+ return lines.join("\n");
1499
+ }
1500
+ lines.push(`# Animation State at ${exp.timestamp}`);
1501
+ lines.push("");
1502
+ if (detail === "forensic") {
1503
+ lines.push("**Environment:**");
1504
+ lines.push(`- Viewport: ${window.innerWidth}\xD7${window.innerHeight}`);
1505
+ lines.push(`- URL: ${window.location.href}`);
1506
+ lines.push(`- User Agent: ${navigator.userAgent}`);
1507
+ lines.push(`- Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}`);
1508
+ lines.push(`- Device Pixel Ratio: ${window.devicePixelRatio}`);
1509
+ lines.push("");
1510
+ }
1511
+ if (exp.hoveredElements.length > 0 || exp.focusedElement) {
1512
+ lines.push("**Interaction state:**");
1513
+ if (exp.hoveredElements.length > 0) {
1514
+ const deepest = exp.hoveredElements[exp.hoveredElements.length - 1] || exp.hoveredElements[0];
1515
+ lines.push(`- Hovered: \`${deepest}\``);
1516
+ }
1517
+ if (exp.focusedElement) {
1518
+ lines.push(`- Focused: \`${exp.focusedElement}\``);
1519
+ }
1520
+ lines.push("");
1521
+ }
1522
+ if (exp.animations.length === 0 && exp.hoveredElements.length === 0 && !exp.focusedElement) {
1523
+ lines.push("No active animations or interactions at this position.");
1524
+ return lines.join("\n");
1525
+ }
1526
+ if (exp.animations.length === 0) {
1527
+ return lines.join("\n");
1528
+ }
1529
+ for (const [, group] of grouped) {
1530
+ const label = group.label && group.label !== group.selector ? `"${group.label}" ` : "";
1531
+ const cssAnims = group.anims.filter((a) => a.type !== "JSAnimation");
1532
+ const jsAnims = group.anims.filter((a) => a.type === "JSAnimation");
1533
+ const cssPropLines = [];
1534
+ if (cssAnims.length > 0) {
1535
+ const seenProps = /* @__PURE__ */ new Set();
1536
+ for (const anim of cssAnims) {
1537
+ const progressStr = anim.progress !== "unknown" ? ` @ ${anim.progress}` : "";
1538
+ for (const prop of anim.properties) {
1539
+ if (seenProps.has(prop.property)) continue;
1540
+ if (!isRealChange(prop)) continue;
1541
+ seenProps.add(prop.property);
1542
+ let line = `- \`${prop.property}\`: ${prop.value}`;
1543
+ if (prop.range) line += ` [${prop.range}]`;
1544
+ line += ` (${anim.timing}${progressStr})`;
1545
+ cssPropLines.push(line);
1546
+ }
1547
+ }
1548
+ }
1549
+ const jsPropLines = [];
1550
+ if (jsAnims.length > 0) {
1551
+ const seenProps = /* @__PURE__ */ new Set();
1552
+ for (const anim of jsAnims) {
1553
+ for (const prop of anim.properties) {
1554
+ if (seenProps.has(prop.property)) continue;
1555
+ if (!isRealChange(prop)) continue;
1556
+ seenProps.add(prop.property);
1557
+ jsPropLines.push(`- \`${prop.property}\`: ${prop.value}`);
1558
+ }
1559
+ }
1560
+ }
1561
+ if (cssPropLines.length === 0 && jsPropLines.length === 0) continue;
1562
+ lines.push(`## ${label}\`${group.selector}\``);
1563
+ lines.push("");
1564
+ if (cssPropLines.length > 0) {
1565
+ const transitionSet = new Set(cssAnims.map((a) => `${a.name} ${a.timing}`));
1566
+ lines.push(`Transitions: ${[...transitionSet].join(", ")}`);
1567
+ lines.push("");
1568
+ for (const line of cssPropLines) lines.push(line);
1569
+ if (detail === "detailed" || detail === "forensic") {
1570
+ const allVars = {};
1571
+ for (const anim of cssAnims) {
1572
+ if (anim.resolvedVars) Object.assign(allVars, anim.resolvedVars);
1573
+ }
1574
+ if (Object.keys(allVars).length > 0) {
1575
+ lines.push("");
1576
+ lines.push("CSS variables:");
1577
+ for (const [name, value] of Object.entries(allVars)) {
1578
+ lines.push(`- \`${name}\`: ${value}`);
1579
+ }
1580
+ }
1581
+ for (const anim of cssAnims) {
1582
+ if (anim.source) {
1583
+ lines.push("");
1584
+ lines.push("```css");
1585
+ lines.push(anim.source);
1586
+ lines.push("```");
1587
+ break;
1588
+ }
1589
+ }
1590
+ }
1591
+ }
1592
+ if (jsPropLines.length > 0) {
1593
+ if (cssPropLines.length > 0) lines.push("");
1594
+ for (const line of jsPropLines) lines.push(line);
1595
+ }
1596
+ lines.push("");
1597
+ }
1598
+ return lines.join("\n");
1599
+ }
1600
+
1601
+ // src/core/engine.ts
1602
+ var LapseEngine = class {
1603
+ constructor() {
1604
+ this.timing = new TimingController();
1605
+ this.recorder = new TimelineRecorder();
1606
+ this.scrubber = null;
1607
+ this.capture = null;
1608
+ this._state = "idle";
1609
+ this.listeners = /* @__PURE__ */ new Set();
1610
+ }
1611
+ get state() {
1612
+ return this._state;
1613
+ }
1614
+ getCapture() {
1615
+ return this.capture;
1616
+ }
1617
+ // -- Speed control --------------------------------------------------------
1618
+ setSpeed(speed) {
1619
+ this.timing.setSpeed(speed);
1620
+ }
1621
+ getSpeed() {
1622
+ return this.timing.getSpeed();
1623
+ }
1624
+ // -- Timeline recording ---------------------------------------------------
1625
+ startRecording(boundingBox) {
1626
+ if (this._state !== "idle") return;
1627
+ this.timing.install();
1628
+ this.recorder.onAutoStop = () => this.stopRecording();
1629
+ this.recorder.startRecording(boundingBox);
1630
+ this._state = "recording";
1631
+ this.notify();
1632
+ }
1633
+ stopRecording() {
1634
+ if (this._state !== "recording") {
1635
+ return {
1636
+ startTime: 0,
1637
+ endTime: 0,
1638
+ duration: 0,
1639
+ animations: [],
1640
+ frames: [],
1641
+ boundingBox: null
1642
+ };
1643
+ }
1644
+ const capture = this.recorder.stopRecording();
1645
+ this.capture = capture;
1646
+ const scrubberState = {
1647
+ elements: this.recorder.elements,
1648
+ frames: capture.frames,
1649
+ capturedPortals: this.recorder.capturedPortalIds,
1650
+ interceptedAnimations: this.recorder.interceptedAnimations,
1651
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1652
+ };
1653
+ this.scrubber = new TimelineScrubber(scrubberState);
1654
+ this._state = "scrubbing";
1655
+ this.notify();
1656
+ return capture;
1657
+ }
1658
+ // -- Scrubbing ------------------------------------------------------------
1659
+ seekTo(timeMs) {
1660
+ this.scrubber?.seekTo(timeMs);
1661
+ }
1662
+ release() {
1663
+ this.scrubber?.release();
1664
+ this.scrubber = null;
1665
+ this.capture = null;
1666
+ this._state = "idle";
1667
+ this.notify();
1668
+ }
1669
+ // -- Export ---------------------------------------------------------------
1670
+ generateExport(timeMs, filter = "active") {
1671
+ if (!this.capture) return null;
1672
+ return generateExport(
1673
+ this.capture.animations,
1674
+ this.capture.frames,
1675
+ timeMs,
1676
+ filter
1677
+ );
1678
+ }
1679
+ exportForLLM(timeMs, filter = "active", detail = "standard") {
1680
+ const exp = this.generateExport(timeMs, filter);
1681
+ if (!exp) return "";
1682
+ return formatExportForLLM(exp, detail);
1683
+ }
1684
+ // -- State subscription ---------------------------------------------------
1685
+ subscribe(listener) {
1686
+ this.listeners.add(listener);
1687
+ return () => this.listeners.delete(listener);
1688
+ }
1689
+ notify() {
1690
+ for (const listener of this.listeners) {
1691
+ listener();
1692
+ }
1693
+ }
1694
+ // -- Cleanup --------------------------------------------------------------
1695
+ destroy() {
1696
+ this.scrubber?.release();
1697
+ this.scrubber = null;
1698
+ this.capture = null;
1699
+ this.timing.destroy();
1700
+ this._state = "idle";
1701
+ }
1702
+ };
1703
+
1704
+ // src/react/LapseContext.tsx
1705
+ import { jsx } from "react/jsx-runtime";
1706
+ var LapseContext = createContext(null);
1707
+ function LapseProvider({ children }) {
1708
+ const engineRef = useRef(null);
1709
+ if (!engineRef.current) {
1710
+ engineRef.current = new LapseEngine();
1711
+ }
1712
+ return /* @__PURE__ */ jsx(LapseContext.Provider, { value: engineRef.current, children });
1713
+ }
1714
+ function useLapseEngine() {
1715
+ const engine = useContext(LapseContext);
1716
+ if (!engine) throw new Error("useLapseEngine must be used within <LapseProvider>");
1717
+ return engine;
1718
+ }
1719
+
1720
+ // src/react/LapsePanel.tsx
1721
+ import { useRef as useRef5, useCallback as useCallback5 } from "react";
1722
+
1723
+ // src/react/Timeline.tsx
1724
+ import { useCallback, useRef as useRef2, useState, useEffect } from "react";
1725
+ import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
1726
+ var DETAIL_LABELS = {
1727
+ compact: "Compact",
1728
+ standard: "Standard",
1729
+ detailed: "Detailed",
1730
+ forensic: "Forensic"
1731
+ };
1732
+ function CopyCheckIcon({ copied }) {
1733
+ const spring = "cubic-bezier(0.34, 1.15, 0.64, 1)";
1734
+ return /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", children: [
1735
+ /* @__PURE__ */ jsx2(
1736
+ "path",
1737
+ {
1738
+ stroke: "currentColor",
1739
+ strokeWidth: "2",
1740
+ d: "M7 4v-.5A1.5 1.5 0 0 1 8.5 2h4A1.5 1.5 0 0 1 14 3.5v4A1.5 1.5 0 0 1 12.5 9H12",
1741
+ style: {
1742
+ opacity: copied ? 0 : 1,
1743
+ transition: copied ? "opacity 0.1s ease-out" : "opacity 0.15s ease-out 0.18s"
1744
+ }
1745
+ }
1746
+ ),
1747
+ /* @__PURE__ */ jsx2(
1748
+ "rect",
1749
+ {
1750
+ stroke: copied ? "#33C664" : "currentColor",
1751
+ fill: "none",
1752
+ style: {
1753
+ x: copied ? 1.5 : 2,
1754
+ y: copied ? 1.5 : 7,
1755
+ width: copied ? 13 : 7,
1756
+ height: copied ? 13 : 7,
1757
+ rx: copied ? 6.5 : 1.5,
1758
+ ry: copied ? 6.5 : 1.5,
1759
+ strokeWidth: 2,
1760
+ transition: copied ? `x 0.28s ${spring}, y 0.28s ${spring}, width 0.28s ${spring}, height 0.28s ${spring}, rx 0.28s ${spring}, ry 0.28s ${spring}` : "x 0.22s ease-in-out, y 0.22s ease-in-out, width 0.22s ease-in-out, height 0.22s ease-in-out, rx 0.22s ease-in-out, ry 0.22s ease-in-out"
1761
+ }
1762
+ }
1763
+ ),
1764
+ /* @__PURE__ */ jsx2(
1765
+ "path",
1766
+ {
1767
+ d: "m5.5 8.5 2 1.5 2.75-3.75",
1768
+ stroke: copied ? "#33C664" : "currentColor",
1769
+ strokeWidth: "2",
1770
+ strokeLinecap: "round",
1771
+ strokeLinejoin: "round",
1772
+ fill: "none",
1773
+ style: {
1774
+ strokeDasharray: 12,
1775
+ strokeDashoffset: copied ? 0 : 12,
1776
+ opacity: copied ? 1 : 0,
1777
+ transition: copied ? "stroke-dashoffset 0.25s ease-out 0.1s, opacity 0.08s ease-out 0.08s" : "stroke-dashoffset 0.12s ease-in, opacity 0.08s ease-in"
1778
+ }
1779
+ }
1780
+ )
1781
+ ] });
1782
+ }
1783
+ var DETAIL_BRIGHT_COUNT = {
1784
+ compact: 1,
1785
+ standard: 2,
1786
+ detailed: 3,
1787
+ forensic: 4
1788
+ };
1789
+ function DetailIcon({ level }) {
1790
+ const bright = DETAIL_BRIGHT_COUNT[level];
1791
+ const ys = [2, 6, 10, 14];
1792
+ return /* @__PURE__ */ jsx2("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", children: ys.map((y, i) => /* @__PURE__ */ jsx2(
1793
+ "path",
1794
+ {
1795
+ stroke: "currentColor",
1796
+ strokeLinecap: "round",
1797
+ strokeWidth: "2",
1798
+ d: "M2 0h12",
1799
+ transform: `translate(0, ${y})`,
1800
+ style: {
1801
+ opacity: i < bright ? 1 : 0.4,
1802
+ transition: "opacity 0.2s ease-out"
1803
+ }
1804
+ },
1805
+ y
1806
+ )) });
1807
+ }
1808
+ function Timeline({
1809
+ state,
1810
+ capture,
1811
+ scrubTime,
1812
+ copied,
1813
+ onStartRecording,
1814
+ onStopRecording,
1815
+ onSeek,
1816
+ onRelease,
1817
+ onExportLLM,
1818
+ detailLevel,
1819
+ onCycleDetailLevel
1820
+ }) {
1821
+ const scrubberRef = useRef2(null);
1822
+ const isDragging = useRef2(false);
1823
+ const [hasLeftRecord, setHasLeftRecord] = useState(false);
1824
+ useEffect(() => {
1825
+ if (state === "recording") setHasLeftRecord(false);
1826
+ }, [state]);
1827
+ const handleScrub = useCallback(
1828
+ (e) => {
1829
+ if (!capture || !scrubberRef.current) return;
1830
+ const rect = scrubberRef.current.getBoundingClientRect();
1831
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
1832
+ onSeek(x * capture.duration);
1833
+ },
1834
+ [capture, onSeek]
1835
+ );
1836
+ const handleMouseDown = useCallback(
1837
+ (e) => {
1838
+ isDragging.current = true;
1839
+ handleScrub(e);
1840
+ const handleMouseMove = (e2) => {
1841
+ if (!isDragging.current || !capture || !scrubberRef.current) return;
1842
+ const rect = scrubberRef.current.getBoundingClientRect();
1843
+ const x = Math.max(0, Math.min(1, (e2.clientX - rect.left) / rect.width));
1844
+ onSeek(x * capture.duration);
1845
+ };
1846
+ const handleMouseUp = () => {
1847
+ isDragging.current = false;
1848
+ window.removeEventListener("mousemove", handleMouseMove);
1849
+ window.removeEventListener("mouseup", handleMouseUp);
1850
+ };
1851
+ window.addEventListener("mousemove", handleMouseMove);
1852
+ window.addEventListener("mouseup", handleMouseUp);
1853
+ },
1854
+ [capture, handleScrub, onSeek]
1855
+ );
1856
+ const progress = capture && capture.duration > 0 ? scrubTime / capture.duration : 0;
1857
+ const isScrubbing = state === "scrubbing";
1858
+ const isRecording = state === "recording";
1859
+ function handleMainClick() {
1860
+ if (state === "idle") onStartRecording();
1861
+ else if (state === "recording") onStopRecording();
1862
+ else if (state === "scrubbing") onRelease();
1863
+ }
1864
+ const tooltip = state === "idle" ? "Record" : state === "recording" ? "Stop" : "Clear";
1865
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1866
+ /* @__PURE__ */ jsxs(
1867
+ "button",
1868
+ {
1869
+ onClick: handleMainClick,
1870
+ className: `lapse-btn lapse-btn-icon lapse-main-btn ${isRecording ? "lapse-record-btn--active" : ""}`,
1871
+ "data-tooltip": tooltip,
1872
+ onMouseLeave: isRecording ? () => setHasLeftRecord(true) : void 0,
1873
+ children: [
1874
+ /* @__PURE__ */ jsx2(
1875
+ "svg",
1876
+ {
1877
+ width: "16",
1878
+ height: "16",
1879
+ viewBox: "0 0 16 16",
1880
+ fill: "none",
1881
+ className: `lapse-morph-icon ${state === "idle" ? "lapse-morph-icon--active" : ""}`,
1882
+ children: /* @__PURE__ */ jsx2("path", { fill: "currentColor", d: "M8 .5a7.5 7.5 0 1 1 0 15 7.5 7.5 0 0 1 0-15Zm0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Z" })
1883
+ }
1884
+ ),
1885
+ /* @__PURE__ */ jsx2(
1886
+ "svg",
1887
+ {
1888
+ width: "16",
1889
+ height: "16",
1890
+ viewBox: "0 0 16 16",
1891
+ fill: "none",
1892
+ className: `lapse-morph-icon ${isRecording ? "lapse-morph-icon--active" : ""}`,
1893
+ children: /* @__PURE__ */ jsx2(
1894
+ "rect",
1895
+ {
1896
+ x: hasLeftRecord ? "1.5" : "0.5",
1897
+ y: hasLeftRecord ? "1.5" : "0.5",
1898
+ width: hasLeftRecord ? "13" : "15",
1899
+ height: hasLeftRecord ? "13" : "15",
1900
+ rx: hasLeftRecord ? "3" : "7.5",
1901
+ fill: "currentColor",
1902
+ className: "lapse-record-shape"
1903
+ }
1904
+ )
1905
+ }
1906
+ ),
1907
+ /* @__PURE__ */ jsx2(
1908
+ "svg",
1909
+ {
1910
+ width: "16",
1911
+ height: "16",
1912
+ viewBox: "0 0 16 16",
1913
+ fill: "none",
1914
+ className: `lapse-morph-icon ${isScrubbing ? "lapse-morph-icon--active" : ""}`,
1915
+ children: /* @__PURE__ */ jsx2("path", { stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "m5.5 3-3 3m0 0h7a3.5 3.5 0 1 1 0 7h-2m-5-7 3 3" })
1916
+ }
1917
+ )
1918
+ ]
1919
+ }
1920
+ ),
1921
+ /* @__PURE__ */ jsx2("div", { className: `lapse-scrub-wrap ${isScrubbing ? "lapse-scrub-wrap--visible" : ""}`, children: /* @__PURE__ */ jsxs("div", { className: "lapse-scrub-inner", children: [
1922
+ /* @__PURE__ */ jsxs(
1923
+ "div",
1924
+ {
1925
+ ref: scrubberRef,
1926
+ onMouseDown: handleMouseDown,
1927
+ className: "lapse-scrub-track",
1928
+ children: [
1929
+ capture && capture.duration > 0 && (() => {
1930
+ const seen = /* @__PURE__ */ new Set();
1931
+ const times = [];
1932
+ for (const frame of capture.frames) {
1933
+ if (!frame.animations) continue;
1934
+ for (const fa of frame.animations) {
1935
+ if (!seen.has(fa.animationId)) {
1936
+ seen.add(fa.animationId);
1937
+ times.push(frame.time);
1938
+ }
1939
+ }
1940
+ }
1941
+ times.sort((a, b) => a - b);
1942
+ const clusters = [];
1943
+ for (const t of times) {
1944
+ const last = clusters[clusters.length - 1];
1945
+ if (last && t - last.time < 200) {
1946
+ last.count++;
1947
+ } else {
1948
+ clusters.push({ time: t, count: 1 });
1949
+ }
1950
+ }
1951
+ return clusters.map((c, i) => /* @__PURE__ */ jsx2(
1952
+ "div",
1953
+ {
1954
+ className: "lapse-scrub-marker",
1955
+ style: { left: `${c.time / capture.duration * 100}%` }
1956
+ },
1957
+ i
1958
+ ));
1959
+ })(),
1960
+ /* @__PURE__ */ jsx2("div", { className: "lapse-scrub-fill", style: { width: `calc(${progress * 100}% + 4px)` } }),
1961
+ /* @__PURE__ */ jsx2("div", { className: "lapse-scrub-playhead", style: { left: `clamp(4px, ${progress * 100}%, calc(100% - 4px))` } }),
1962
+ /* @__PURE__ */ jsx2("div", { className: "lapse-scrub-label", children: /* @__PURE__ */ jsxs("span", { children: [
1963
+ Math.round(scrubTime),
1964
+ "ms / ",
1965
+ Math.round(capture?.duration ?? 0),
1966
+ "ms"
1967
+ ] }) })
1968
+ ]
1969
+ }
1970
+ ),
1971
+ /* @__PURE__ */ jsx2(
1972
+ "button",
1973
+ {
1974
+ onClick: onCycleDetailLevel,
1975
+ className: "lapse-btn lapse-btn-icon",
1976
+ "data-tooltip": DETAIL_LABELS[detailLevel],
1977
+ children: /* @__PURE__ */ jsx2(DetailIcon, { level: detailLevel })
1978
+ }
1979
+ ),
1980
+ /* @__PURE__ */ jsx2("button", { onClick: onExportLLM, className: "lapse-btn lapse-btn-icon", "data-tooltip": copied ? "Copied!" : "Copy", children: /* @__PURE__ */ jsx2(CopyCheckIcon, { copied }) })
1981
+ ] }) })
1982
+ ] });
1983
+ }
1984
+
1985
+ // src/react/SpeedControl.tsx
1986
+ import { useRef as useRef3, useCallback as useCallback2 } from "react";
1987
+
1988
+ // src/react/constants.ts
1989
+ var SPEED_PRESETS = [0.1, 0.25, 0.5, 0.75, 1];
1990
+ var SPEED_MIN = 0.05;
1991
+ var SPEED_MAX = 5;
1992
+ function formatSpeed(speed) {
1993
+ if (speed >= 0.1) return `${speed}\xD7`;
1994
+ return `${speed.toFixed(2)}\xD7`;
1995
+ }
1996
+
1997
+ // src/react/SpeedControl.tsx
1998
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1999
+ var SPEEDS = [1, 0.75, 0.5, 0.25, 0.1];
2000
+ var SPEED_ANGLES = {
2001
+ 0.1: 225,
2002
+ // 7 o'clock
2003
+ 0.25: 315,
2004
+ // 10 o'clock
2005
+ 0.5: 0,
2006
+ // 12 o'clock
2007
+ 0.75: 45,
2008
+ // 2 o'clock
2009
+ 1: 135
2010
+ // 4 o'clock
2011
+ };
2012
+ var HAND_PATHS = {
2013
+ 0.1: "M7.293 5.293a1 1 0 1 1 1.414 1.414l-1.5 1.5a1 1 0 1 1-1.414-1.414l1.5-1.5Z",
2014
+ 0.25: "M5.793 5.794a1 1 0 0 1 1.414 0l1.5 1.499a1 1 0 0 1-1.414 1.414l-1.5-1.499a1 1 0 0 1 0-1.414Z",
2015
+ 0.5: "M8 5a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0V6a1 1 0 0 1 1-1Z",
2016
+ 0.75: "M8.793 5.293a1 1 0 0 1 1.414 1.414l-1.5 1.5a1 1 0 0 1-1.414-1.414l1.5-1.5Z",
2017
+ 1: "M7.293 8.293a1 1 0 0 1 1.414 0l1.5 1.5a1 1 0 1 1-1.414 1.414l-1.5-1.5a1 1 0 0 1 0-1.414Z"
2018
+ };
2019
+ function SpeedControl({ speed, isPaused, onSetSpeed, onTogglePause }) {
2020
+ const currentIndex = SPEEDS.findIndex((s) => Math.abs(speed - s) < 1e-3);
2021
+ const activeIndex = currentIndex >= 0 ? currentIndex : 0;
2022
+ const prevAngleRef = useRef3(SPEED_ANGLES[SPEEDS[activeIndex]]);
2023
+ const cumulativeRef = useRef3(SPEED_ANGLES[SPEEDS[activeIndex]]);
2024
+ const directionRef = useRef3(1);
2025
+ const forceClockwiseRef = useRef3(false);
2026
+ function cycle() {
2027
+ if (isPaused) {
2028
+ onTogglePause();
2029
+ return;
2030
+ }
2031
+ let next = activeIndex + directionRef.current;
2032
+ if (next >= SPEEDS.length) {
2033
+ directionRef.current = -1;
2034
+ next = activeIndex - 1;
2035
+ } else if (next < 0) {
2036
+ directionRef.current = 1;
2037
+ next = activeIndex + 1;
2038
+ }
2039
+ onSetSpeed(SPEEDS[next]);
2040
+ }
2041
+ const targetAngle = isPaused ? 180 : SPEED_ANGLES[SPEEDS[activeIndex]] ?? 135;
2042
+ if (targetAngle !== prevAngleRef.current) {
2043
+ let delta = targetAngle - prevAngleRef.current;
2044
+ if (isPaused) {
2045
+ while (delta >= 0) delta -= 360;
2046
+ } else if (forceClockwiseRef.current) {
2047
+ while (delta <= 0) delta += 360;
2048
+ forceClockwiseRef.current = false;
2049
+ } else {
2050
+ while (delta > 180) delta -= 360;
2051
+ while (delta < -180) delta += 360;
2052
+ }
2053
+ cumulativeRef.current += delta;
2054
+ prevAngleRef.current = targetAngle;
2055
+ }
2056
+ const holdTimerRef = useRef3(null);
2057
+ const didHoldRef = useRef3(false);
2058
+ const startHold = useCallback2(() => {
2059
+ didHoldRef.current = false;
2060
+ holdTimerRef.current = setTimeout(() => {
2061
+ didHoldRef.current = true;
2062
+ forceClockwiseRef.current = true;
2063
+ directionRef.current = 1;
2064
+ onSetSpeed(1);
2065
+ }, 240);
2066
+ }, [onSetSpeed]);
2067
+ const endHold = useCallback2(() => {
2068
+ if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
2069
+ holdTimerRef.current = null;
2070
+ }, []);
2071
+ const handleClick = useCallback2(() => {
2072
+ if (didHoldRef.current) {
2073
+ didHoldRef.current = false;
2074
+ return;
2075
+ }
2076
+ cycle();
2077
+ }, [cycle]);
2078
+ const currentSpeed = SPEEDS[activeIndex];
2079
+ return /* @__PURE__ */ jsxs2("div", { className: "lapse-speed", children: [
2080
+ /* @__PURE__ */ jsxs2(
2081
+ "button",
2082
+ {
2083
+ onClick: onTogglePause,
2084
+ className: "lapse-btn lapse-btn-icon lapse-playpause",
2085
+ "data-tooltip": isPaused ? "Play" : "Pause",
2086
+ children: [
2087
+ /* @__PURE__ */ jsx3("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", className: `lapse-playpause-icon ${isPaused ? "lapse-playpause-icon--active" : ""}`, style: { marginLeft: 1 }, children: /* @__PURE__ */ jsx3("path", { stroke: "currentColor", strokeLinecap: "round", strokeWidth: "2", d: "M3 12.821V3.18a1 1 0 0 1 1.476-.88l8.9 4.822a1 1 0 0 1 0 1.758l-8.9 4.821A1 1 0 0 1 3 12.821Z" }) }),
2088
+ /* @__PURE__ */ jsx3("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", className: `lapse-playpause-icon ${!isPaused ? "lapse-playpause-icon--active" : ""}`, children: /* @__PURE__ */ jsx3("path", { stroke: "currentColor", strokeLinecap: "round", strokeWidth: "2", d: "M3.5 2.5v11m9-11v11" }) })
2089
+ ]
2090
+ }
2091
+ ),
2092
+ /* @__PURE__ */ jsx3(
2093
+ "button",
2094
+ {
2095
+ onClick: handleClick,
2096
+ onMouseDown: startHold,
2097
+ onMouseUp: endHold,
2098
+ onMouseLeave: endHold,
2099
+ className: "lapse-speed-cycle",
2100
+ "data-tooltip": formatSpeed(speed),
2101
+ children: /* @__PURE__ */ jsxs2("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", className: "lapse-speed-clock", children: [
2102
+ /* @__PURE__ */ jsx3("path", { fill: "currentColor", d: "M8 .5a7.5 7.5 0 1 1 0 15 7.5 7.5 0 0 1 0-15Zm0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Z" }),
2103
+ /* @__PURE__ */ jsx3("g", { style: { transform: `rotate(${cumulativeRef.current}deg)`, transformOrigin: "8px 8px", transition: "transform 0.4s cubic-bezier(0, 0.55, 0.45, 1)" }, children: /* @__PURE__ */ jsx3("path", { fill: "currentColor", d: HAND_PATHS[0.5] }) })
2104
+ ] })
2105
+ }
2106
+ )
2107
+ ] });
2108
+ }
2109
+
2110
+ // src/react/useTimeline.ts
2111
+ import { useState as useState2, useCallback as useCallback3, useEffect as useEffect2, useRef as useRef4, useSyncExternalStore } from "react";
2112
+ var DETAIL_LEVELS = ["compact", "standard", "detailed", "forensic"];
2113
+ function useTimeline() {
2114
+ const engine = useLapseEngine();
2115
+ const state = useSyncExternalStore(
2116
+ (cb) => engine.subscribe(cb),
2117
+ () => engine.state
2118
+ );
2119
+ const [capture, setCapture] = useState2(null);
2120
+ const [scrubTime, setScrubTime] = useState2(0);
2121
+ const [copied, setCopied] = useState2(false);
2122
+ const [exportFilter, setExportFilter] = useState2("all-animations");
2123
+ const [detailLevel, setDetailLevel] = useState2("standard");
2124
+ const copiedTimeout = useRef4(null);
2125
+ const pendingSeek = useRef4(null);
2126
+ const rafId = useRef4(0);
2127
+ useEffect2(() => {
2128
+ if (state === "scrubbing" && !capture) {
2129
+ const engineCapture = engine.getCapture();
2130
+ if (engineCapture) {
2131
+ setCapture(engineCapture);
2132
+ setScrubTime(0);
2133
+ }
2134
+ } else if (state === "idle") {
2135
+ setCapture(null);
2136
+ setScrubTime(0);
2137
+ }
2138
+ }, [state, capture, engine]);
2139
+ const startRecording = useCallback3(
2140
+ (boundingBox) => {
2141
+ setCapture(null);
2142
+ setScrubTime(0);
2143
+ engine.startRecording(boundingBox);
2144
+ },
2145
+ [engine]
2146
+ );
2147
+ const stopRecording = useCallback3(() => {
2148
+ const result = engine.stopRecording();
2149
+ setCapture(result);
2150
+ setScrubTime(0);
2151
+ }, [engine]);
2152
+ const seek = useCallback3(
2153
+ (timeMs) => {
2154
+ setScrubTime(timeMs);
2155
+ pendingSeek.current = timeMs;
2156
+ if (!rafId.current) {
2157
+ rafId.current = requestAnimationFrame(() => {
2158
+ rafId.current = 0;
2159
+ if (pendingSeek.current !== null) {
2160
+ engine.seekTo(pendingSeek.current);
2161
+ pendingSeek.current = null;
2162
+ }
2163
+ });
2164
+ }
2165
+ },
2166
+ [engine]
2167
+ );
2168
+ const release = useCallback3(() => {
2169
+ if (rafId.current) {
2170
+ cancelAnimationFrame(rafId.current);
2171
+ rafId.current = 0;
2172
+ }
2173
+ pendingSeek.current = null;
2174
+ engine.release();
2175
+ setCapture(null);
2176
+ setScrubTime(0);
2177
+ }, [engine]);
2178
+ const cycleDetailLevel = useCallback3(() => {
2179
+ setDetailLevel((prev) => {
2180
+ const idx = DETAIL_LEVELS.indexOf(prev);
2181
+ return DETAIL_LEVELS[(idx + 1) % DETAIL_LEVELS.length];
2182
+ });
2183
+ }, []);
2184
+ const exportLLM = useCallback3(
2185
+ (filter) => {
2186
+ if (!capture) return "";
2187
+ const f = filter || exportFilter || "active";
2188
+ const t = Number(scrubTime) || 0;
2189
+ const text = engine.exportForLLM(t, f, detailLevel);
2190
+ navigator.clipboard.writeText(text).catch(() => {
2191
+ });
2192
+ setCopied(true);
2193
+ if (copiedTimeout.current) clearTimeout(copiedTimeout.current);
2194
+ copiedTimeout.current = setTimeout(() => setCopied(false), 2e3);
2195
+ return text;
2196
+ },
2197
+ [engine, capture, scrubTime, exportFilter, detailLevel]
2198
+ );
2199
+ return {
2200
+ state,
2201
+ capture,
2202
+ scrubTime,
2203
+ copied,
2204
+ exportFilter,
2205
+ setExportFilter,
2206
+ detailLevel,
2207
+ cycleDetailLevel,
2208
+ startRecording,
2209
+ stopRecording,
2210
+ seek,
2211
+ release,
2212
+ exportLLM
2213
+ };
2214
+ }
2215
+
2216
+ // src/react/useSpeed.ts
2217
+ import { useState as useState3, useCallback as useCallback4, useEffect as useEffect3 } from "react";
2218
+ function useSpeed() {
2219
+ const engine = useLapseEngine();
2220
+ const [speed, setSpeedState] = useState3(1);
2221
+ const [previousSpeed, setPreviousSpeed] = useState3(1);
2222
+ const [isPaused, setIsPaused] = useState3(false);
2223
+ const setSpeed = useCallback4(
2224
+ (value) => {
2225
+ const clamped = Math.max(SPEED_MIN, Math.min(SPEED_MAX, value));
2226
+ setSpeedState(clamped);
2227
+ setIsPaused(false);
2228
+ setPreviousSpeed(clamped);
2229
+ engine.setSpeed(clamped);
2230
+ },
2231
+ [engine]
2232
+ );
2233
+ const togglePause = useCallback4(() => {
2234
+ if (isPaused) {
2235
+ setSpeedState(previousSpeed);
2236
+ setIsPaused(false);
2237
+ engine.setSpeed(previousSpeed);
2238
+ } else {
2239
+ setPreviousSpeed(speed);
2240
+ setSpeedState(0);
2241
+ setIsPaused(true);
2242
+ engine.setSpeed(0);
2243
+ }
2244
+ }, [engine, isPaused, previousSpeed, speed]);
2245
+ const stepDown = useCallback4(() => {
2246
+ const currentIndex = SPEED_PRESETS.findIndex((p) => p >= speed);
2247
+ const nextIndex = Math.max(0, (currentIndex <= 0 ? 1 : currentIndex) - 1);
2248
+ setSpeed(SPEED_PRESETS[nextIndex]);
2249
+ }, [speed, setSpeed]);
2250
+ const stepUp = useCallback4(() => {
2251
+ const currentIndex = SPEED_PRESETS.findIndex((p) => p > speed);
2252
+ const nextIndex = Math.min(
2253
+ SPEED_PRESETS.length - 1,
2254
+ currentIndex < 0 ? SPEED_PRESETS.length - 1 : currentIndex
2255
+ );
2256
+ setSpeed(SPEED_PRESETS[nextIndex]);
2257
+ }, [speed, setSpeed]);
2258
+ useEffect3(() => {
2259
+ function handleKeyDown(e) {
2260
+ if (e.target instanceof HTMLInputElement) return;
2261
+ switch (e.key) {
2262
+ case "[":
2263
+ stepDown();
2264
+ break;
2265
+ case "]":
2266
+ stepUp();
2267
+ break;
2268
+ case "\\":
2269
+ setSpeed(1);
2270
+ break;
2271
+ case "s":
2272
+ togglePause();
2273
+ break;
2274
+ }
2275
+ }
2276
+ window.addEventListener("keydown", handleKeyDown);
2277
+ return () => window.removeEventListener("keydown", handleKeyDown);
2278
+ }, [stepDown, stepUp, setSpeed, togglePause]);
2279
+ return { speed, isPaused, setSpeed, togglePause };
2280
+ }
2281
+
2282
+ // src/react/LapsePanel.tsx
2283
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
2284
+ function LapsePanel() {
2285
+ const timeline = useTimeline();
2286
+ const { speed, isPaused, setSpeed, togglePause } = useSpeed();
2287
+ const panelRef = useRef5(null);
2288
+ const warmTimeout = useRef5(null);
2289
+ const handleMouseOver = useCallback5((e) => {
2290
+ const target = e.target.closest?.("[data-tooltip]");
2291
+ if (target && panelRef.current) {
2292
+ panelRef.current.classList.add("lapse-tooltip-warm");
2293
+ if (warmTimeout.current) clearTimeout(warmTimeout.current);
2294
+ }
2295
+ }, []);
2296
+ const handleMouseOut = useCallback5((e) => {
2297
+ const related = e.relatedTarget;
2298
+ const stillOnTooltip = related?.closest?.("[data-tooltip]");
2299
+ if (!stillOnTooltip) {
2300
+ warmTimeout.current = setTimeout(() => {
2301
+ panelRef.current?.classList.remove("lapse-tooltip-warm");
2302
+ }, 300);
2303
+ }
2304
+ }, []);
2305
+ return /* @__PURE__ */ jsxs3(
2306
+ "div",
2307
+ {
2308
+ ref: panelRef,
2309
+ className: "lapse-panel",
2310
+ onMouseOver: handleMouseOver,
2311
+ onMouseOut: handleMouseOut,
2312
+ children: [
2313
+ /* @__PURE__ */ jsx4(
2314
+ Timeline,
2315
+ {
2316
+ state: timeline.state,
2317
+ capture: timeline.capture,
2318
+ scrubTime: timeline.scrubTime,
2319
+ copied: timeline.copied,
2320
+ exportFilter: timeline.exportFilter,
2321
+ onSetExportFilter: timeline.setExportFilter,
2322
+ onStartRecording: () => timeline.startRecording(),
2323
+ onStopRecording: timeline.stopRecording,
2324
+ onSeek: timeline.seek,
2325
+ onRelease: timeline.release,
2326
+ onExportLLM: () => timeline.exportLLM(),
2327
+ detailLevel: timeline.detailLevel,
2328
+ onCycleDetailLevel: timeline.cycleDetailLevel
2329
+ }
2330
+ ),
2331
+ timeline.state !== "scrubbing" && /* @__PURE__ */ jsx4("div", { className: `lapse-speed-wrap ${timeline.state === "recording" ? "lapse-speed-wrap--hidden" : ""}`, children: /* @__PURE__ */ jsx4(
2332
+ SpeedControl,
2333
+ {
2334
+ speed,
2335
+ isPaused,
2336
+ onSetSpeed: setSpeed,
2337
+ onTogglePause: togglePause
2338
+ }
2339
+ ) })
2340
+ ]
2341
+ }
2342
+ );
2343
+ }
2344
+
2345
+ // src/react/styles.ts
2346
+ var PANEL_STYLES = (
2347
+ /* css */
2348
+ `
2349
+ :host {
2350
+ --lapse-bg: #1a1a1a;
2351
+ --lapse-surface: #2a2a2a;
2352
+ --lapse-border: #3a3a3a;
2353
+ --lapse-text: #e5e5e5;
2354
+ --lapse-text-muted: #888;
2355
+ --lapse-accent: #6d9fff;
2356
+ --lapse-speed-active: #5b8def;
2357
+ --lapse-danger: #ff6b6b;
2358
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2359
+ font-size: 13px;
2360
+ line-height: 1.4;
2361
+ color: var(--lapse-text);
2362
+ }
2363
+
2364
+ *, *::before, *::after {
2365
+ box-sizing: border-box;
2366
+ margin: 0;
2367
+ padding: 0;
2368
+ }
2369
+
2370
+ /* Tooltips */
2371
+ [data-tooltip] {
2372
+ position: relative;
2373
+ }
2374
+ [data-tooltip]::after {
2375
+ content: attr(data-tooltip);
2376
+ position: absolute;
2377
+ bottom: calc(100% + 8px);
2378
+ left: 50%;
2379
+ transform: translateX(-50%) translateY(2px) scale(0.96);
2380
+ padding: 4px 10px;
2381
+ border-radius: 6px;
2382
+ background: #0d0d0d;
2383
+ border: 0.5px solid var(--lapse-border);
2384
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
2385
+ color: var(--lapse-text);
2386
+ font-size: 11px;
2387
+ font-weight: 500;
2388
+ white-space: nowrap;
2389
+ pointer-events: none;
2390
+ opacity: 0;
2391
+ transition: opacity 0.15s ease-out 0.65s, transform 0.15s ease-out 0.65s;
2392
+ }
2393
+ [data-tooltip]:hover::after {
2394
+ opacity: 1;
2395
+ transform: translateX(-50%) translateY(0) scale(1);
2396
+ }
2397
+ /* Warm: when panel already has a tooltip showing, new ones appear instantly */
2398
+ .lapse-panel.lapse-tooltip-warm [data-tooltip]::after {
2399
+ transition-duration: 0s;
2400
+ transition-delay: 0s;
2401
+ }
2402
+
2403
+ /* Panel container */
2404
+ .lapse-panel {
2405
+ display: flex;
2406
+ align-items: center;
2407
+ gap: 0;
2408
+ background: var(--lapse-bg);
2409
+ border: 0.5px solid var(--lapse-border);
2410
+ border-radius: 999px;
2411
+ padding: 4px;
2412
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2), 0 4px 16px rgba(0, 0, 0, 0.1);
2413
+ overflow: visible;
2414
+ }
2415
+
2416
+ /* Buttons */
2417
+ .lapse-btn {
2418
+ display: flex;
2419
+ align-items: center;
2420
+ gap: 6px;
2421
+ height: 32px;
2422
+ padding: 0 12px;
2423
+ border: none;
2424
+ border-radius: 999px;
2425
+ font-size: 13px;
2426
+ font-weight: 500;
2427
+ cursor: pointer;
2428
+ white-space: nowrap;
2429
+ transition: background 0.15s, color 0.15s;
2430
+ }
2431
+
2432
+ .lapse-btn-surface {
2433
+ background: transparent;
2434
+ color: var(--lapse-text-muted);
2435
+ }
2436
+ .lapse-btn-surface:hover {
2437
+ background: var(--lapse-surface);
2438
+ color: var(--lapse-text);
2439
+ }
2440
+
2441
+ .lapse-btn-recording {
2442
+ background: rgba(239, 68, 68, 0.1);
2443
+ color: #f87171;
2444
+ }
2445
+ .lapse-btn-recording:hover { background: rgba(239, 68, 68, 0.2); }
2446
+
2447
+ .lapse-btn-accent {
2448
+ background: rgba(109, 159, 255, 0.2);
2449
+ color: var(--lapse-accent);
2450
+ font-size: 13px;
2451
+ }
2452
+ .lapse-btn-accent:hover { background: rgba(109, 159, 255, 0.3); }
2453
+
2454
+ .lapse-btn-icon {
2455
+ width: 32px;
2456
+ height: 32px;
2457
+ padding: 0;
2458
+ justify-content: center;
2459
+ background: transparent;
2460
+ color: var(--lapse-text-muted);
2461
+ border: none;
2462
+ border-radius: 999px;
2463
+ cursor: pointer;
2464
+ transition: background 0.15s, color 0.15s;
2465
+ }
2466
+ .lapse-btn-icon:hover {
2467
+ background: var(--lapse-surface);
2468
+ color: var(--lapse-text);
2469
+ }
2470
+ .lapse-record-btn--active:hover {
2471
+ color: #ef4444;
2472
+ }
2473
+
2474
+ /* Main button \u2014 icon morphing (record / recording / close) */
2475
+ .lapse-main-btn {
2476
+ position: relative;
2477
+ }
2478
+
2479
+ .lapse-morph-icon {
2480
+ position: absolute;
2481
+ opacity: 0;
2482
+ scale: 0.4;
2483
+ filter: blur(6px);
2484
+ transition: opacity 0.2s ease, scale 0.2s ease, filter 0.2s ease;
2485
+ display: block;
2486
+ }
2487
+ .lapse-morph-icon--active {
2488
+ opacity: 1;
2489
+ scale: 1;
2490
+ filter: blur(0);
2491
+ }
2492
+
2493
+ .lapse-record-shape {
2494
+ animation: lapse-pulse 1.5s ease-in-out infinite;
2495
+ transition: x 0.4s cubic-bezier(0.19, 1, 0.22, 1),
2496
+ y 0.4s cubic-bezier(0.19, 1, 0.22, 1),
2497
+ width 0.4s cubic-bezier(0.19, 1, 0.22, 1),
2498
+ height 0.4s cubic-bezier(0.19, 1, 0.22, 1),
2499
+ rx 0.4s cubic-bezier(0.19, 1, 0.22, 1);
2500
+ }
2501
+
2502
+ .lapse-record-btn--active {
2503
+ color: #ef4444;
2504
+ }
2505
+
2506
+ @keyframes lapse-pulse {
2507
+ 0%, 100% { opacity: 1; }
2508
+ 50% { opacity: 0.5; }
2509
+ }
2510
+
2511
+ /* Scrub content \u2014 expands when entering scrubbing state */
2512
+ .lapse-scrub-wrap {
2513
+ display: grid;
2514
+ grid-template-columns: 0fr;
2515
+ margin-left: 0;
2516
+ transition: grid-template-columns 0.4s cubic-bezier(0.19, 1, 0.22, 1),
2517
+ margin-left 0.4s cubic-bezier(0.19, 1, 0.22, 1);
2518
+ }
2519
+ .lapse-scrub-wrap > * {
2520
+ min-width: 0;
2521
+ }
2522
+ .lapse-scrub-wrap--visible {
2523
+ grid-template-columns: 1fr;
2524
+ margin-left: 6px;
2525
+ }
2526
+
2527
+ .lapse-scrub-inner {
2528
+ display: flex;
2529
+ align-items: center;
2530
+ gap: 6px;
2531
+ }
2532
+ .lapse-scrub-inner > * {
2533
+ transform-origin: left center;
2534
+ transition: opacity 0.6s cubic-bezier(0.19, 1, 0.22, 1) 0.05s,
2535
+ transform 0.6s cubic-bezier(0.19, 1, 0.22, 1) 0.05s,
2536
+ filter 0.8s cubic-bezier(0.19, 1, 0.22, 1) 0.05s;
2537
+ }
2538
+ .lapse-scrub-wrap:not(.lapse-scrub-wrap--visible) .lapse-scrub-inner > * {
2539
+ opacity: 0;
2540
+ transform: scale(0.85);
2541
+ filter: blur(10px);
2542
+ transition-delay: 0ms;
2543
+ transition-duration: 0.15s;
2544
+ }
2545
+
2546
+ /* Spinner */
2547
+ .lapse-spinner {
2548
+ width: 10px;
2549
+ height: 10px;
2550
+ animation: lapse-spin 1s linear infinite;
2551
+ }
2552
+ .lapse-spinner-track { opacity: 0.25; }
2553
+ .lapse-spinner-head { opacity: 0.75; }
2554
+
2555
+ @keyframes lapse-spin {
2556
+ from { transform: rotate(0deg); }
2557
+ to { transform: rotate(360deg); }
2558
+ }
2559
+
2560
+ /* Scrubber */
2561
+ .lapse-scrub-track {
2562
+ position: relative;
2563
+ height: 32px;
2564
+ width: 200px;
2565
+ cursor: pointer;
2566
+ border-radius: 6px;
2567
+ background: var(--lapse-surface);
2568
+ user-select: none;
2569
+ overflow: hidden;
2570
+ }
2571
+
2572
+ .lapse-scrub-marker {
2573
+ position: absolute;
2574
+ top: 0;
2575
+ height: 100%;
2576
+ width: 1px;
2577
+ background: rgba(109, 159, 255, 0.5);
2578
+ pointer-events: none;
2579
+ }
2580
+
2581
+ .lapse-scrub-fill {
2582
+ position: absolute;
2583
+ inset: 0;
2584
+ right: auto;
2585
+ background: rgba(109, 159, 255, 0.1);
2586
+ pointer-events: none;
2587
+ }
2588
+
2589
+ .lapse-scrub-playhead {
2590
+ position: absolute;
2591
+ top: 4px;
2592
+ bottom: 4px;
2593
+ width: 2px;
2594
+ border-radius: 1px;
2595
+ background: var(--lapse-accent);
2596
+ transform: translateX(-50%);
2597
+ pointer-events: none;
2598
+ }
2599
+
2600
+ .lapse-scrub-label {
2601
+ position: absolute;
2602
+ inset: 0;
2603
+ display: flex;
2604
+ align-items: flex-end;
2605
+ justify-content: center;
2606
+ padding-bottom: 2px;
2607
+ pointer-events: none;
2608
+ }
2609
+ .lapse-scrub-label span {
2610
+ font-size: 13px;
2611
+ font-variant-numeric: tabular-nums;
2612
+ color: var(--lapse-text-muted);
2613
+ }
2614
+
2615
+ /* Filter buttons */
2616
+ .lapse-filter-group {
2617
+ display: flex;
2618
+ border-radius: 6px;
2619
+ background: var(--lapse-surface);
2620
+ overflow: hidden;
2621
+ }
2622
+
2623
+ .lapse-filter-btn {
2624
+ height: 32px;
2625
+ padding: 0 8px;
2626
+ border: none;
2627
+ background: transparent;
2628
+ font-size: 13px;
2629
+ font-weight: 500;
2630
+ color: var(--lapse-text-muted);
2631
+ cursor: pointer;
2632
+ transition: background 0.15s, color 0.15s;
2633
+ }
2634
+ .lapse-filter-btn:hover { color: var(--lapse-text); }
2635
+ .lapse-filter-btn--active {
2636
+ background: rgba(109, 159, 255, 0.2);
2637
+ color: var(--lapse-accent);
2638
+ }
2639
+
2640
+ /* Chevron */
2641
+ .lapse-chevron {
2642
+ transition: transform 0.2s;
2643
+ }
2644
+ .lapse-chevron--open {
2645
+ transform: rotate(90deg);
2646
+ }
2647
+
2648
+ /* Animation tags */
2649
+ .lapse-anim-tags {
2650
+ display: flex;
2651
+ flex-wrap: wrap;
2652
+ gap: 6px;
2653
+ }
2654
+ .lapse-anim-tag {
2655
+ background: rgba(109, 159, 255, 0.1);
2656
+ color: var(--lapse-accent);
2657
+ padding: 2px 6px;
2658
+ border-radius: 4px;
2659
+ font-size: 13px;
2660
+ }
2661
+
2662
+ /* Speed control wrapper \u2014 collapses when recording */
2663
+ .lapse-speed-wrap {
2664
+ display: grid;
2665
+ grid-template-columns: 1fr;
2666
+ margin-left: 6px;
2667
+ transition: grid-template-columns 0.4s cubic-bezier(0.19, 1, 0.22, 1),
2668
+ margin-left 0.4s cubic-bezier(0.19, 1, 0.22, 1);
2669
+ }
2670
+ .lapse-speed-wrap > * {
2671
+ min-width: 0;
2672
+ }
2673
+ .lapse-speed-wrap--hidden {
2674
+ grid-template-columns: 0fr;
2675
+ margin-left: 0;
2676
+ pointer-events: none;
2677
+ }
2678
+ .lapse-speed-wrap .lapse-speed {
2679
+ transition: transform 0.6s cubic-bezier(0.19, 1, 0.22, 1),
2680
+ filter 0.8s cubic-bezier(0.19, 1, 0.22, 1),
2681
+ opacity 0.6s cubic-bezier(0.19, 1, 0.22, 1);
2682
+ }
2683
+ .lapse-speed-wrap--hidden .lapse-speed {
2684
+ transform: translateX(-8px) scale(0.4);
2685
+ filter: blur(10px);
2686
+ opacity: 0;
2687
+ }
2688
+
2689
+ /* Speed control */
2690
+ .lapse-speed {
2691
+ display: flex;
2692
+ align-items: center;
2693
+ gap: 6px;
2694
+ }
2695
+
2696
+ /* Speed cycle button */
2697
+ .lapse-speed-cycle {
2698
+ display: flex;
2699
+ align-items: center;
2700
+ gap: 6px;
2701
+ height: 32px;
2702
+ padding: 0 8px;
2703
+ border: none;
2704
+ border-radius: 999px;
2705
+ background: transparent;
2706
+ color: var(--lapse-text-muted);
2707
+ font-size: 13px;
2708
+ font-weight: 500;
2709
+ cursor: pointer;
2710
+ transition: background 0.15s, color 0.15s;
2711
+ white-space: nowrap;
2712
+ }
2713
+ .lapse-speed-cycle:hover {
2714
+ background: var(--lapse-surface);
2715
+ color: var(--lapse-text);
2716
+ }
2717
+
2718
+ /* Play/pause transition */
2719
+ .lapse-playpause {
2720
+ position: relative;
2721
+ }
2722
+
2723
+ .lapse-playpause-icon {
2724
+ position: absolute;
2725
+ opacity: 0;
2726
+ scale: 0.4;
2727
+ filter: blur(6px);
2728
+ transition: opacity 0.2s ease, scale 0.2s ease, filter 0.2s ease;
2729
+ display: block;
2730
+ }
2731
+
2732
+ .lapse-playpause-icon--active {
2733
+ opacity: 1;
2734
+ scale: 1;
2735
+ filter: blur(0);
2736
+ }
2737
+
2738
+ .lapse-speed-cycle svg {
2739
+ display: block;
2740
+ }
2741
+ `
2742
+ );
2743
+
2744
+ // src/react/Lapse.tsx
2745
+ import { jsx as jsx5 } from "react/jsx-runtime";
2746
+ function Lapse({ position = "bottom-left" }) {
2747
+ const hostRef = useRef6(null);
2748
+ const [shadowRoot, setShadowRoot] = useState4(null);
2749
+ useEffect4(() => {
2750
+ const host = hostRef.current;
2751
+ if (!host || host.shadowRoot) {
2752
+ if (host?.shadowRoot) setShadowRoot(host.shadowRoot);
2753
+ return;
2754
+ }
2755
+ const shadow = host.attachShadow({ mode: "open" });
2756
+ const style = document.createElement("style");
2757
+ style.textContent = PANEL_STYLES;
2758
+ shadow.appendChild(style);
2759
+ const mount = document.createElement("div");
2760
+ shadow.appendChild(mount);
2761
+ setShadowRoot(shadow);
2762
+ }, []);
2763
+ const positionOffset = position === "top-left" ? { top: 20, left: 20 } : position === "top-right" ? { top: 20, right: 20 } : position === "bottom-left" ? { bottom: 20, left: 20 } : { bottom: 20, right: 20 };
2764
+ return /* @__PURE__ */ jsx5(
2765
+ "div",
2766
+ {
2767
+ ref: hostRef,
2768
+ "data-lapse-panel": "",
2769
+ style: {
2770
+ position: "fixed",
2771
+ zIndex: 2147483647,
2772
+ // max int — must sit above the scrub blocker (z-index: 999999)
2773
+ pointerEvents: "auto",
2774
+ ...positionOffset
2775
+ },
2776
+ children: shadowRoot && createPortal(
2777
+ /* @__PURE__ */ jsx5(LapseProvider, { children: /* @__PURE__ */ jsx5(LapsePanel, {}) }),
2778
+ shadowRoot.lastElementChild || shadowRoot
2779
+ )
2780
+ }
2781
+ );
2782
+ }
2783
+ export {
2784
+ Lapse,
2785
+ LapseEngine,
2786
+ LapseProvider,
2787
+ useLapseEngine,
2788
+ useSpeed,
2789
+ useTimeline
2790
+ };
2791
+ //# sourceMappingURL=index.mjs.map