saccade 0.0.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -8,16 +8,22 @@ import { createPortal } from "react-dom";
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
+ this.gsapInstance = null;
24
+ // WeakMap tracking for animations and media
25
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
26
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
21
27
  this._raf = requestAnimationFrame.bind(window);
22
28
  this._caf = cancelAnimationFrame.bind(window);
23
29
  this._setTimeout = setTimeout.bind(window);
@@ -27,19 +33,34 @@ var TimingController = class {
27
33
  this._perfNow = performance.now.bind(performance);
28
34
  this._dateNow = Date.now;
29
35
  this.realBaseline = this._perfNow();
36
+ this.dateRealBaseline = this._dateNow();
37
+ this.dateVirtualBaseline = this.dateRealBaseline;
30
38
  }
31
39
  getVirtualTime() {
32
40
  const realElapsed = this._perfNow() - this.realBaseline;
33
41
  return this.virtualBaseline + realElapsed * this.speed;
34
42
  }
43
+ getVirtualDateNow() {
44
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
45
+ return this.dateVirtualBaseline + realElapsed * this.speed;
46
+ }
35
47
  reanchor() {
36
48
  const virtualNow = this.getVirtualTime();
37
49
  this.realBaseline = this._perfNow();
38
50
  this.virtualBaseline = virtualNow;
51
+ const virtualDateNow = this.getVirtualDateNow();
52
+ this.dateRealBaseline = this._dateNow();
53
+ this.dateVirtualBaseline = virtualDateNow;
54
+ }
55
+ /** Effective speed divisor — avoids division by zero at speed=0. */
56
+ get speedDivisor() {
57
+ return this.speed || ZERO_SPEED_DIVISOR;
39
58
  }
40
59
  /** Install timing patches. Safe to call multiple times. */
41
60
  install() {
42
61
  if (this.installed) return;
62
+ if (window.__saccadeInstalled) return;
63
+ window.__saccadeInstalled = true;
43
64
  this.installed = true;
44
65
  const self = this;
45
66
  window.__LAPSE_ORIGINAL_RAF__ = this._raf;
@@ -48,21 +69,27 @@ var TimingController = class {
48
69
  Element.prototype.animate = function(...args) {
49
70
  const anim = origAnimate.apply(this, args);
50
71
  if (self.speed !== 1) {
51
- anim.playbackRate = self.speed || 1e-3;
72
+ const originalRate = anim.playbackRate;
73
+ const applied = originalRate * (self.speed || 1e-3);
74
+ anim.playbackRate = applied;
75
+ self.trackedAnims.set(anim, { original: originalRate, applied });
52
76
  }
53
77
  return anim;
54
78
  };
55
79
  performance.now = () => self.getVirtualTime();
56
- const dateBaseline = this._dateNow();
57
- Date.now = () => dateBaseline + self.getVirtualTime();
80
+ Date.now = () => self.getVirtualDateNow();
58
81
  window.requestAnimationFrame = (callback) => {
59
82
  return self._raf(() => {
83
+ if (self.speed === 0) {
84
+ window.requestAnimationFrame(callback);
85
+ return;
86
+ }
60
87
  callback(self.getVirtualTime());
61
88
  });
62
89
  };
63
90
  window.cancelAnimationFrame = this._caf;
64
91
  window.setTimeout = ((handler, delay, ...args) => {
65
- const scaledDelay = (delay ?? 0) / (self.speed || 1);
92
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
66
93
  return self._setTimeout(handler, scaledDelay, ...args);
67
94
  });
68
95
  window.clearTimeout = this._clearTimeout;
@@ -70,7 +97,7 @@ var TimingController = class {
70
97
  const id = self.nextIntervalId++;
71
98
  const baseDelay = delay ?? 0;
72
99
  function tick() {
73
- const scaledDelay = baseDelay / (self.speed || 1);
100
+ const scaledDelay = baseDelay / self.speedDivisor;
74
101
  const realId = self._setTimeout(() => {
75
102
  if (typeof handler === "function") {
76
103
  ;
@@ -95,61 +122,99 @@ var TimingController = class {
95
122
  self._clearInterval(id);
96
123
  }
97
124
  });
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
- }
125
+ this.startAnimationPoll();
110
126
  }
111
127
  /** Set playback speed. Requires install() first. */
112
128
  setSpeed(newSpeed) {
113
129
  if (!this.installed) this.install();
114
130
  this.reanchor();
115
131
  this.speed = newSpeed;
116
- document.querySelectorAll("video, audio").forEach((el) => {
117
- ;
118
- el.playbackRate = newSpeed;
119
- });
120
132
  this.patchAnimations();
133
+ this.patchMedia();
134
+ this.patchGSAP();
121
135
  }
122
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
136
+ getSpeed() {
137
+ return this.speed;
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // Animation polling — per-frame via original rAF
141
+ // ---------------------------------------------------------------------------
142
+ startAnimationPoll() {
143
+ const poll = () => {
144
+ if (!this.installed) return;
145
+ this.patchAnimations();
146
+ this.patchMedia();
147
+ this.animPollId = this._raf(poll);
148
+ };
149
+ this.animPollId = this._raf(poll);
150
+ }
151
+ /** Patch playbackRate on all active animations via WAAPI. */
123
152
  patchAnimations() {
124
153
  try {
125
154
  const anims = document.getAnimations();
126
155
  for (const anim of anims) {
127
156
  const target = anim.effect?.target;
128
157
  if (target?.closest?.("[data-lapse-panel]")) continue;
129
- anim.playbackRate = this.speed || 1e-3;
158
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
159
+ const effectiveSpeed = this.speed || 1e-3;
160
+ let tracked = this.trackedAnims.get(anim);
161
+ if (!tracked) {
162
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
163
+ this.trackedAnims.set(anim, tracked);
164
+ } else if (anim.playbackRate !== tracked.applied) {
165
+ tracked.original = anim.playbackRate;
166
+ }
167
+ const desired = tracked.original * effectiveSpeed;
168
+ if (anim.playbackRate !== desired) {
169
+ anim.playbackRate = desired;
170
+ tracked.applied = desired;
171
+ }
130
172
  }
131
173
  } catch {
132
174
  }
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 {
175
+ }
176
+ /** Patch playbackRate on all video/audio elements. */
177
+ patchMedia() {
178
+ try {
179
+ document.querySelectorAll("video, audio").forEach((node) => {
180
+ const el = node;
181
+ if (el.closest?.("[data-lapse-panel]")) return;
182
+ if (el.closest?.("[data-saccade-exclude]")) return;
183
+ let tracked = this.trackedMedia.get(el);
184
+ if (!tracked) {
185
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
186
+ this.trackedMedia.set(el, tracked);
187
+ } else if (el.playbackRate !== tracked.applied) {
188
+ tracked.original = el.playbackRate;
189
+ }
190
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
191
+ if (el.playbackRate !== desired) {
192
+ el.playbackRate = desired;
193
+ tracked.applied = desired;
146
194
  }
147
- }, 100);
195
+ });
196
+ } catch {
148
197
  }
149
198
  }
150
- getSpeed() {
151
- return this.speed;
199
+ /**
200
+ * Register a GSAP instance (for ES-module imports where window.gsap is
201
+ * undefined). Applies the current timeScale immediately if speed !== 1.
202
+ */
203
+ registerGSAP(gsap) {
204
+ this.gsapInstance = gsap;
205
+ if (this.speed !== 1) this.patchGSAP();
152
206
  }
207
+ /** Sync GSAP's global timeline if present. */
208
+ patchGSAP() {
209
+ try {
210
+ const gsap = this.gsapInstance ?? window.gsap;
211
+ gsap?.globalTimeline?.timeScale(this.speed || 1e-3);
212
+ } catch {
213
+ }
214
+ }
215
+ // ---------------------------------------------------------------------------
216
+ // Cleanup
217
+ // ---------------------------------------------------------------------------
153
218
  /** Restore all patched APIs to originals. */
154
219
  destroy() {
155
220
  if (!this.installed) return;
@@ -169,19 +234,33 @@ var TimingController = class {
169
234
  Element.prototype.animate = this._origAnimate;
170
235
  this._origAnimate = null;
171
236
  }
172
- this.mediaObserver?.disconnect();
173
- this.mediaObserver = null;
174
- if (this.animObserver != null) {
175
- this._clearInterval(this.animObserver);
176
- this.animObserver = null;
237
+ if (this.animPollId) {
238
+ this._caf(this.animPollId);
239
+ this.animPollId = 0;
177
240
  }
178
241
  try {
179
242
  for (const anim of document.getAnimations()) {
180
- anim.playbackRate = 1;
243
+ const tracked = this.trackedAnims.get(anim);
244
+ anim.playbackRate = tracked?.original ?? 1;
181
245
  }
182
246
  } catch {
183
247
  }
248
+ try {
249
+ document.querySelectorAll("video, audio").forEach((node) => {
250
+ const el = node;
251
+ const tracked = this.trackedMedia.get(el);
252
+ el.playbackRate = tracked?.original ?? 1;
253
+ });
254
+ } catch {
255
+ }
256
+ try {
257
+ const gsap = this.gsapInstance ?? window.gsap;
258
+ gsap?.globalTimeline?.timeScale(1);
259
+ } catch {
260
+ }
261
+ this.gsapInstance = null;
184
262
  delete window.__LAPSE_ORIGINAL_RAF__;
263
+ delete window.__saccadeInstalled;
185
264
  this.installed = false;
186
265
  }
187
266
  };
@@ -251,6 +330,10 @@ var SNAPSHOT_ATTRS = [
251
330
  "data-hover",
252
331
  "data-at-boundary",
253
332
  "data-scrubbing",
333
+ "data-starting-style",
334
+ "data-ending-style",
335
+ "data-panel-open",
336
+ "data-hidden",
254
337
  "aria-checked",
255
338
  "aria-selected",
256
339
  "aria-expanded",
@@ -264,6 +347,7 @@ var SNAPSHOT_ATTRS = [
264
347
  "checked",
265
348
  "disabled",
266
349
  "hidden",
350
+ "inert",
267
351
  "value",
268
352
  "class",
269
353
  "style"
@@ -395,6 +479,8 @@ var _TimelineRecorder = class _TimelineRecorder {
395
479
  // ---- WAAPI interception --------------------------------------------------
396
480
  /** Animations captured via Element.prototype.animate monkey-patch. */
397
481
  this.interceptedAnimations = [];
482
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
483
+ this.seekableClones = /* @__PURE__ */ new Map();
398
484
  this.hiddenSince = null;
399
485
  this.onVisibilityChange = null;
400
486
  /** Set to true when the capture loop self-terminates due to limits. */
@@ -445,6 +531,7 @@ var _TimelineRecorder = class _TimelineRecorder {
445
531
  this.portalIdCounter = 0;
446
532
  this.currentPortalIds.clear();
447
533
  this.capturedPortals.clear();
534
+ this.seekableClones.clear();
448
535
  this.prevInlineStyles.clear();
449
536
  this.jsAnimStartTimes.clear();
450
537
  this.jsAnimLastSeen.clear();
@@ -798,6 +885,13 @@ var _TimelineRecorder = class _TimelineRecorder {
798
885
  } catch (_) {
799
886
  }
800
887
  }
888
+ const cleanedKeyframes = keyframes2.map((kf) => {
889
+ const clean = {};
890
+ for (const [k, v] of Object.entries(kf)) {
891
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
892
+ }
893
+ return clean;
894
+ });
801
895
  this.animations.set(id, {
802
896
  id,
803
897
  name,
@@ -809,7 +903,9 @@ var _TimelineRecorder = class _TimelineRecorder {
809
903
  type,
810
904
  source,
811
905
  resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
812
- conflicts
906
+ conflicts,
907
+ rawKeyframes: cleanedKeyframes,
908
+ rawTiming: { ...timing, fill: "both" }
813
909
  });
814
910
  }
815
911
  const keyframes = a.effect?.getKeyframes?.() || [];
@@ -1085,38 +1181,28 @@ var _TimelineRecorder = class _TimelineRecorder {
1085
1181
  } catch (_) {
1086
1182
  }
1087
1183
  }, 0);
1088
- if (this.frames.length > 0) {
1089
- const frame0 = this.frames[0];
1090
- if (frame0.elementSnapshots) {
1091
- for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
1092
- const el = this.elements.get(sel);
1093
- if (!el || !el.isConnected) continue;
1094
- if (snap.__styles) {
1095
- for (const [prop, value] of Object.entries(snap.__styles)) {
1096
- if (SAFE_PROPS_SET.has(prop)) {
1097
- el.style.setProperty(prop, value, "important");
1098
- }
1099
- }
1100
- }
1101
- if (snap.__attrs) {
1102
- for (const [attr, value] of Object.entries(snap.__attrs)) {
1103
- if (attr === "checked") {
1104
- ;
1105
- el.checked = value === "true";
1106
- } else if (attr === "class" && value != null) {
1107
- el.className = value;
1108
- } else if (attr === "style") {
1109
- } else if (attr === "value" && value != null) {
1110
- ;
1111
- el.value = value;
1112
- } else if (value == null) {
1113
- el.removeAttribute(attr);
1114
- } else {
1115
- el.setAttribute(attr, value);
1116
- }
1117
- }
1118
- }
1119
- }
1184
+ this.seekableClones.clear();
1185
+ for (const [animId, animInfo] of this.animations) {
1186
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1187
+ if (animInfo.type === "JSAnimation") continue;
1188
+ const firstColon = animId.indexOf(":");
1189
+ const secondColon = animId.indexOf(":", firstColon + 1);
1190
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1191
+ const el = this.elements.get(elSelector);
1192
+ if (!el?.isConnected) continue;
1193
+ try {
1194
+ const clone = el.animate(animInfo.rawKeyframes, {
1195
+ ...animInfo.rawTiming,
1196
+ fill: "both"
1197
+ });
1198
+ clone.pause();
1199
+ clone.currentTime = 0;
1200
+ this.seekableClones.set(animId, {
1201
+ animation: clone,
1202
+ element: el,
1203
+ effect: clone.effect
1204
+ });
1205
+ } catch (_) {
1120
1206
  }
1121
1207
  }
1122
1208
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1139,6 +1225,8 @@ var TimelineRecorder = _TimelineRecorder;
1139
1225
  // src/core/scrubber.ts
1140
1226
  var TimelineScrubber = class {
1141
1227
  constructor(state) {
1228
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1229
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1142
1230
  /** Saved originals for restore on release */
1143
1231
  this._originalAnimate = null;
1144
1232
  this._originalRaf = null;
@@ -1148,36 +1236,24 @@ var TimelineScrubber = class {
1148
1236
  this.frames = state.frames;
1149
1237
  this.capturedPortals = state.capturedPortals;
1150
1238
  this.interceptedAnimations = state.interceptedAnimations;
1151
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1239
+ this.seekableClones = state.seekableClones;
1152
1240
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1153
1241
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1154
1242
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1155
1243
  this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1156
- }
1157
- // ---------------------------------------------------------------------------
1158
- // Selector helper mirrors the recorder's getSelector so we can look up
1159
- // elements by the same key the recorder used in frame.animations[].animationId
1160
- // ---------------------------------------------------------------------------
1161
- getSelector(el) {
1162
- if (!el || !el.tagName) return null;
1163
- const parts = [];
1164
- let current = el;
1165
- for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
1166
- const tag = current.tagName.toLowerCase();
1167
- const parent = current.parentElement;
1168
- if (parent) {
1169
- const siblings = Array.from(parent.children);
1170
- const idx = siblings.indexOf(current) + 1;
1171
- parts.unshift(`${tag}:nth-child(${idx})`);
1172
- } else {
1173
- parts.unshift(tag);
1244
+ for (let i = 0; i < this.frames.length; i++) {
1245
+ for (const fa of this.frames[i].animations) {
1246
+ const range = this.animFrameRanges.get(fa.animationId);
1247
+ if (!range) {
1248
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1249
+ } else {
1250
+ range.last = i;
1251
+ }
1174
1252
  }
1175
- current = parent;
1176
1253
  }
1177
- return parts.join(" > ");
1178
1254
  }
1179
1255
  // ---------------------------------------------------------------------------
1180
- // seekTo — scrub the DOM to match a specific timestamp
1256
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1181
1257
  // ---------------------------------------------------------------------------
1182
1258
  seekTo(timeMs) {
1183
1259
  if (!this.frames.length) return;
@@ -1225,6 +1301,34 @@ var TimelineScrubber = class {
1225
1301
  }
1226
1302
  }
1227
1303
  }
1304
+ const activeAnimIds = /* @__PURE__ */ new Map();
1305
+ for (const fa of frame.animations || []) {
1306
+ activeAnimIds.set(fa.animationId, fa);
1307
+ }
1308
+ for (const [animId, clone] of this.seekableClones) {
1309
+ const frameAnim = activeAnimIds.get(animId);
1310
+ try {
1311
+ if (frameAnim) {
1312
+ if (!clone.animation.effect) {
1313
+ clone.animation.effect = clone.effect;
1314
+ }
1315
+ clone.animation.currentTime = frameAnim.currentTime;
1316
+ } else {
1317
+ const range = this.animFrameRanges.get(animId);
1318
+ if (!range || lo < range.first) {
1319
+ clone.animation.effect = null;
1320
+ } else {
1321
+ if (!clone.animation.effect) {
1322
+ clone.animation.effect = clone.effect;
1323
+ }
1324
+ const timing = clone.effect.getTiming();
1325
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1326
+ clone.animation.currentTime = endTime;
1327
+ }
1328
+ }
1329
+ } catch {
1330
+ }
1331
+ }
1228
1332
  for (const entry of this.interceptedAnimations) {
1229
1333
  try {
1230
1334
  const anim = entry.animation;
@@ -1239,34 +1343,13 @@ var TimelineScrubber = class {
1239
1343
  const el = this.elements.get(sel);
1240
1344
  if (!el || !el.isConnected) continue;
1241
1345
  if (el.closest?.("[data-lapse-panel]")) continue;
1242
- const hasAnimation = (frame.animations || []).some(
1243
- (a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
1244
- );
1245
1346
  const snapTyped = snap;
1246
- if (snapTyped.__styles) {
1247
- for (const [prop, value] of Object.entries(snapTyped.__styles)) {
1248
- if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
1249
- el.style.setProperty(prop, value, "important");
1250
- }
1251
- }
1252
- }
1253
1347
  if (snapTyped.__attrs) {
1254
1348
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1349
+ if (attr === "class" || attr === "style") continue;
1255
1350
  if (attr === "checked") {
1256
1351
  ;
1257
1352
  el.checked = value === "true";
1258
- } else if (attr === "class") {
1259
- if (value != null) el.className = value;
1260
- } else if (attr === "style") {
1261
- if (value) {
1262
- el.setAttribute("style", value);
1263
- el.style.transition = "none";
1264
- if (snapTyped.__styles) {
1265
- for (const [prop, val] of Object.entries(snapTyped.__styles)) {
1266
- el.style.setProperty(prop, val, "important");
1267
- }
1268
- }
1269
- }
1270
1353
  } else if (attr === "value") {
1271
1354
  if (value != null) el.value = value;
1272
1355
  } else if (value == null) {
@@ -1277,39 +1360,31 @@ var TimelineScrubber = class {
1277
1360
  }
1278
1361
  }
1279
1362
  }
1280
- for (const anim of frame.animations || []) {
1281
- const firstColon = anim.animationId.indexOf(":");
1282
- const secondColon = anim.animationId.indexOf(":", firstColon + 1);
1283
- const animSel = secondColon >= 0 ? anim.animationId.substring(secondColon + 1) : "";
1284
- const animEl = this.elements.get(animSel);
1285
- if (!animEl || !animEl.isConnected) continue;
1286
- for (const prop of anim.properties || []) {
1363
+ for (const fa of frame.animations || []) {
1364
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1365
+ const firstColon = fa.animationId.indexOf(":");
1366
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1367
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1368
+ const el = this.elements.get(elSel);
1369
+ if (!el || !el.isConnected) continue;
1370
+ for (const prop of fa.properties) {
1287
1371
  if (prop.value) {
1288
- animEl.style.setProperty(prop.property, prop.value, "important");
1372
+ el.style.setProperty(prop.property, prop.value, "important");
1289
1373
  }
1290
1374
  }
1291
1375
  }
1292
- const animatedSels = /* @__PURE__ */ new Set();
1293
- for (const anim of frame.animations || []) {
1294
- const fc = anim.animationId.indexOf(":");
1295
- const sc = anim.animationId.indexOf(":", fc + 1);
1296
- if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
1297
- }
1298
- document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
1299
- const el = rawEl;
1300
- const sel = this.getSelector(el);
1301
- if (sel && !animatedSels.has(sel)) {
1302
- el.style.removeProperty("opacity");
1303
- el.style.removeProperty("transform");
1304
- el.style.removeProperty("filter");
1305
- el.style.removeProperty("stroke-dashoffset");
1306
- }
1307
- });
1308
1376
  }
1309
1377
  // ---------------------------------------------------------------------------
1310
1378
  // release — tear down all scrub state and restore the page to normal
1311
1379
  // ---------------------------------------------------------------------------
1312
1380
  release() {
1381
+ for (const [, clone] of this.seekableClones) {
1382
+ try {
1383
+ clone.animation.cancel();
1384
+ } catch {
1385
+ }
1386
+ }
1387
+ this.seekableClones.clear();
1313
1388
  for (const entry of this.interceptedAnimations) {
1314
1389
  try {
1315
1390
  entry.animation.cancel();
@@ -1371,6 +1446,7 @@ var TimelineScrubber = class {
1371
1446
  this.elements.clear();
1372
1447
  this.frames.length = 0;
1373
1448
  this.capturedPortals.clear();
1449
+ this.animFrameRanges.clear();
1374
1450
  }
1375
1451
  };
1376
1452
 
@@ -1457,7 +1533,7 @@ function generateExport(animations, frames, timeMs, filter = "active") {
1457
1533
  animations: animExports
1458
1534
  };
1459
1535
  }
1460
- function formatExportForLLM(exp, detail = "standard") {
1536
+ function formatExportForLLM(exp, detail = "moderate") {
1461
1537
  const lines = [];
1462
1538
  const grouped = /* @__PURE__ */ new Map();
1463
1539
  for (const anim of exp.animations) {
@@ -1472,7 +1548,7 @@ function formatExportForLLM(exp, detail = "standard") {
1472
1548
  const [from, to] = prop.range.split(" \u2192 ");
1473
1549
  return !(from && to && from.trim() === to.trim());
1474
1550
  }
1475
- if (detail === "compact") {
1551
+ if (detail === "brief") {
1476
1552
  lines.push(`# Animation State at ${exp.timestamp}`);
1477
1553
  lines.push("");
1478
1554
  for (const [, group] of grouped) {
@@ -1497,7 +1573,7 @@ function formatExportForLLM(exp, detail = "standard") {
1497
1573
  }
1498
1574
  lines.push(`# Animation State at ${exp.timestamp}`);
1499
1575
  lines.push("");
1500
- if (detail === "forensic") {
1576
+ if (detail === "granular") {
1501
1577
  lines.push("**Environment:**");
1502
1578
  lines.push(`- Viewport: ${window.innerWidth}\xD7${window.innerHeight}`);
1503
1579
  lines.push(`- URL: ${window.location.href}`);
@@ -1564,7 +1640,7 @@ function formatExportForLLM(exp, detail = "standard") {
1564
1640
  lines.push(`Transitions: ${[...transitionSet].join(", ")}`);
1565
1641
  lines.push("");
1566
1642
  for (const line of cssPropLines) lines.push(line);
1567
- if (detail === "detailed" || detail === "forensic") {
1643
+ if (detail === "detailed" || detail === "granular") {
1568
1644
  const allVars = {};
1569
1645
  for (const anim of cssAnims) {
1570
1646
  if (anim.resolvedVars) Object.assign(allVars, anim.resolvedVars);
@@ -1619,6 +1695,22 @@ var SaccadeEngine = class {
1619
1695
  getSpeed() {
1620
1696
  return this.timing.getSpeed();
1621
1697
  }
1698
+ /**
1699
+ * Install the timing patches immediately, without changing speed.
1700
+ *
1701
+ * Call this as early as possible (before app code, GSAP, or Framer Motion
1702
+ * run) so they capture the patched time functions rather than the originals.
1703
+ * Idempotent and harmless to call more than once. `setSpeed` and
1704
+ * `startRecording` also install on demand, so this is only needed to win the
1705
+ * early-load race against libraries that cache `Date.now`/`performance.now`.
1706
+ */
1707
+ install() {
1708
+ this.timing.install();
1709
+ }
1710
+ /** Register a module-imported GSAP instance so saccade can slow it. */
1711
+ registerGSAP(gsap) {
1712
+ this.timing.registerGSAP(gsap);
1713
+ }
1622
1714
  // -- Timeline recording ---------------------------------------------------
1623
1715
  startRecording(boundingBox) {
1624
1716
  if (this._state !== "idle") return;
@@ -1643,7 +1735,7 @@ var SaccadeEngine = class {
1643
1735
  try {
1644
1736
  capture = this.recorder.stopRecording();
1645
1737
  } catch (e) {
1646
- console.error("[Saccade] stopRecording failed:", e);
1738
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1647
1739
  document.getElementById("__lapse-scrub-blocker")?.remove();
1648
1740
  document.getElementById("__lapse-no-transitions")?.remove();
1649
1741
  document.getElementById("__lapse-state-rules")?.remove();
@@ -1664,7 +1756,8 @@ var SaccadeEngine = class {
1664
1756
  frames: capture.frames,
1665
1757
  capturedPortals: this.recorder.capturedPortalIds,
1666
1758
  interceptedAnimations: this.recorder.interceptedAnimations,
1667
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1759
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1760
+ seekableClones: this.recorder.seekableClones
1668
1761
  };
1669
1762
  this.scrubber = new TimelineScrubber(scrubberState);
1670
1763
  this._state = "scrubbing";
@@ -1692,7 +1785,7 @@ var SaccadeEngine = class {
1692
1785
  filter
1693
1786
  );
1694
1787
  }
1695
- exportForLLM(timeMs, filter = "active", detail = "standard") {
1788
+ exportForLLM(timeMs, filter = "active", detail = "moderate") {
1696
1789
  const exp = this.generateExport(timeMs, filter);
1697
1790
  if (!exp) return "";
1698
1791
  return formatExportForLLM(exp, detail);
@@ -1717,13 +1810,29 @@ var SaccadeEngine = class {
1717
1810
  }
1718
1811
  };
1719
1812
 
1813
+ // src/core/shared.ts
1814
+ var KEY = "__saccadeSharedEngine__";
1815
+ function getSharedEngine() {
1816
+ const g = globalThis;
1817
+ if (!g[KEY]) g[KEY] = new SaccadeEngine();
1818
+ return g[KEY];
1819
+ }
1820
+ function resetSharedEngine() {
1821
+ const g = globalThis;
1822
+ g[KEY]?.destroy();
1823
+ g[KEY] = null;
1824
+ }
1825
+
1720
1826
  // src/react/SaccadeContext.tsx
1721
1827
  import { jsx } from "react/jsx-runtime";
1722
1828
  var SaccadeContext = createContext(null);
1723
- function SaccadeProvider({ children }) {
1829
+ function SaccadeProvider({
1830
+ children,
1831
+ engine
1832
+ }) {
1724
1833
  const engineRef = useRef(null);
1725
1834
  if (!engineRef.current) {
1726
- engineRef.current = new SaccadeEngine();
1835
+ engineRef.current = engine ?? getSharedEngine();
1727
1836
  }
1728
1837
  return /* @__PURE__ */ jsx(SaccadeContext.Provider, { value: engineRef.current, children });
1729
1838
  }
@@ -1740,10 +1849,10 @@ import { useRef as useRef5, useCallback as useCallback5 } from "react";
1740
1849
  import { useCallback, useRef as useRef2, useState, useEffect } from "react";
1741
1850
  import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
1742
1851
  var DETAIL_LABELS = {
1743
- compact: "Compact",
1744
- standard: "Standard",
1852
+ brief: "Brief",
1853
+ moderate: "Moderate",
1745
1854
  detailed: "Detailed",
1746
- forensic: "Forensic"
1855
+ granular: "Granular"
1747
1856
  };
1748
1857
  function CopyCheckIcon({ copied }) {
1749
1858
  const spring = "cubic-bezier(0.34, 1.15, 0.64, 1)";
@@ -1797,10 +1906,10 @@ function CopyCheckIcon({ copied }) {
1797
1906
  ] });
1798
1907
  }
1799
1908
  var DETAIL_BRIGHT_COUNT = {
1800
- compact: 1,
1801
- standard: 2,
1909
+ brief: 1,
1910
+ moderate: 2,
1802
1911
  detailed: 3,
1803
- forensic: 4
1912
+ granular: 4
1804
1913
  };
1805
1914
  function DetailIcon({ level }) {
1806
1915
  const bright = DETAIL_BRIGHT_COUNT[level];
@@ -2125,7 +2234,9 @@ function SpeedControl({ speed, isPaused, onSetSpeed, onTogglePause }) {
2125
2234
 
2126
2235
  // src/react/useTimeline.ts
2127
2236
  import { useState as useState2, useCallback as useCallback3, useEffect as useEffect2, useRef as useRef4, useSyncExternalStore } from "react";
2128
- var DETAIL_LEVELS = ["compact", "standard", "detailed", "forensic"];
2237
+ var _realSetTimeout = setTimeout.bind(window);
2238
+ var _realClearTimeout = clearTimeout.bind(window);
2239
+ var DETAIL_LEVELS = ["brief", "moderate", "detailed", "granular"];
2129
2240
  function useTimeline() {
2130
2241
  const engine = useSaccadeEngine();
2131
2242
  const state = useSyncExternalStore(
@@ -2136,7 +2247,7 @@ function useTimeline() {
2136
2247
  const [scrubTime, setScrubTime] = useState2(0);
2137
2248
  const [copied, setCopied] = useState2(false);
2138
2249
  const [exportFilter, setExportFilter] = useState2("all-animations");
2139
- const [detailLevel, setDetailLevel] = useState2("standard");
2250
+ const [detailLevel, setDetailLevel] = useState2("moderate");
2140
2251
  const copiedTimeout = useRef4(null);
2141
2252
  const pendingSeek = useRef4(null);
2142
2253
  const rafId = useRef4(0);
@@ -2210,8 +2321,8 @@ function useTimeline() {
2210
2321
  navigator.clipboard.writeText(text).catch(() => {
2211
2322
  });
2212
2323
  setCopied(true);
2213
- if (copiedTimeout.current) clearTimeout(copiedTimeout.current);
2214
- copiedTimeout.current = setTimeout(() => setCopied(false), 2e3);
2324
+ if (copiedTimeout.current) _realClearTimeout(copiedTimeout.current);
2325
+ copiedTimeout.current = _realSetTimeout(() => setCopied(false), 1800);
2215
2326
  return text;
2216
2327
  },
2217
2328
  [engine, capture, scrubTime, exportFilter, detailLevel]
@@ -2763,15 +2874,16 @@ var PANEL_STYLES = (
2763
2874
 
2764
2875
  // src/react/Saccade.tsx
2765
2876
  import { jsx as jsx5 } from "react/jsx-runtime";
2766
- function Saccade({ position = "bottom-left" }) {
2767
- const hostRef = useRef6(null);
2877
+ function Saccade({ position = "bottom-left", engine }) {
2768
2878
  const [shadowRoot, setShadowRoot] = useState4(null);
2879
+ const hostRef = useRef6(null);
2769
2880
  useEffect4(() => {
2770
- const host = hostRef.current;
2771
- if (!host || host.shadowRoot) {
2772
- if (host?.shadowRoot) setShadowRoot(host.shadowRoot);
2773
- return;
2774
- }
2881
+ const host = document.createElement("div");
2882
+ host.setAttribute("data-lapse-panel", "");
2883
+ 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";
2884
+ host.style.cssText = `position:fixed;z-index:2147483647;pointer-events:auto;${positionOffset}`;
2885
+ document.body.appendChild(host);
2886
+ hostRef.current = host;
2775
2887
  const shadow = host.attachShadow({ mode: "open" });
2776
2888
  const style = document.createElement("style");
2777
2889
  style.textContent = PANEL_STYLES;
@@ -2779,31 +2891,23 @@ function Saccade({ position = "bottom-left" }) {
2779
2891
  const mount = document.createElement("div");
2780
2892
  shadow.appendChild(mount);
2781
2893
  setShadowRoot(shadow);
2782
- }, []);
2783
- 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 };
2784
- return /* @__PURE__ */ jsx5(
2785
- "div",
2786
- {
2787
- ref: hostRef,
2788
- "data-lapse-panel": "",
2789
- style: {
2790
- position: "fixed",
2791
- zIndex: 2147483647,
2792
- // max int — must sit above the scrub blocker (z-index: 999999)
2793
- pointerEvents: "auto",
2794
- ...positionOffset
2795
- },
2796
- children: shadowRoot && createPortal(
2797
- /* @__PURE__ */ jsx5(SaccadeProvider, { children: /* @__PURE__ */ jsx5(SaccadePanel, {}) }),
2798
- shadowRoot.lastElementChild || shadowRoot
2799
- )
2800
- }
2894
+ return () => {
2895
+ host.remove();
2896
+ hostRef.current = null;
2897
+ };
2898
+ }, [position]);
2899
+ if (!shadowRoot) return null;
2900
+ return createPortal(
2901
+ /* @__PURE__ */ jsx5(SaccadeProvider, { engine, children: /* @__PURE__ */ jsx5(SaccadePanel, {}) }),
2902
+ shadowRoot.lastElementChild || shadowRoot
2801
2903
  );
2802
2904
  }
2803
2905
  export {
2804
2906
  Saccade,
2805
2907
  SaccadeEngine,
2806
2908
  SaccadeProvider,
2909
+ getSharedEngine,
2910
+ resetSharedEngine,
2807
2911
  useSaccadeEngine,
2808
2912
  useSpeed,
2809
2913
  useTimeline