saccade 0.0.3 → 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.cjs CHANGED
@@ -38,16 +38,21 @@ var import_react_dom = require("react-dom");
38
38
  var import_react = require("react");
39
39
 
40
40
  // src/core/timing.ts
41
+ var MEDIA_RATE_MIN = 0.0625;
42
+ var MEDIA_RATE_MAX = 16;
43
+ var ZERO_SPEED_DIVISOR = 1e-4;
41
44
  var TimingController = class {
42
45
  constructor() {
43
46
  this.speed = 1;
44
47
  this.virtualBaseline = 0;
45
48
  this.intervalMap = /* @__PURE__ */ new Map();
46
49
  this.nextIntervalId = 1e6;
47
- this.mediaObserver = null;
48
- this.animObserver = null;
49
50
  this._origAnimate = null;
50
51
  this.installed = false;
52
+ this.animPollId = 0;
53
+ // WeakMap tracking for animations and media
54
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
55
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
51
56
  this._raf = requestAnimationFrame.bind(window);
52
57
  this._caf = cancelAnimationFrame.bind(window);
53
58
  this._setTimeout = setTimeout.bind(window);
@@ -57,19 +62,34 @@ var TimingController = class {
57
62
  this._perfNow = performance.now.bind(performance);
58
63
  this._dateNow = Date.now;
59
64
  this.realBaseline = this._perfNow();
65
+ this.dateRealBaseline = this._dateNow();
66
+ this.dateVirtualBaseline = this.dateRealBaseline;
60
67
  }
61
68
  getVirtualTime() {
62
69
  const realElapsed = this._perfNow() - this.realBaseline;
63
70
  return this.virtualBaseline + realElapsed * this.speed;
64
71
  }
72
+ getVirtualDateNow() {
73
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
74
+ return this.dateVirtualBaseline + realElapsed * this.speed;
75
+ }
65
76
  reanchor() {
66
77
  const virtualNow = this.getVirtualTime();
67
78
  this.realBaseline = this._perfNow();
68
79
  this.virtualBaseline = virtualNow;
80
+ const virtualDateNow = this.getVirtualDateNow();
81
+ this.dateRealBaseline = this._dateNow();
82
+ this.dateVirtualBaseline = virtualDateNow;
83
+ }
84
+ /** Effective speed divisor — avoids division by zero at speed=0. */
85
+ get speedDivisor() {
86
+ return this.speed || ZERO_SPEED_DIVISOR;
69
87
  }
70
88
  /** Install timing patches. Safe to call multiple times. */
71
89
  install() {
72
90
  if (this.installed) return;
91
+ if (window.__saccadeInstalled) return;
92
+ window.__saccadeInstalled = true;
73
93
  this.installed = true;
74
94
  const self = this;
75
95
  window.__LAPSE_ORIGINAL_RAF__ = this._raf;
@@ -78,21 +98,27 @@ var TimingController = class {
78
98
  Element.prototype.animate = function(...args) {
79
99
  const anim = origAnimate.apply(this, args);
80
100
  if (self.speed !== 1) {
81
- anim.playbackRate = self.speed || 1e-3;
101
+ const originalRate = anim.playbackRate;
102
+ const applied = originalRate * (self.speed || 1e-3);
103
+ anim.playbackRate = applied;
104
+ self.trackedAnims.set(anim, { original: originalRate, applied });
82
105
  }
83
106
  return anim;
84
107
  };
85
108
  performance.now = () => self.getVirtualTime();
86
- const dateBaseline = this._dateNow();
87
- Date.now = () => dateBaseline + self.getVirtualTime();
109
+ Date.now = () => self.getVirtualDateNow();
88
110
  window.requestAnimationFrame = (callback) => {
89
111
  return self._raf(() => {
112
+ if (self.speed === 0) {
113
+ window.requestAnimationFrame(callback);
114
+ return;
115
+ }
90
116
  callback(self.getVirtualTime());
91
117
  });
92
118
  };
93
119
  window.cancelAnimationFrame = this._caf;
94
120
  window.setTimeout = ((handler, delay, ...args) => {
95
- const scaledDelay = (delay ?? 0) / (self.speed || 1);
121
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
96
122
  return self._setTimeout(handler, scaledDelay, ...args);
97
123
  });
98
124
  window.clearTimeout = this._clearTimeout;
@@ -100,7 +126,7 @@ var TimingController = class {
100
126
  const id = self.nextIntervalId++;
101
127
  const baseDelay = delay ?? 0;
102
128
  function tick() {
103
- const scaledDelay = baseDelay / (self.speed || 1);
129
+ const scaledDelay = baseDelay / self.speedDivisor;
104
130
  const realId = self._setTimeout(() => {
105
131
  if (typeof handler === "function") {
106
132
  ;
@@ -125,61 +151,93 @@ var TimingController = class {
125
151
  self._clearInterval(id);
126
152
  }
127
153
  });
128
- this.mediaObserver = new MutationObserver((mutations) => {
129
- for (const mutation of mutations) {
130
- for (const node of mutation.addedNodes) {
131
- if (node instanceof HTMLVideoElement || node instanceof HTMLAudioElement) {
132
- node.playbackRate = self.speed;
133
- }
134
- }
135
- }
136
- });
137
- if (document.body) {
138
- this.mediaObserver.observe(document.body, { childList: true, subtree: true });
139
- }
154
+ this.startAnimationPoll();
140
155
  }
141
156
  /** Set playback speed. Requires install() first. */
142
157
  setSpeed(newSpeed) {
143
158
  if (!this.installed) this.install();
144
159
  this.reanchor();
145
160
  this.speed = newSpeed;
146
- document.querySelectorAll("video, audio").forEach((el) => {
147
- ;
148
- el.playbackRate = newSpeed;
149
- });
150
161
  this.patchAnimations();
162
+ this.patchMedia();
163
+ this.patchGSAP();
164
+ }
165
+ getSpeed() {
166
+ return this.speed;
151
167
  }
152
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
168
+ // ---------------------------------------------------------------------------
169
+ // Animation polling — per-frame via original rAF
170
+ // ---------------------------------------------------------------------------
171
+ startAnimationPoll() {
172
+ const poll = () => {
173
+ if (!this.installed) return;
174
+ this.patchAnimations();
175
+ this.patchMedia();
176
+ this.animPollId = this._raf(poll);
177
+ };
178
+ this.animPollId = this._raf(poll);
179
+ }
180
+ /** Patch playbackRate on all active animations via WAAPI. */
153
181
  patchAnimations() {
154
182
  try {
155
183
  const anims = document.getAnimations();
156
184
  for (const anim of anims) {
157
185
  const target = anim.effect?.target;
158
186
  if (target?.closest?.("[data-lapse-panel]")) continue;
159
- anim.playbackRate = this.speed || 1e-3;
187
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
188
+ const effectiveSpeed = this.speed || 1e-3;
189
+ let tracked = this.trackedAnims.get(anim);
190
+ if (!tracked) {
191
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
192
+ this.trackedAnims.set(anim, tracked);
193
+ } else if (anim.playbackRate !== tracked.applied) {
194
+ tracked.original = anim.playbackRate;
195
+ }
196
+ const desired = tracked.original * effectiveSpeed;
197
+ if (anim.playbackRate !== desired) {
198
+ anim.playbackRate = desired;
199
+ tracked.applied = desired;
200
+ }
160
201
  }
161
202
  } catch {
162
203
  }
163
- if (!this.animObserver) {
164
- this.animObserver = this._setInterval(() => {
165
- if (!this.installed) return;
166
- try {
167
- const anims = document.getAnimations();
168
- for (const anim of anims) {
169
- const target = anim.effect?.target;
170
- if (target?.closest?.("[data-lapse-panel]")) continue;
171
- if (anim.playbackRate !== this.speed) {
172
- anim.playbackRate = this.speed || 1e-3;
173
- }
174
- }
175
- } catch {
204
+ }
205
+ /** Patch playbackRate on all video/audio elements. */
206
+ patchMedia() {
207
+ try {
208
+ document.querySelectorAll("video, audio").forEach((node) => {
209
+ const el = node;
210
+ if (el.closest?.("[data-lapse-panel]")) return;
211
+ if (el.closest?.("[data-saccade-exclude]")) return;
212
+ let tracked = this.trackedMedia.get(el);
213
+ if (!tracked) {
214
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
215
+ this.trackedMedia.set(el, tracked);
216
+ } else if (el.playbackRate !== tracked.applied) {
217
+ tracked.original = el.playbackRate;
218
+ }
219
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
220
+ if (el.playbackRate !== desired) {
221
+ el.playbackRate = desired;
222
+ tracked.applied = desired;
176
223
  }
177
- }, 100);
224
+ });
225
+ } catch {
178
226
  }
179
227
  }
180
- getSpeed() {
181
- return this.speed;
228
+ /** Sync GSAP's global timeline if present. */
229
+ patchGSAP() {
230
+ try {
231
+ const gsap = window.gsap;
232
+ if (gsap?.globalTimeline) {
233
+ gsap.globalTimeline.timeScale(this.speed || 1e-3);
234
+ }
235
+ } catch {
236
+ }
182
237
  }
238
+ // ---------------------------------------------------------------------------
239
+ // Cleanup
240
+ // ---------------------------------------------------------------------------
183
241
  /** Restore all patched APIs to originals. */
184
242
  destroy() {
185
243
  if (!this.installed) return;
@@ -199,19 +257,32 @@ var TimingController = class {
199
257
  Element.prototype.animate = this._origAnimate;
200
258
  this._origAnimate = null;
201
259
  }
202
- this.mediaObserver?.disconnect();
203
- this.mediaObserver = null;
204
- if (this.animObserver != null) {
205
- this._clearInterval(this.animObserver);
206
- this.animObserver = null;
260
+ if (this.animPollId) {
261
+ this._caf(this.animPollId);
262
+ this.animPollId = 0;
207
263
  }
208
264
  try {
209
265
  for (const anim of document.getAnimations()) {
210
- anim.playbackRate = 1;
266
+ const tracked = this.trackedAnims.get(anim);
267
+ anim.playbackRate = tracked?.original ?? 1;
211
268
  }
212
269
  } catch {
213
270
  }
271
+ try {
272
+ document.querySelectorAll("video, audio").forEach((node) => {
273
+ const el = node;
274
+ const tracked = this.trackedMedia.get(el);
275
+ el.playbackRate = tracked?.original ?? 1;
276
+ });
277
+ } catch {
278
+ }
279
+ try {
280
+ const gsap = window.gsap;
281
+ if (gsap?.globalTimeline) gsap.globalTimeline.timeScale(1);
282
+ } catch {
283
+ }
214
284
  delete window.__LAPSE_ORIGINAL_RAF__;
285
+ delete window.__saccadeInstalled;
215
286
  this.installed = false;
216
287
  }
217
288
  };
@@ -281,6 +352,10 @@ var SNAPSHOT_ATTRS = [
281
352
  "data-hover",
282
353
  "data-at-boundary",
283
354
  "data-scrubbing",
355
+ "data-starting-style",
356
+ "data-ending-style",
357
+ "data-panel-open",
358
+ "data-hidden",
284
359
  "aria-checked",
285
360
  "aria-selected",
286
361
  "aria-expanded",
@@ -294,6 +369,7 @@ var SNAPSHOT_ATTRS = [
294
369
  "checked",
295
370
  "disabled",
296
371
  "hidden",
372
+ "inert",
297
373
  "value",
298
374
  "class",
299
375
  "style"
@@ -425,6 +501,8 @@ var _TimelineRecorder = class _TimelineRecorder {
425
501
  // ---- WAAPI interception --------------------------------------------------
426
502
  /** Animations captured via Element.prototype.animate monkey-patch. */
427
503
  this.interceptedAnimations = [];
504
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
505
+ this.seekableClones = /* @__PURE__ */ new Map();
428
506
  this.hiddenSince = null;
429
507
  this.onVisibilityChange = null;
430
508
  /** Set to true when the capture loop self-terminates due to limits. */
@@ -475,6 +553,7 @@ var _TimelineRecorder = class _TimelineRecorder {
475
553
  this.portalIdCounter = 0;
476
554
  this.currentPortalIds.clear();
477
555
  this.capturedPortals.clear();
556
+ this.seekableClones.clear();
478
557
  this.prevInlineStyles.clear();
479
558
  this.jsAnimStartTimes.clear();
480
559
  this.jsAnimLastSeen.clear();
@@ -828,6 +907,13 @@ var _TimelineRecorder = class _TimelineRecorder {
828
907
  } catch (_) {
829
908
  }
830
909
  }
910
+ const cleanedKeyframes = keyframes2.map((kf) => {
911
+ const clean = {};
912
+ for (const [k, v] of Object.entries(kf)) {
913
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
914
+ }
915
+ return clean;
916
+ });
831
917
  this.animations.set(id, {
832
918
  id,
833
919
  name,
@@ -839,7 +925,9 @@ var _TimelineRecorder = class _TimelineRecorder {
839
925
  type,
840
926
  source,
841
927
  resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
842
- conflicts
928
+ conflicts,
929
+ rawKeyframes: cleanedKeyframes,
930
+ rawTiming: { ...timing, fill: "both" }
843
931
  });
844
932
  }
845
933
  const keyframes = a.effect?.getKeyframes?.() || [];
@@ -1115,38 +1203,28 @@ var _TimelineRecorder = class _TimelineRecorder {
1115
1203
  } catch (_) {
1116
1204
  }
1117
1205
  }, 0);
1118
- if (this.frames.length > 0) {
1119
- const frame0 = this.frames[0];
1120
- if (frame0.elementSnapshots) {
1121
- for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
1122
- const el = this.elements.get(sel);
1123
- if (!el || !el.isConnected) continue;
1124
- if (snap.__styles) {
1125
- for (const [prop, value] of Object.entries(snap.__styles)) {
1126
- if (SAFE_PROPS_SET.has(prop)) {
1127
- el.style.setProperty(prop, value, "important");
1128
- }
1129
- }
1130
- }
1131
- if (snap.__attrs) {
1132
- for (const [attr, value] of Object.entries(snap.__attrs)) {
1133
- if (attr === "checked") {
1134
- ;
1135
- el.checked = value === "true";
1136
- } else if (attr === "class" && value != null) {
1137
- el.className = value;
1138
- } else if (attr === "style") {
1139
- } else if (attr === "value" && value != null) {
1140
- ;
1141
- el.value = value;
1142
- } else if (value == null) {
1143
- el.removeAttribute(attr);
1144
- } else {
1145
- el.setAttribute(attr, value);
1146
- }
1147
- }
1148
- }
1149
- }
1206
+ this.seekableClones.clear();
1207
+ for (const [animId, animInfo] of this.animations) {
1208
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1209
+ if (animInfo.type === "JSAnimation") continue;
1210
+ const firstColon = animId.indexOf(":");
1211
+ const secondColon = animId.indexOf(":", firstColon + 1);
1212
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1213
+ const el = this.elements.get(elSelector);
1214
+ if (!el?.isConnected) continue;
1215
+ try {
1216
+ const clone = el.animate(animInfo.rawKeyframes, {
1217
+ ...animInfo.rawTiming,
1218
+ fill: "both"
1219
+ });
1220
+ clone.pause();
1221
+ clone.currentTime = 0;
1222
+ this.seekableClones.set(animId, {
1223
+ animation: clone,
1224
+ element: el,
1225
+ effect: clone.effect
1226
+ });
1227
+ } catch (_) {
1150
1228
  }
1151
1229
  }
1152
1230
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1169,6 +1247,8 @@ var TimelineRecorder = _TimelineRecorder;
1169
1247
  // src/core/scrubber.ts
1170
1248
  var TimelineScrubber = class {
1171
1249
  constructor(state) {
1250
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1251
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1172
1252
  /** Saved originals for restore on release */
1173
1253
  this._originalAnimate = null;
1174
1254
  this._originalRaf = null;
@@ -1178,36 +1258,24 @@ var TimelineScrubber = class {
1178
1258
  this.frames = state.frames;
1179
1259
  this.capturedPortals = state.capturedPortals;
1180
1260
  this.interceptedAnimations = state.interceptedAnimations;
1181
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1261
+ this.seekableClones = state.seekableClones;
1182
1262
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1183
1263
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1184
1264
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1185
1265
  this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1186
- }
1187
- // ---------------------------------------------------------------------------
1188
- // Selector helper mirrors the recorder's getSelector so we can look up
1189
- // elements by the same key the recorder used in frame.animations[].animationId
1190
- // ---------------------------------------------------------------------------
1191
- getSelector(el) {
1192
- if (!el || !el.tagName) return null;
1193
- const parts = [];
1194
- let current = el;
1195
- for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
1196
- const tag = current.tagName.toLowerCase();
1197
- const parent = current.parentElement;
1198
- if (parent) {
1199
- const siblings = Array.from(parent.children);
1200
- const idx = siblings.indexOf(current) + 1;
1201
- parts.unshift(`${tag}:nth-child(${idx})`);
1202
- } else {
1203
- parts.unshift(tag);
1266
+ for (let i = 0; i < this.frames.length; i++) {
1267
+ for (const fa of this.frames[i].animations) {
1268
+ const range = this.animFrameRanges.get(fa.animationId);
1269
+ if (!range) {
1270
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1271
+ } else {
1272
+ range.last = i;
1273
+ }
1204
1274
  }
1205
- current = parent;
1206
1275
  }
1207
- return parts.join(" > ");
1208
1276
  }
1209
1277
  // ---------------------------------------------------------------------------
1210
- // seekTo — scrub the DOM to match a specific timestamp
1278
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1211
1279
  // ---------------------------------------------------------------------------
1212
1280
  seekTo(timeMs) {
1213
1281
  if (!this.frames.length) return;
@@ -1255,6 +1323,34 @@ var TimelineScrubber = class {
1255
1323
  }
1256
1324
  }
1257
1325
  }
1326
+ const activeAnimIds = /* @__PURE__ */ new Map();
1327
+ for (const fa of frame.animations || []) {
1328
+ activeAnimIds.set(fa.animationId, fa);
1329
+ }
1330
+ for (const [animId, clone] of this.seekableClones) {
1331
+ const frameAnim = activeAnimIds.get(animId);
1332
+ try {
1333
+ if (frameAnim) {
1334
+ if (!clone.animation.effect) {
1335
+ clone.animation.effect = clone.effect;
1336
+ }
1337
+ clone.animation.currentTime = frameAnim.currentTime;
1338
+ } else {
1339
+ const range = this.animFrameRanges.get(animId);
1340
+ if (!range || lo < range.first) {
1341
+ clone.animation.effect = null;
1342
+ } else {
1343
+ if (!clone.animation.effect) {
1344
+ clone.animation.effect = clone.effect;
1345
+ }
1346
+ const timing = clone.effect.getTiming();
1347
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1348
+ clone.animation.currentTime = endTime;
1349
+ }
1350
+ }
1351
+ } catch {
1352
+ }
1353
+ }
1258
1354
  for (const entry of this.interceptedAnimations) {
1259
1355
  try {
1260
1356
  const anim = entry.animation;
@@ -1269,34 +1365,13 @@ var TimelineScrubber = class {
1269
1365
  const el = this.elements.get(sel);
1270
1366
  if (!el || !el.isConnected) continue;
1271
1367
  if (el.closest?.("[data-lapse-panel]")) continue;
1272
- const hasAnimation = (frame.animations || []).some(
1273
- (a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
1274
- );
1275
1368
  const snapTyped = snap;
1276
- if (snapTyped.__styles) {
1277
- for (const [prop, value] of Object.entries(snapTyped.__styles)) {
1278
- if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
1279
- el.style.setProperty(prop, value, "important");
1280
- }
1281
- }
1282
- }
1283
1369
  if (snapTyped.__attrs) {
1284
1370
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1371
+ if (attr === "class" || attr === "style") continue;
1285
1372
  if (attr === "checked") {
1286
1373
  ;
1287
1374
  el.checked = value === "true";
1288
- } else if (attr === "class") {
1289
- if (value != null) el.className = value;
1290
- } else if (attr === "style") {
1291
- if (value) {
1292
- el.setAttribute("style", value);
1293
- el.style.transition = "none";
1294
- if (snapTyped.__styles) {
1295
- for (const [prop, val] of Object.entries(snapTyped.__styles)) {
1296
- el.style.setProperty(prop, val, "important");
1297
- }
1298
- }
1299
- }
1300
1375
  } else if (attr === "value") {
1301
1376
  if (value != null) el.value = value;
1302
1377
  } else if (value == null) {
@@ -1307,39 +1382,31 @@ var TimelineScrubber = class {
1307
1382
  }
1308
1383
  }
1309
1384
  }
1310
- for (const anim of frame.animations || []) {
1311
- const firstColon = anim.animationId.indexOf(":");
1312
- const secondColon = anim.animationId.indexOf(":", firstColon + 1);
1313
- const animSel = secondColon >= 0 ? anim.animationId.substring(secondColon + 1) : "";
1314
- const animEl = this.elements.get(animSel);
1315
- if (!animEl || !animEl.isConnected) continue;
1316
- for (const prop of anim.properties || []) {
1385
+ for (const fa of frame.animations || []) {
1386
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1387
+ const firstColon = fa.animationId.indexOf(":");
1388
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1389
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1390
+ const el = this.elements.get(elSel);
1391
+ if (!el || !el.isConnected) continue;
1392
+ for (const prop of fa.properties) {
1317
1393
  if (prop.value) {
1318
- animEl.style.setProperty(prop.property, prop.value, "important");
1394
+ el.style.setProperty(prop.property, prop.value, "important");
1319
1395
  }
1320
1396
  }
1321
1397
  }
1322
- const animatedSels = /* @__PURE__ */ new Set();
1323
- for (const anim of frame.animations || []) {
1324
- const fc = anim.animationId.indexOf(":");
1325
- const sc = anim.animationId.indexOf(":", fc + 1);
1326
- if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
1327
- }
1328
- document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
1329
- const el = rawEl;
1330
- const sel = this.getSelector(el);
1331
- if (sel && !animatedSels.has(sel)) {
1332
- el.style.removeProperty("opacity");
1333
- el.style.removeProperty("transform");
1334
- el.style.removeProperty("filter");
1335
- el.style.removeProperty("stroke-dashoffset");
1336
- }
1337
- });
1338
1398
  }
1339
1399
  // ---------------------------------------------------------------------------
1340
1400
  // release — tear down all scrub state and restore the page to normal
1341
1401
  // ---------------------------------------------------------------------------
1342
1402
  release() {
1403
+ for (const [, clone] of this.seekableClones) {
1404
+ try {
1405
+ clone.animation.cancel();
1406
+ } catch {
1407
+ }
1408
+ }
1409
+ this.seekableClones.clear();
1343
1410
  for (const entry of this.interceptedAnimations) {
1344
1411
  try {
1345
1412
  entry.animation.cancel();
@@ -1401,6 +1468,7 @@ var TimelineScrubber = class {
1401
1468
  this.elements.clear();
1402
1469
  this.frames.length = 0;
1403
1470
  this.capturedPortals.clear();
1471
+ this.animFrameRanges.clear();
1404
1472
  }
1405
1473
  };
1406
1474
 
@@ -1673,7 +1741,7 @@ var SaccadeEngine = class {
1673
1741
  try {
1674
1742
  capture = this.recorder.stopRecording();
1675
1743
  } catch (e) {
1676
- console.error("[Saccade] stopRecording failed:", e);
1744
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1677
1745
  document.getElementById("__lapse-scrub-blocker")?.remove();
1678
1746
  document.getElementById("__lapse-no-transitions")?.remove();
1679
1747
  document.getElementById("__lapse-state-rules")?.remove();
@@ -1694,7 +1762,8 @@ var SaccadeEngine = class {
1694
1762
  frames: capture.frames,
1695
1763
  capturedPortals: this.recorder.capturedPortalIds,
1696
1764
  interceptedAnimations: this.recorder.interceptedAnimations,
1697
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1765
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1766
+ seekableClones: this.recorder.seekableClones
1698
1767
  };
1699
1768
  this.scrubber = new TimelineScrubber(scrubberState);
1700
1769
  this._state = "scrubbing";
@@ -2794,14 +2863,15 @@ var PANEL_STYLES = (
2794
2863
  // src/react/Saccade.tsx
2795
2864
  var import_jsx_runtime5 = require("react/jsx-runtime");
2796
2865
  function Saccade({ position = "bottom-left" }) {
2797
- const hostRef = (0, import_react7.useRef)(null);
2798
2866
  const [shadowRoot, setShadowRoot] = (0, import_react7.useState)(null);
2867
+ const hostRef = (0, import_react7.useRef)(null);
2799
2868
  (0, import_react7.useEffect)(() => {
2800
- const host = hostRef.current;
2801
- if (!host || host.shadowRoot) {
2802
- if (host?.shadowRoot) setShadowRoot(host.shadowRoot);
2803
- return;
2804
- }
2869
+ const host = document.createElement("div");
2870
+ host.setAttribute("data-lapse-panel", "");
2871
+ 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";
2872
+ host.style.cssText = `position:fixed;z-index:2147483647;pointer-events:auto;${positionOffset}`;
2873
+ document.body.appendChild(host);
2874
+ hostRef.current = host;
2805
2875
  const shadow = host.attachShadow({ mode: "open" });
2806
2876
  const style = document.createElement("style");
2807
2877
  style.textContent = PANEL_STYLES;
@@ -2809,25 +2879,15 @@ function Saccade({ position = "bottom-left" }) {
2809
2879
  const mount = document.createElement("div");
2810
2880
  shadow.appendChild(mount);
2811
2881
  setShadowRoot(shadow);
2812
- }, []);
2813
- 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 };
2814
- return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
2815
- "div",
2816
- {
2817
- ref: hostRef,
2818
- "data-lapse-panel": "",
2819
- style: {
2820
- position: "fixed",
2821
- zIndex: 2147483647,
2822
- // max int — must sit above the scrub blocker (z-index: 999999)
2823
- pointerEvents: "auto",
2824
- ...positionOffset
2825
- },
2826
- children: shadowRoot && (0, import_react_dom.createPortal)(
2827
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(SaccadeProvider, { children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(SaccadePanel, {}) }),
2828
- shadowRoot.lastElementChild || shadowRoot
2829
- )
2830
- }
2882
+ return () => {
2883
+ host.remove();
2884
+ hostRef.current = null;
2885
+ };
2886
+ }, [position]);
2887
+ if (!shadowRoot) return null;
2888
+ return (0, import_react_dom.createPortal)(
2889
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(SaccadeProvider, { children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(SaccadePanel, {}) }),
2890
+ shadowRoot.lastElementChild || shadowRoot
2831
2891
  );
2832
2892
  }
2833
2893
  // Annotate the CommonJS export names for ESM import in node: