saccade 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,23 +1,28 @@
1
1
  "use client";
2
2
 
3
- // src/react/Lapse.tsx
3
+ // src/react/Saccade.tsx
4
4
  import { useRef as useRef6, useState as useState4, useEffect as useEffect4 } from "react";
5
5
  import { createPortal } from "react-dom";
6
6
 
7
- // src/react/LapseContext.tsx
7
+ // src/react/SaccadeContext.tsx
8
8
  import { createContext, useContext, useRef } from "react";
9
9
 
10
10
  // src/core/timing.ts
11
+ var MEDIA_RATE_MIN = 0.0625;
12
+ var MEDIA_RATE_MAX = 16;
13
+ var ZERO_SPEED_DIVISOR = 1e-4;
11
14
  var TimingController = class {
12
15
  constructor() {
13
16
  this.speed = 1;
14
17
  this.virtualBaseline = 0;
15
18
  this.intervalMap = /* @__PURE__ */ new Map();
16
19
  this.nextIntervalId = 1e6;
17
- this.mediaObserver = null;
18
- this.animObserver = null;
19
20
  this._origAnimate = null;
20
21
  this.installed = false;
22
+ this.animPollId = 0;
23
+ // WeakMap tracking for animations and media
24
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
25
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
21
26
  this._raf = requestAnimationFrame.bind(window);
22
27
  this._caf = cancelAnimationFrame.bind(window);
23
28
  this._setTimeout = setTimeout.bind(window);
@@ -27,19 +32,34 @@ var TimingController = class {
27
32
  this._perfNow = performance.now.bind(performance);
28
33
  this._dateNow = Date.now;
29
34
  this.realBaseline = this._perfNow();
35
+ this.dateRealBaseline = this._dateNow();
36
+ this.dateVirtualBaseline = this.dateRealBaseline;
30
37
  }
31
38
  getVirtualTime() {
32
39
  const realElapsed = this._perfNow() - this.realBaseline;
33
40
  return this.virtualBaseline + realElapsed * this.speed;
34
41
  }
42
+ getVirtualDateNow() {
43
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
44
+ return this.dateVirtualBaseline + realElapsed * this.speed;
45
+ }
35
46
  reanchor() {
36
47
  const virtualNow = this.getVirtualTime();
37
48
  this.realBaseline = this._perfNow();
38
49
  this.virtualBaseline = virtualNow;
50
+ const virtualDateNow = this.getVirtualDateNow();
51
+ this.dateRealBaseline = this._dateNow();
52
+ this.dateVirtualBaseline = virtualDateNow;
53
+ }
54
+ /** Effective speed divisor — avoids division by zero at speed=0. */
55
+ get speedDivisor() {
56
+ return this.speed || ZERO_SPEED_DIVISOR;
39
57
  }
40
58
  /** Install timing patches. Safe to call multiple times. */
41
59
  install() {
42
60
  if (this.installed) return;
61
+ if (window.__saccadeInstalled) return;
62
+ window.__saccadeInstalled = true;
43
63
  this.installed = true;
44
64
  const self = this;
45
65
  window.__LAPSE_ORIGINAL_RAF__ = this._raf;
@@ -48,21 +68,27 @@ var TimingController = class {
48
68
  Element.prototype.animate = function(...args) {
49
69
  const anim = origAnimate.apply(this, args);
50
70
  if (self.speed !== 1) {
51
- anim.playbackRate = self.speed || 1e-3;
71
+ const originalRate = anim.playbackRate;
72
+ const applied = originalRate * (self.speed || 1e-3);
73
+ anim.playbackRate = applied;
74
+ self.trackedAnims.set(anim, { original: originalRate, applied });
52
75
  }
53
76
  return anim;
54
77
  };
55
78
  performance.now = () => self.getVirtualTime();
56
- const dateBaseline = this._dateNow();
57
- Date.now = () => dateBaseline + self.getVirtualTime();
79
+ Date.now = () => self.getVirtualDateNow();
58
80
  window.requestAnimationFrame = (callback) => {
59
81
  return self._raf(() => {
82
+ if (self.speed === 0) {
83
+ window.requestAnimationFrame(callback);
84
+ return;
85
+ }
60
86
  callback(self.getVirtualTime());
61
87
  });
62
88
  };
63
89
  window.cancelAnimationFrame = this._caf;
64
90
  window.setTimeout = ((handler, delay, ...args) => {
65
- const scaledDelay = (delay ?? 0) / (self.speed || 1);
91
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
66
92
  return self._setTimeout(handler, scaledDelay, ...args);
67
93
  });
68
94
  window.clearTimeout = this._clearTimeout;
@@ -70,7 +96,7 @@ var TimingController = class {
70
96
  const id = self.nextIntervalId++;
71
97
  const baseDelay = delay ?? 0;
72
98
  function tick() {
73
- const scaledDelay = baseDelay / (self.speed || 1);
99
+ const scaledDelay = baseDelay / self.speedDivisor;
74
100
  const realId = self._setTimeout(() => {
75
101
  if (typeof handler === "function") {
76
102
  ;
@@ -95,61 +121,93 @@ var TimingController = class {
95
121
  self._clearInterval(id);
96
122
  }
97
123
  });
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
- }
124
+ this.startAnimationPoll();
110
125
  }
111
126
  /** Set playback speed. Requires install() first. */
112
127
  setSpeed(newSpeed) {
113
128
  if (!this.installed) this.install();
114
129
  this.reanchor();
115
130
  this.speed = newSpeed;
116
- document.querySelectorAll("video, audio").forEach((el) => {
117
- ;
118
- el.playbackRate = newSpeed;
119
- });
120
131
  this.patchAnimations();
132
+ this.patchMedia();
133
+ this.patchGSAP();
134
+ }
135
+ getSpeed() {
136
+ return this.speed;
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // Animation polling — per-frame via original rAF
140
+ // ---------------------------------------------------------------------------
141
+ startAnimationPoll() {
142
+ const poll = () => {
143
+ if (!this.installed) return;
144
+ this.patchAnimations();
145
+ this.patchMedia();
146
+ this.animPollId = this._raf(poll);
147
+ };
148
+ this.animPollId = this._raf(poll);
121
149
  }
122
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
150
+ /** Patch playbackRate on all active animations via WAAPI. */
123
151
  patchAnimations() {
124
152
  try {
125
153
  const anims = document.getAnimations();
126
154
  for (const anim of anims) {
127
155
  const target = anim.effect?.target;
128
156
  if (target?.closest?.("[data-lapse-panel]")) continue;
129
- anim.playbackRate = this.speed || 1e-3;
157
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
158
+ const effectiveSpeed = this.speed || 1e-3;
159
+ let tracked = this.trackedAnims.get(anim);
160
+ if (!tracked) {
161
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
162
+ this.trackedAnims.set(anim, tracked);
163
+ } else if (anim.playbackRate !== tracked.applied) {
164
+ tracked.original = anim.playbackRate;
165
+ }
166
+ const desired = tracked.original * effectiveSpeed;
167
+ if (anim.playbackRate !== desired) {
168
+ anim.playbackRate = desired;
169
+ tracked.applied = desired;
170
+ }
130
171
  }
131
172
  } catch {
132
173
  }
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 {
174
+ }
175
+ /** Patch playbackRate on all video/audio elements. */
176
+ patchMedia() {
177
+ try {
178
+ document.querySelectorAll("video, audio").forEach((node) => {
179
+ const el = node;
180
+ if (el.closest?.("[data-lapse-panel]")) return;
181
+ if (el.closest?.("[data-saccade-exclude]")) return;
182
+ let tracked = this.trackedMedia.get(el);
183
+ if (!tracked) {
184
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
185
+ this.trackedMedia.set(el, tracked);
186
+ } else if (el.playbackRate !== tracked.applied) {
187
+ tracked.original = el.playbackRate;
188
+ }
189
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
190
+ if (el.playbackRate !== desired) {
191
+ el.playbackRate = desired;
192
+ tracked.applied = desired;
146
193
  }
147
- }, 100);
194
+ });
195
+ } catch {
148
196
  }
149
197
  }
150
- getSpeed() {
151
- return this.speed;
198
+ /** Sync GSAP's global timeline if present. */
199
+ patchGSAP() {
200
+ try {
201
+ const gsap = window.gsap;
202
+ if (gsap?.globalTimeline) {
203
+ gsap.globalTimeline.timeScale(this.speed || 1e-3);
204
+ }
205
+ } catch {
206
+ }
152
207
  }
208
+ // ---------------------------------------------------------------------------
209
+ // Cleanup
210
+ // ---------------------------------------------------------------------------
153
211
  /** Restore all patched APIs to originals. */
154
212
  destroy() {
155
213
  if (!this.installed) return;
@@ -169,19 +227,32 @@ var TimingController = class {
169
227
  Element.prototype.animate = this._origAnimate;
170
228
  this._origAnimate = null;
171
229
  }
172
- this.mediaObserver?.disconnect();
173
- this.mediaObserver = null;
174
- if (this.animObserver != null) {
175
- this._clearInterval(this.animObserver);
176
- this.animObserver = null;
230
+ if (this.animPollId) {
231
+ this._caf(this.animPollId);
232
+ this.animPollId = 0;
177
233
  }
178
234
  try {
179
235
  for (const anim of document.getAnimations()) {
180
- anim.playbackRate = 1;
236
+ const tracked = this.trackedAnims.get(anim);
237
+ anim.playbackRate = tracked?.original ?? 1;
181
238
  }
182
239
  } catch {
183
240
  }
241
+ try {
242
+ document.querySelectorAll("video, audio").forEach((node) => {
243
+ const el = node;
244
+ const tracked = this.trackedMedia.get(el);
245
+ el.playbackRate = tracked?.original ?? 1;
246
+ });
247
+ } catch {
248
+ }
249
+ try {
250
+ const gsap = window.gsap;
251
+ if (gsap?.globalTimeline) gsap.globalTimeline.timeScale(1);
252
+ } catch {
253
+ }
184
254
  delete window.__LAPSE_ORIGINAL_RAF__;
255
+ delete window.__saccadeInstalled;
185
256
  this.installed = false;
186
257
  }
187
258
  };
@@ -251,6 +322,10 @@ var SNAPSHOT_ATTRS = [
251
322
  "data-hover",
252
323
  "data-at-boundary",
253
324
  "data-scrubbing",
325
+ "data-starting-style",
326
+ "data-ending-style",
327
+ "data-panel-open",
328
+ "data-hidden",
254
329
  "aria-checked",
255
330
  "aria-selected",
256
331
  "aria-expanded",
@@ -264,6 +339,7 @@ var SNAPSHOT_ATTRS = [
264
339
  "checked",
265
340
  "disabled",
266
341
  "hidden",
342
+ "inert",
267
343
  "value",
268
344
  "class",
269
345
  "style"
@@ -395,6 +471,8 @@ var _TimelineRecorder = class _TimelineRecorder {
395
471
  // ---- WAAPI interception --------------------------------------------------
396
472
  /** Animations captured via Element.prototype.animate monkey-patch. */
397
473
  this.interceptedAnimations = [];
474
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
475
+ this.seekableClones = /* @__PURE__ */ new Map();
398
476
  this.hiddenSince = null;
399
477
  this.onVisibilityChange = null;
400
478
  /** Set to true when the capture loop self-terminates due to limits. */
@@ -445,6 +523,7 @@ var _TimelineRecorder = class _TimelineRecorder {
445
523
  this.portalIdCounter = 0;
446
524
  this.currentPortalIds.clear();
447
525
  this.capturedPortals.clear();
526
+ this.seekableClones.clear();
448
527
  this.prevInlineStyles.clear();
449
528
  this.jsAnimStartTimes.clear();
450
529
  this.jsAnimLastSeen.clear();
@@ -798,6 +877,13 @@ var _TimelineRecorder = class _TimelineRecorder {
798
877
  } catch (_) {
799
878
  }
800
879
  }
880
+ const cleanedKeyframes = keyframes2.map((kf) => {
881
+ const clean = {};
882
+ for (const [k, v] of Object.entries(kf)) {
883
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
884
+ }
885
+ return clean;
886
+ });
801
887
  this.animations.set(id, {
802
888
  id,
803
889
  name,
@@ -809,7 +895,9 @@ var _TimelineRecorder = class _TimelineRecorder {
809
895
  type,
810
896
  source,
811
897
  resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
812
- conflicts
898
+ conflicts,
899
+ rawKeyframes: cleanedKeyframes,
900
+ rawTiming: { ...timing, fill: "both" }
813
901
  });
814
902
  }
815
903
  const keyframes = a.effect?.getKeyframes?.() || [];
@@ -1030,96 +1118,83 @@ var _TimelineRecorder = class _TimelineRecorder {
1030
1118
  blocker.title = "Clear the timeline to interact with the page";
1031
1119
  document.body.appendChild(blocker);
1032
1120
  this.blockerEl = blocker;
1033
- try {
1034
- const lapseStyle = document.createElement("style");
1035
- lapseStyle.id = "__lapse-state-rules";
1036
- let allCss = "";
1037
- for (const style of document.querySelectorAll("style")) {
1038
- if (style.id === "__lapse-state-rules") continue;
1039
- allCss += style.textContent + "\n";
1040
- }
1041
- for (const sheet of document.styleSheets) {
1042
- try {
1043
- let walk2 = function(rules) {
1044
- for (const rule of rules) {
1045
- if (rule.cssRules) {
1046
- walk2(rule.cssRules);
1047
- continue;
1048
- }
1049
- const t = rule.cssText;
1050
- if (t && (t.includes(":hover") || t.includes(":focus"))) {
1051
- allCss += t + "\n";
1121
+ setTimeout(() => {
1122
+ try {
1123
+ const lapseStyle = document.createElement("style");
1124
+ lapseStyle.id = "__lapse-state-rules";
1125
+ const hoverFocusRules = [];
1126
+ for (const sheet of document.styleSheets) {
1127
+ try {
1128
+ const walk = (rules) => {
1129
+ for (const rule of rules) {
1130
+ if (rule.cssRules) {
1131
+ walk(rule.cssRules);
1132
+ continue;
1133
+ }
1134
+ const t = rule.cssText;
1135
+ if (t && (t.includes(":hover") || t.includes(":focus"))) {
1136
+ hoverFocusRules.push(t);
1137
+ }
1052
1138
  }
1053
- }
1054
- };
1055
- var walk = walk2;
1056
- walk2(sheet.cssRules);
1057
- } catch (_) {
1058
- }
1059
- }
1060
- const stateRegex = /([^{}]*(?::hover|:focus-visible|:focus-within|:focus(?!-))[^{}]*)\{([^{}]*)\}/g;
1061
- let match;
1062
- while ((match = stateRegex.exec(allCss)) !== null) {
1063
- const selector = match[1].trim();
1064
- const body = match[2].trim();
1065
- if (!body) continue;
1066
- const newBody = body.replace(
1067
- /([^;:]+):\s*([^;]+)(;|$)/g,
1068
- (m, prop, val, end) => {
1069
- if (val.includes("!important")) return m;
1070
- return prop + ": " + val.trim() + " !important" + end;
1139
+ };
1140
+ walk(sheet.cssRules);
1141
+ } catch (_) {
1071
1142
  }
1072
- );
1073
- if (selector.includes(":hover")) {
1074
- lapseStyle.textContent += selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }\n";
1075
- }
1076
- if (selector.includes(":focus-visible")) {
1077
- lapseStyle.textContent += selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }\n";
1078
- } else if (selector.includes(":focus-within")) {
1079
- lapseStyle.textContent += selector.replace(
1080
- /:focus-within/g,
1081
- ":has([data-lapse-focus])"
1082
- ) + " { " + newBody + " }\n";
1083
- } else if (selector.includes(":focus")) {
1084
- lapseStyle.textContent += selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }\n";
1085
1143
  }
1086
- }
1087
- document.head.appendChild(lapseStyle);
1088
- this.lapseStyleEl = lapseStyle;
1089
- } catch (_) {
1090
- }
1091
- if (this.frames.length > 0) {
1092
- const frame0 = this.frames[0];
1093
- if (frame0.elementSnapshots) {
1094
- for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
1095
- const el = this.elements.get(sel);
1096
- if (!el || !el.isConnected) continue;
1097
- if (snap.__styles) {
1098
- for (const [prop, value] of Object.entries(snap.__styles)) {
1099
- if (SAFE_PROPS_SET.has(prop)) {
1100
- el.style.setProperty(prop, value, "important");
1101
- }
1144
+ const parts = [];
1145
+ for (const ruleText of hoverFocusRules) {
1146
+ const braceIdx = ruleText.indexOf("{");
1147
+ if (braceIdx === -1) continue;
1148
+ const selector = ruleText.slice(0, braceIdx).trim();
1149
+ const bodyEnd = ruleText.lastIndexOf("}");
1150
+ const body = ruleText.slice(braceIdx + 1, bodyEnd).trim();
1151
+ if (!body) continue;
1152
+ const newBody = body.replace(
1153
+ /([^;:]+):\s*([^;]+)(;|$)/g,
1154
+ (m, prop, val, end) => {
1155
+ if (val.includes("!important")) return m;
1156
+ return prop + ": " + val.trim() + " !important" + end;
1102
1157
  }
1158
+ );
1159
+ if (selector.includes(":hover")) {
1160
+ parts.push(selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }");
1103
1161
  }
1104
- if (snap.__attrs) {
1105
- for (const [attr, value] of Object.entries(snap.__attrs)) {
1106
- if (attr === "checked") {
1107
- ;
1108
- el.checked = value === "true";
1109
- } else if (attr === "class" && value != null) {
1110
- el.className = value;
1111
- } else if (attr === "style") {
1112
- } else if (attr === "value" && value != null) {
1113
- ;
1114
- el.value = value;
1115
- } else if (value == null) {
1116
- el.removeAttribute(attr);
1117
- } else {
1118
- el.setAttribute(attr, value);
1119
- }
1120
- }
1162
+ if (selector.includes(":focus-visible")) {
1163
+ parts.push(selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }");
1164
+ } else if (selector.includes(":focus-within")) {
1165
+ parts.push(selector.replace(/:focus-within/g, ":has([data-lapse-focus])") + " { " + newBody + " }");
1166
+ } else if (selector.includes(":focus")) {
1167
+ parts.push(selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }");
1121
1168
  }
1122
1169
  }
1170
+ lapseStyle.textContent = parts.join("\n");
1171
+ document.head.appendChild(lapseStyle);
1172
+ this.lapseStyleEl = lapseStyle;
1173
+ } catch (_) {
1174
+ }
1175
+ }, 0);
1176
+ this.seekableClones.clear();
1177
+ for (const [animId, animInfo] of this.animations) {
1178
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1179
+ if (animInfo.type === "JSAnimation") continue;
1180
+ const firstColon = animId.indexOf(":");
1181
+ const secondColon = animId.indexOf(":", firstColon + 1);
1182
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1183
+ const el = this.elements.get(elSelector);
1184
+ if (!el?.isConnected) continue;
1185
+ try {
1186
+ const clone = el.animate(animInfo.rawKeyframes, {
1187
+ ...animInfo.rawTiming,
1188
+ fill: "both"
1189
+ });
1190
+ clone.pause();
1191
+ clone.currentTime = 0;
1192
+ this.seekableClones.set(animId, {
1193
+ animation: clone,
1194
+ element: el,
1195
+ effect: clone.effect
1196
+ });
1197
+ } catch (_) {
1123
1198
  }
1124
1199
  }
1125
1200
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1142,6 +1217,8 @@ var TimelineRecorder = _TimelineRecorder;
1142
1217
  // src/core/scrubber.ts
1143
1218
  var TimelineScrubber = class {
1144
1219
  constructor(state) {
1220
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1221
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1145
1222
  /** Saved originals for restore on release */
1146
1223
  this._originalAnimate = null;
1147
1224
  this._originalRaf = null;
@@ -1151,36 +1228,24 @@ var TimelineScrubber = class {
1151
1228
  this.frames = state.frames;
1152
1229
  this.capturedPortals = state.capturedPortals;
1153
1230
  this.interceptedAnimations = state.interceptedAnimations;
1154
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1231
+ this.seekableClones = state.seekableClones;
1155
1232
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1156
1233
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1157
1234
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1158
1235
  this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1159
- }
1160
- // ---------------------------------------------------------------------------
1161
- // Selector helper mirrors the recorder's getSelector so we can look up
1162
- // elements by the same key the recorder used in frame.animations[].animationId
1163
- // ---------------------------------------------------------------------------
1164
- getSelector(el) {
1165
- if (!el || !el.tagName) return null;
1166
- const parts = [];
1167
- let current = el;
1168
- for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
1169
- const tag = current.tagName.toLowerCase();
1170
- const parent = current.parentElement;
1171
- if (parent) {
1172
- const siblings = Array.from(parent.children);
1173
- const idx = siblings.indexOf(current) + 1;
1174
- parts.unshift(`${tag}:nth-child(${idx})`);
1175
- } else {
1176
- parts.unshift(tag);
1236
+ for (let i = 0; i < this.frames.length; i++) {
1237
+ for (const fa of this.frames[i].animations) {
1238
+ const range = this.animFrameRanges.get(fa.animationId);
1239
+ if (!range) {
1240
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1241
+ } else {
1242
+ range.last = i;
1243
+ }
1177
1244
  }
1178
- current = parent;
1179
1245
  }
1180
- return parts.join(" > ");
1181
1246
  }
1182
1247
  // ---------------------------------------------------------------------------
1183
- // seekTo — scrub the DOM to match a specific timestamp
1248
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1184
1249
  // ---------------------------------------------------------------------------
1185
1250
  seekTo(timeMs) {
1186
1251
  if (!this.frames.length) return;
@@ -1228,6 +1293,34 @@ var TimelineScrubber = class {
1228
1293
  }
1229
1294
  }
1230
1295
  }
1296
+ const activeAnimIds = /* @__PURE__ */ new Map();
1297
+ for (const fa of frame.animations || []) {
1298
+ activeAnimIds.set(fa.animationId, fa);
1299
+ }
1300
+ for (const [animId, clone] of this.seekableClones) {
1301
+ const frameAnim = activeAnimIds.get(animId);
1302
+ try {
1303
+ if (frameAnim) {
1304
+ if (!clone.animation.effect) {
1305
+ clone.animation.effect = clone.effect;
1306
+ }
1307
+ clone.animation.currentTime = frameAnim.currentTime;
1308
+ } else {
1309
+ const range = this.animFrameRanges.get(animId);
1310
+ if (!range || lo < range.first) {
1311
+ clone.animation.effect = null;
1312
+ } else {
1313
+ if (!clone.animation.effect) {
1314
+ clone.animation.effect = clone.effect;
1315
+ }
1316
+ const timing = clone.effect.getTiming();
1317
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1318
+ clone.animation.currentTime = endTime;
1319
+ }
1320
+ }
1321
+ } catch {
1322
+ }
1323
+ }
1231
1324
  for (const entry of this.interceptedAnimations) {
1232
1325
  try {
1233
1326
  const anim = entry.animation;
@@ -1242,34 +1335,13 @@ var TimelineScrubber = class {
1242
1335
  const el = this.elements.get(sel);
1243
1336
  if (!el || !el.isConnected) continue;
1244
1337
  if (el.closest?.("[data-lapse-panel]")) continue;
1245
- const hasAnimation = (frame.animations || []).some(
1246
- (a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
1247
- );
1248
1338
  const snapTyped = snap;
1249
- if (snapTyped.__styles) {
1250
- for (const [prop, value] of Object.entries(snapTyped.__styles)) {
1251
- if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
1252
- el.style.setProperty(prop, value, "important");
1253
- }
1254
- }
1255
- }
1256
1339
  if (snapTyped.__attrs) {
1257
1340
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1341
+ if (attr === "class" || attr === "style") continue;
1258
1342
  if (attr === "checked") {
1259
1343
  ;
1260
1344
  el.checked = value === "true";
1261
- } else if (attr === "class") {
1262
- if (value != null) el.className = value;
1263
- } else if (attr === "style") {
1264
- if (value) {
1265
- el.setAttribute("style", value);
1266
- el.style.transition = "none";
1267
- if (snapTyped.__styles) {
1268
- for (const [prop, val] of Object.entries(snapTyped.__styles)) {
1269
- el.style.setProperty(prop, val, "important");
1270
- }
1271
- }
1272
- }
1273
1345
  } else if (attr === "value") {
1274
1346
  if (value != null) el.value = value;
1275
1347
  } else if (value == null) {
@@ -1280,39 +1352,31 @@ var TimelineScrubber = class {
1280
1352
  }
1281
1353
  }
1282
1354
  }
1283
- for (const anim of frame.animations || []) {
1284
- const firstColon = anim.animationId.indexOf(":");
1285
- const secondColon = anim.animationId.indexOf(":", firstColon + 1);
1286
- const animSel = secondColon >= 0 ? anim.animationId.substring(secondColon + 1) : "";
1287
- const animEl = this.elements.get(animSel);
1288
- if (!animEl || !animEl.isConnected) continue;
1289
- for (const prop of anim.properties || []) {
1355
+ for (const fa of frame.animations || []) {
1356
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1357
+ const firstColon = fa.animationId.indexOf(":");
1358
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1359
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1360
+ const el = this.elements.get(elSel);
1361
+ if (!el || !el.isConnected) continue;
1362
+ for (const prop of fa.properties) {
1290
1363
  if (prop.value) {
1291
- animEl.style.setProperty(prop.property, prop.value, "important");
1364
+ el.style.setProperty(prop.property, prop.value, "important");
1292
1365
  }
1293
1366
  }
1294
1367
  }
1295
- const animatedSels = /* @__PURE__ */ new Set();
1296
- for (const anim of frame.animations || []) {
1297
- const fc = anim.animationId.indexOf(":");
1298
- const sc = anim.animationId.indexOf(":", fc + 1);
1299
- if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
1300
- }
1301
- document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
1302
- const el = rawEl;
1303
- const sel = this.getSelector(el);
1304
- if (sel && !animatedSels.has(sel)) {
1305
- el.style.removeProperty("opacity");
1306
- el.style.removeProperty("transform");
1307
- el.style.removeProperty("filter");
1308
- el.style.removeProperty("stroke-dashoffset");
1309
- }
1310
- });
1311
1368
  }
1312
1369
  // ---------------------------------------------------------------------------
1313
1370
  // release — tear down all scrub state and restore the page to normal
1314
1371
  // ---------------------------------------------------------------------------
1315
1372
  release() {
1373
+ for (const [, clone] of this.seekableClones) {
1374
+ try {
1375
+ clone.animation.cancel();
1376
+ } catch {
1377
+ }
1378
+ }
1379
+ this.seekableClones.clear();
1316
1380
  for (const entry of this.interceptedAnimations) {
1317
1381
  try {
1318
1382
  entry.animation.cancel();
@@ -1374,6 +1438,7 @@ var TimelineScrubber = class {
1374
1438
  this.elements.clear();
1375
1439
  this.frames.length = 0;
1376
1440
  this.capturedPortals.clear();
1441
+ this.animFrameRanges.clear();
1377
1442
  }
1378
1443
  };
1379
1444
 
@@ -1600,7 +1665,7 @@ function formatExportForLLM(exp, detail = "standard") {
1600
1665
  }
1601
1666
 
1602
1667
  // src/core/engine.ts
1603
- var LapseEngine = class {
1668
+ var SaccadeEngine = class {
1604
1669
  constructor() {
1605
1670
  this.timing = new TimingController();
1606
1671
  this.recorder = new TimelineRecorder();
@@ -1642,14 +1707,33 @@ var LapseEngine = class {
1642
1707
  boundingBox: null
1643
1708
  };
1644
1709
  }
1645
- const capture = this.recorder.stopRecording();
1710
+ let capture;
1711
+ try {
1712
+ capture = this.recorder.stopRecording();
1713
+ } catch (e) {
1714
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1715
+ document.getElementById("__lapse-scrub-blocker")?.remove();
1716
+ document.getElementById("__lapse-no-transitions")?.remove();
1717
+ document.getElementById("__lapse-state-rules")?.remove();
1718
+ this._state = "idle";
1719
+ this.notify();
1720
+ return {
1721
+ startTime: 0,
1722
+ endTime: 0,
1723
+ duration: 0,
1724
+ animations: [],
1725
+ frames: [],
1726
+ boundingBox: null
1727
+ };
1728
+ }
1646
1729
  this.capture = capture;
1647
1730
  const scrubberState = {
1648
1731
  elements: this.recorder.elements,
1649
1732
  frames: capture.frames,
1650
1733
  capturedPortals: this.recorder.capturedPortalIds,
1651
1734
  interceptedAnimations: this.recorder.interceptedAnimations,
1652
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1735
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1736
+ seekableClones: this.recorder.seekableClones
1653
1737
  };
1654
1738
  this.scrubber = new TimelineScrubber(scrubberState);
1655
1739
  this._state = "scrubbing";
@@ -1702,23 +1786,23 @@ var LapseEngine = class {
1702
1786
  }
1703
1787
  };
1704
1788
 
1705
- // src/react/LapseContext.tsx
1789
+ // src/react/SaccadeContext.tsx
1706
1790
  import { jsx } from "react/jsx-runtime";
1707
- var LapseContext = createContext(null);
1708
- function LapseProvider({ children }) {
1791
+ var SaccadeContext = createContext(null);
1792
+ function SaccadeProvider({ children }) {
1709
1793
  const engineRef = useRef(null);
1710
1794
  if (!engineRef.current) {
1711
- engineRef.current = new LapseEngine();
1795
+ engineRef.current = new SaccadeEngine();
1712
1796
  }
1713
- return /* @__PURE__ */ jsx(LapseContext.Provider, { value: engineRef.current, children });
1797
+ return /* @__PURE__ */ jsx(SaccadeContext.Provider, { value: engineRef.current, children });
1714
1798
  }
1715
- function useLapseEngine() {
1716
- const engine = useContext(LapseContext);
1717
- if (!engine) throw new Error("useLapseEngine must be used within <LapseProvider>");
1799
+ function useSaccadeEngine() {
1800
+ const engine = useContext(SaccadeContext);
1801
+ if (!engine) throw new Error("useSaccadeEngine must be used within <SaccadeProvider>");
1718
1802
  return engine;
1719
1803
  }
1720
1804
 
1721
- // src/react/LapsePanel.tsx
1805
+ // src/react/SaccadePanel.tsx
1722
1806
  import { useRef as useRef5, useCallback as useCallback5 } from "react";
1723
1807
 
1724
1808
  // src/react/Timeline.tsx
@@ -2112,7 +2196,7 @@ function SpeedControl({ speed, isPaused, onSetSpeed, onTogglePause }) {
2112
2196
  import { useState as useState2, useCallback as useCallback3, useEffect as useEffect2, useRef as useRef4, useSyncExternalStore } from "react";
2113
2197
  var DETAIL_LEVELS = ["compact", "standard", "detailed", "forensic"];
2114
2198
  function useTimeline() {
2115
- const engine = useLapseEngine();
2199
+ const engine = useSaccadeEngine();
2116
2200
  const state = useSyncExternalStore(
2117
2201
  (cb) => engine.subscribe(cb),
2118
2202
  () => engine.state
@@ -2146,9 +2230,13 @@ function useTimeline() {
2146
2230
  [engine]
2147
2231
  );
2148
2232
  const stopRecording = useCallback3(() => {
2149
- const result = engine.stopRecording();
2150
- setCapture(result);
2151
- setScrubTime(0);
2233
+ try {
2234
+ const result = engine.stopRecording();
2235
+ setCapture(result);
2236
+ setScrubTime(0);
2237
+ } catch (e) {
2238
+ console.error("[Saccade] stopRecording failed:", e);
2239
+ }
2152
2240
  }, [engine]);
2153
2241
  const seek = useCallback3(
2154
2242
  (timeMs) => {
@@ -2217,7 +2305,7 @@ function useTimeline() {
2217
2305
  // src/react/useSpeed.ts
2218
2306
  import { useState as useState3, useCallback as useCallback4, useEffect as useEffect3 } from "react";
2219
2307
  function useSpeed() {
2220
- const engine = useLapseEngine();
2308
+ const engine = useSaccadeEngine();
2221
2309
  const [speed, setSpeedState] = useState3(1);
2222
2310
  const [previousSpeed, setPreviousSpeed] = useState3(1);
2223
2311
  const [isPaused, setIsPaused] = useState3(false);
@@ -2280,9 +2368,9 @@ function useSpeed() {
2280
2368
  return { speed, isPaused, setSpeed, togglePause };
2281
2369
  }
2282
2370
 
2283
- // src/react/LapsePanel.tsx
2371
+ // src/react/SaccadePanel.tsx
2284
2372
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
2285
- function LapsePanel() {
2373
+ function SaccadePanel() {
2286
2374
  const timeline = useTimeline();
2287
2375
  const { speed, isPaused, setSpeed, togglePause } = useSpeed();
2288
2376
  const panelRef = useRef5(null);
@@ -2742,17 +2830,18 @@ var PANEL_STYLES = (
2742
2830
  `
2743
2831
  );
2744
2832
 
2745
- // src/react/Lapse.tsx
2833
+ // src/react/Saccade.tsx
2746
2834
  import { jsx as jsx5 } from "react/jsx-runtime";
2747
- function Lapse({ position = "bottom-left" }) {
2748
- const hostRef = useRef6(null);
2835
+ function Saccade({ position = "bottom-left" }) {
2749
2836
  const [shadowRoot, setShadowRoot] = useState4(null);
2837
+ const hostRef = useRef6(null);
2750
2838
  useEffect4(() => {
2751
- const host = hostRef.current;
2752
- if (!host || host.shadowRoot) {
2753
- if (host?.shadowRoot) setShadowRoot(host.shadowRoot);
2754
- return;
2755
- }
2839
+ const host = document.createElement("div");
2840
+ host.setAttribute("data-lapse-panel", "");
2841
+ const positionOffset = position === "top-left" ? "top:20px;left:20px" : position === "top-right" ? "top:20px;right:20px" : position === "bottom-left" ? "bottom:20px;left:20px" : "bottom:20px;right:20px";
2842
+ host.style.cssText = `position:fixed;z-index:2147483647;pointer-events:auto;${positionOffset}`;
2843
+ document.body.appendChild(host);
2844
+ hostRef.current = host;
2756
2845
  const shadow = host.attachShadow({ mode: "open" });
2757
2846
  const style = document.createElement("style");
2758
2847
  style.textContent = PANEL_STYLES;
@@ -2760,32 +2849,22 @@ function Lapse({ position = "bottom-left" }) {
2760
2849
  const mount = document.createElement("div");
2761
2850
  shadow.appendChild(mount);
2762
2851
  setShadowRoot(shadow);
2763
- }, []);
2764
- 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 };
2765
- return /* @__PURE__ */ jsx5(
2766
- "div",
2767
- {
2768
- ref: hostRef,
2769
- "data-lapse-panel": "",
2770
- style: {
2771
- position: "fixed",
2772
- zIndex: 2147483647,
2773
- // max int — must sit above the scrub blocker (z-index: 999999)
2774
- pointerEvents: "auto",
2775
- ...positionOffset
2776
- },
2777
- children: shadowRoot && createPortal(
2778
- /* @__PURE__ */ jsx5(LapseProvider, { children: /* @__PURE__ */ jsx5(LapsePanel, {}) }),
2779
- shadowRoot.lastElementChild || shadowRoot
2780
- )
2781
- }
2852
+ return () => {
2853
+ host.remove();
2854
+ hostRef.current = null;
2855
+ };
2856
+ }, [position]);
2857
+ if (!shadowRoot) return null;
2858
+ return createPortal(
2859
+ /* @__PURE__ */ jsx5(SaccadeProvider, { children: /* @__PURE__ */ jsx5(SaccadePanel, {}) }),
2860
+ shadowRoot.lastElementChild || shadowRoot
2782
2861
  );
2783
2862
  }
2784
2863
  export {
2785
- Lapse,
2786
- LapseEngine,
2787
- LapseProvider,
2788
- useLapseEngine,
2864
+ Saccade,
2865
+ SaccadeEngine,
2866
+ SaccadeProvider,
2867
+ useSaccadeEngine,
2789
2868
  useSpeed,
2790
2869
  useTimeline
2791
2870
  };