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.cjs CHANGED
@@ -24,6 +24,8 @@ __export(src_exports, {
24
24
  Saccade: () => Saccade,
25
25
  SaccadeEngine: () => SaccadeEngine,
26
26
  SaccadeProvider: () => SaccadeProvider,
27
+ getSharedEngine: () => getSharedEngine,
28
+ resetSharedEngine: () => resetSharedEngine,
27
29
  useSaccadeEngine: () => useSaccadeEngine,
28
30
  useSpeed: () => useSpeed,
29
31
  useTimeline: () => useTimeline
@@ -38,16 +40,22 @@ var import_react_dom = require("react-dom");
38
40
  var import_react = require("react");
39
41
 
40
42
  // src/core/timing.ts
43
+ var MEDIA_RATE_MIN = 0.0625;
44
+ var MEDIA_RATE_MAX = 16;
45
+ var ZERO_SPEED_DIVISOR = 1e-4;
41
46
  var TimingController = class {
42
47
  constructor() {
43
48
  this.speed = 1;
44
49
  this.virtualBaseline = 0;
45
50
  this.intervalMap = /* @__PURE__ */ new Map();
46
51
  this.nextIntervalId = 1e6;
47
- this.mediaObserver = null;
48
- this.animObserver = null;
49
52
  this._origAnimate = null;
50
53
  this.installed = false;
54
+ this.animPollId = 0;
55
+ this.gsapInstance = null;
56
+ // WeakMap tracking for animations and media
57
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
58
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
51
59
  this._raf = requestAnimationFrame.bind(window);
52
60
  this._caf = cancelAnimationFrame.bind(window);
53
61
  this._setTimeout = setTimeout.bind(window);
@@ -57,19 +65,34 @@ var TimingController = class {
57
65
  this._perfNow = performance.now.bind(performance);
58
66
  this._dateNow = Date.now;
59
67
  this.realBaseline = this._perfNow();
68
+ this.dateRealBaseline = this._dateNow();
69
+ this.dateVirtualBaseline = this.dateRealBaseline;
60
70
  }
61
71
  getVirtualTime() {
62
72
  const realElapsed = this._perfNow() - this.realBaseline;
63
73
  return this.virtualBaseline + realElapsed * this.speed;
64
74
  }
75
+ getVirtualDateNow() {
76
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
77
+ return this.dateVirtualBaseline + realElapsed * this.speed;
78
+ }
65
79
  reanchor() {
66
80
  const virtualNow = this.getVirtualTime();
67
81
  this.realBaseline = this._perfNow();
68
82
  this.virtualBaseline = virtualNow;
83
+ const virtualDateNow = this.getVirtualDateNow();
84
+ this.dateRealBaseline = this._dateNow();
85
+ this.dateVirtualBaseline = virtualDateNow;
86
+ }
87
+ /** Effective speed divisor — avoids division by zero at speed=0. */
88
+ get speedDivisor() {
89
+ return this.speed || ZERO_SPEED_DIVISOR;
69
90
  }
70
91
  /** Install timing patches. Safe to call multiple times. */
71
92
  install() {
72
93
  if (this.installed) return;
94
+ if (window.__saccadeInstalled) return;
95
+ window.__saccadeInstalled = true;
73
96
  this.installed = true;
74
97
  const self = this;
75
98
  window.__LAPSE_ORIGINAL_RAF__ = this._raf;
@@ -78,21 +101,27 @@ var TimingController = class {
78
101
  Element.prototype.animate = function(...args) {
79
102
  const anim = origAnimate.apply(this, args);
80
103
  if (self.speed !== 1) {
81
- anim.playbackRate = self.speed || 1e-3;
104
+ const originalRate = anim.playbackRate;
105
+ const applied = originalRate * (self.speed || 1e-3);
106
+ anim.playbackRate = applied;
107
+ self.trackedAnims.set(anim, { original: originalRate, applied });
82
108
  }
83
109
  return anim;
84
110
  };
85
111
  performance.now = () => self.getVirtualTime();
86
- const dateBaseline = this._dateNow();
87
- Date.now = () => dateBaseline + self.getVirtualTime();
112
+ Date.now = () => self.getVirtualDateNow();
88
113
  window.requestAnimationFrame = (callback) => {
89
114
  return self._raf(() => {
115
+ if (self.speed === 0) {
116
+ window.requestAnimationFrame(callback);
117
+ return;
118
+ }
90
119
  callback(self.getVirtualTime());
91
120
  });
92
121
  };
93
122
  window.cancelAnimationFrame = this._caf;
94
123
  window.setTimeout = ((handler, delay, ...args) => {
95
- const scaledDelay = (delay ?? 0) / (self.speed || 1);
124
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
96
125
  return self._setTimeout(handler, scaledDelay, ...args);
97
126
  });
98
127
  window.clearTimeout = this._clearTimeout;
@@ -100,7 +129,7 @@ var TimingController = class {
100
129
  const id = self.nextIntervalId++;
101
130
  const baseDelay = delay ?? 0;
102
131
  function tick() {
103
- const scaledDelay = baseDelay / (self.speed || 1);
132
+ const scaledDelay = baseDelay / self.speedDivisor;
104
133
  const realId = self._setTimeout(() => {
105
134
  if (typeof handler === "function") {
106
135
  ;
@@ -125,61 +154,99 @@ var TimingController = class {
125
154
  self._clearInterval(id);
126
155
  }
127
156
  });
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
- }
157
+ this.startAnimationPoll();
140
158
  }
141
159
  /** Set playback speed. Requires install() first. */
142
160
  setSpeed(newSpeed) {
143
161
  if (!this.installed) this.install();
144
162
  this.reanchor();
145
163
  this.speed = newSpeed;
146
- document.querySelectorAll("video, audio").forEach((el) => {
147
- ;
148
- el.playbackRate = newSpeed;
149
- });
150
164
  this.patchAnimations();
165
+ this.patchMedia();
166
+ this.patchGSAP();
151
167
  }
152
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
168
+ getSpeed() {
169
+ return this.speed;
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Animation polling — per-frame via original rAF
173
+ // ---------------------------------------------------------------------------
174
+ startAnimationPoll() {
175
+ const poll = () => {
176
+ if (!this.installed) return;
177
+ this.patchAnimations();
178
+ this.patchMedia();
179
+ this.animPollId = this._raf(poll);
180
+ };
181
+ this.animPollId = this._raf(poll);
182
+ }
183
+ /** Patch playbackRate on all active animations via WAAPI. */
153
184
  patchAnimations() {
154
185
  try {
155
186
  const anims = document.getAnimations();
156
187
  for (const anim of anims) {
157
188
  const target = anim.effect?.target;
158
189
  if (target?.closest?.("[data-lapse-panel]")) continue;
159
- anim.playbackRate = this.speed || 1e-3;
190
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
191
+ const effectiveSpeed = this.speed || 1e-3;
192
+ let tracked = this.trackedAnims.get(anim);
193
+ if (!tracked) {
194
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
195
+ this.trackedAnims.set(anim, tracked);
196
+ } else if (anim.playbackRate !== tracked.applied) {
197
+ tracked.original = anim.playbackRate;
198
+ }
199
+ const desired = tracked.original * effectiveSpeed;
200
+ if (anim.playbackRate !== desired) {
201
+ anim.playbackRate = desired;
202
+ tracked.applied = desired;
203
+ }
160
204
  }
161
205
  } catch {
162
206
  }
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 {
207
+ }
208
+ /** Patch playbackRate on all video/audio elements. */
209
+ patchMedia() {
210
+ try {
211
+ document.querySelectorAll("video, audio").forEach((node) => {
212
+ const el = node;
213
+ if (el.closest?.("[data-lapse-panel]")) return;
214
+ if (el.closest?.("[data-saccade-exclude]")) return;
215
+ let tracked = this.trackedMedia.get(el);
216
+ if (!tracked) {
217
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
218
+ this.trackedMedia.set(el, tracked);
219
+ } else if (el.playbackRate !== tracked.applied) {
220
+ tracked.original = el.playbackRate;
221
+ }
222
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
223
+ if (el.playbackRate !== desired) {
224
+ el.playbackRate = desired;
225
+ tracked.applied = desired;
176
226
  }
177
- }, 100);
227
+ });
228
+ } catch {
178
229
  }
179
230
  }
180
- getSpeed() {
181
- return this.speed;
231
+ /**
232
+ * Register a GSAP instance (for ES-module imports where window.gsap is
233
+ * undefined). Applies the current timeScale immediately if speed !== 1.
234
+ */
235
+ registerGSAP(gsap) {
236
+ this.gsapInstance = gsap;
237
+ if (this.speed !== 1) this.patchGSAP();
182
238
  }
239
+ /** Sync GSAP's global timeline if present. */
240
+ patchGSAP() {
241
+ try {
242
+ const gsap = this.gsapInstance ?? window.gsap;
243
+ gsap?.globalTimeline?.timeScale(this.speed || 1e-3);
244
+ } catch {
245
+ }
246
+ }
247
+ // ---------------------------------------------------------------------------
248
+ // Cleanup
249
+ // ---------------------------------------------------------------------------
183
250
  /** Restore all patched APIs to originals. */
184
251
  destroy() {
185
252
  if (!this.installed) return;
@@ -199,19 +266,33 @@ var TimingController = class {
199
266
  Element.prototype.animate = this._origAnimate;
200
267
  this._origAnimate = null;
201
268
  }
202
- this.mediaObserver?.disconnect();
203
- this.mediaObserver = null;
204
- if (this.animObserver != null) {
205
- this._clearInterval(this.animObserver);
206
- this.animObserver = null;
269
+ if (this.animPollId) {
270
+ this._caf(this.animPollId);
271
+ this.animPollId = 0;
207
272
  }
208
273
  try {
209
274
  for (const anim of document.getAnimations()) {
210
- anim.playbackRate = 1;
275
+ const tracked = this.trackedAnims.get(anim);
276
+ anim.playbackRate = tracked?.original ?? 1;
211
277
  }
212
278
  } catch {
213
279
  }
280
+ try {
281
+ document.querySelectorAll("video, audio").forEach((node) => {
282
+ const el = node;
283
+ const tracked = this.trackedMedia.get(el);
284
+ el.playbackRate = tracked?.original ?? 1;
285
+ });
286
+ } catch {
287
+ }
288
+ try {
289
+ const gsap = this.gsapInstance ?? window.gsap;
290
+ gsap?.globalTimeline?.timeScale(1);
291
+ } catch {
292
+ }
293
+ this.gsapInstance = null;
214
294
  delete window.__LAPSE_ORIGINAL_RAF__;
295
+ delete window.__saccadeInstalled;
215
296
  this.installed = false;
216
297
  }
217
298
  };
@@ -281,6 +362,10 @@ var SNAPSHOT_ATTRS = [
281
362
  "data-hover",
282
363
  "data-at-boundary",
283
364
  "data-scrubbing",
365
+ "data-starting-style",
366
+ "data-ending-style",
367
+ "data-panel-open",
368
+ "data-hidden",
284
369
  "aria-checked",
285
370
  "aria-selected",
286
371
  "aria-expanded",
@@ -294,6 +379,7 @@ var SNAPSHOT_ATTRS = [
294
379
  "checked",
295
380
  "disabled",
296
381
  "hidden",
382
+ "inert",
297
383
  "value",
298
384
  "class",
299
385
  "style"
@@ -425,6 +511,8 @@ var _TimelineRecorder = class _TimelineRecorder {
425
511
  // ---- WAAPI interception --------------------------------------------------
426
512
  /** Animations captured via Element.prototype.animate monkey-patch. */
427
513
  this.interceptedAnimations = [];
514
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
515
+ this.seekableClones = /* @__PURE__ */ new Map();
428
516
  this.hiddenSince = null;
429
517
  this.onVisibilityChange = null;
430
518
  /** Set to true when the capture loop self-terminates due to limits. */
@@ -475,6 +563,7 @@ var _TimelineRecorder = class _TimelineRecorder {
475
563
  this.portalIdCounter = 0;
476
564
  this.currentPortalIds.clear();
477
565
  this.capturedPortals.clear();
566
+ this.seekableClones.clear();
478
567
  this.prevInlineStyles.clear();
479
568
  this.jsAnimStartTimes.clear();
480
569
  this.jsAnimLastSeen.clear();
@@ -828,6 +917,13 @@ var _TimelineRecorder = class _TimelineRecorder {
828
917
  } catch (_) {
829
918
  }
830
919
  }
920
+ const cleanedKeyframes = keyframes2.map((kf) => {
921
+ const clean = {};
922
+ for (const [k, v] of Object.entries(kf)) {
923
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
924
+ }
925
+ return clean;
926
+ });
831
927
  this.animations.set(id, {
832
928
  id,
833
929
  name,
@@ -839,7 +935,9 @@ var _TimelineRecorder = class _TimelineRecorder {
839
935
  type,
840
936
  source,
841
937
  resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
842
- conflicts
938
+ conflicts,
939
+ rawKeyframes: cleanedKeyframes,
940
+ rawTiming: { ...timing, fill: "both" }
843
941
  });
844
942
  }
845
943
  const keyframes = a.effect?.getKeyframes?.() || [];
@@ -1115,38 +1213,28 @@ var _TimelineRecorder = class _TimelineRecorder {
1115
1213
  } catch (_) {
1116
1214
  }
1117
1215
  }, 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
- }
1216
+ this.seekableClones.clear();
1217
+ for (const [animId, animInfo] of this.animations) {
1218
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1219
+ if (animInfo.type === "JSAnimation") continue;
1220
+ const firstColon = animId.indexOf(":");
1221
+ const secondColon = animId.indexOf(":", firstColon + 1);
1222
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1223
+ const el = this.elements.get(elSelector);
1224
+ if (!el?.isConnected) continue;
1225
+ try {
1226
+ const clone = el.animate(animInfo.rawKeyframes, {
1227
+ ...animInfo.rawTiming,
1228
+ fill: "both"
1229
+ });
1230
+ clone.pause();
1231
+ clone.currentTime = 0;
1232
+ this.seekableClones.set(animId, {
1233
+ animation: clone,
1234
+ element: el,
1235
+ effect: clone.effect
1236
+ });
1237
+ } catch (_) {
1150
1238
  }
1151
1239
  }
1152
1240
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1169,6 +1257,8 @@ var TimelineRecorder = _TimelineRecorder;
1169
1257
  // src/core/scrubber.ts
1170
1258
  var TimelineScrubber = class {
1171
1259
  constructor(state) {
1260
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1261
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1172
1262
  /** Saved originals for restore on release */
1173
1263
  this._originalAnimate = null;
1174
1264
  this._originalRaf = null;
@@ -1178,36 +1268,24 @@ var TimelineScrubber = class {
1178
1268
  this.frames = state.frames;
1179
1269
  this.capturedPortals = state.capturedPortals;
1180
1270
  this.interceptedAnimations = state.interceptedAnimations;
1181
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1271
+ this.seekableClones = state.seekableClones;
1182
1272
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1183
1273
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1184
1274
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1185
1275
  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);
1276
+ for (let i = 0; i < this.frames.length; i++) {
1277
+ for (const fa of this.frames[i].animations) {
1278
+ const range = this.animFrameRanges.get(fa.animationId);
1279
+ if (!range) {
1280
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1281
+ } else {
1282
+ range.last = i;
1283
+ }
1204
1284
  }
1205
- current = parent;
1206
1285
  }
1207
- return parts.join(" > ");
1208
1286
  }
1209
1287
  // ---------------------------------------------------------------------------
1210
- // seekTo — scrub the DOM to match a specific timestamp
1288
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1211
1289
  // ---------------------------------------------------------------------------
1212
1290
  seekTo(timeMs) {
1213
1291
  if (!this.frames.length) return;
@@ -1255,6 +1333,34 @@ var TimelineScrubber = class {
1255
1333
  }
1256
1334
  }
1257
1335
  }
1336
+ const activeAnimIds = /* @__PURE__ */ new Map();
1337
+ for (const fa of frame.animations || []) {
1338
+ activeAnimIds.set(fa.animationId, fa);
1339
+ }
1340
+ for (const [animId, clone] of this.seekableClones) {
1341
+ const frameAnim = activeAnimIds.get(animId);
1342
+ try {
1343
+ if (frameAnim) {
1344
+ if (!clone.animation.effect) {
1345
+ clone.animation.effect = clone.effect;
1346
+ }
1347
+ clone.animation.currentTime = frameAnim.currentTime;
1348
+ } else {
1349
+ const range = this.animFrameRanges.get(animId);
1350
+ if (!range || lo < range.first) {
1351
+ clone.animation.effect = null;
1352
+ } else {
1353
+ if (!clone.animation.effect) {
1354
+ clone.animation.effect = clone.effect;
1355
+ }
1356
+ const timing = clone.effect.getTiming();
1357
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1358
+ clone.animation.currentTime = endTime;
1359
+ }
1360
+ }
1361
+ } catch {
1362
+ }
1363
+ }
1258
1364
  for (const entry of this.interceptedAnimations) {
1259
1365
  try {
1260
1366
  const anim = entry.animation;
@@ -1269,34 +1375,13 @@ var TimelineScrubber = class {
1269
1375
  const el = this.elements.get(sel);
1270
1376
  if (!el || !el.isConnected) continue;
1271
1377
  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
1378
  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
1379
  if (snapTyped.__attrs) {
1284
1380
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1381
+ if (attr === "class" || attr === "style") continue;
1285
1382
  if (attr === "checked") {
1286
1383
  ;
1287
1384
  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
1385
  } else if (attr === "value") {
1301
1386
  if (value != null) el.value = value;
1302
1387
  } else if (value == null) {
@@ -1307,39 +1392,31 @@ var TimelineScrubber = class {
1307
1392
  }
1308
1393
  }
1309
1394
  }
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 || []) {
1395
+ for (const fa of frame.animations || []) {
1396
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1397
+ const firstColon = fa.animationId.indexOf(":");
1398
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1399
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1400
+ const el = this.elements.get(elSel);
1401
+ if (!el || !el.isConnected) continue;
1402
+ for (const prop of fa.properties) {
1317
1403
  if (prop.value) {
1318
- animEl.style.setProperty(prop.property, prop.value, "important");
1404
+ el.style.setProperty(prop.property, prop.value, "important");
1319
1405
  }
1320
1406
  }
1321
1407
  }
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
1408
  }
1339
1409
  // ---------------------------------------------------------------------------
1340
1410
  // release — tear down all scrub state and restore the page to normal
1341
1411
  // ---------------------------------------------------------------------------
1342
1412
  release() {
1413
+ for (const [, clone] of this.seekableClones) {
1414
+ try {
1415
+ clone.animation.cancel();
1416
+ } catch {
1417
+ }
1418
+ }
1419
+ this.seekableClones.clear();
1343
1420
  for (const entry of this.interceptedAnimations) {
1344
1421
  try {
1345
1422
  entry.animation.cancel();
@@ -1401,6 +1478,7 @@ var TimelineScrubber = class {
1401
1478
  this.elements.clear();
1402
1479
  this.frames.length = 0;
1403
1480
  this.capturedPortals.clear();
1481
+ this.animFrameRanges.clear();
1404
1482
  }
1405
1483
  };
1406
1484
 
@@ -1487,7 +1565,7 @@ function generateExport(animations, frames, timeMs, filter = "active") {
1487
1565
  animations: animExports
1488
1566
  };
1489
1567
  }
1490
- function formatExportForLLM(exp, detail = "standard") {
1568
+ function formatExportForLLM(exp, detail = "moderate") {
1491
1569
  const lines = [];
1492
1570
  const grouped = /* @__PURE__ */ new Map();
1493
1571
  for (const anim of exp.animations) {
@@ -1502,7 +1580,7 @@ function formatExportForLLM(exp, detail = "standard") {
1502
1580
  const [from, to] = prop.range.split(" \u2192 ");
1503
1581
  return !(from && to && from.trim() === to.trim());
1504
1582
  }
1505
- if (detail === "compact") {
1583
+ if (detail === "brief") {
1506
1584
  lines.push(`# Animation State at ${exp.timestamp}`);
1507
1585
  lines.push("");
1508
1586
  for (const [, group] of grouped) {
@@ -1527,7 +1605,7 @@ function formatExportForLLM(exp, detail = "standard") {
1527
1605
  }
1528
1606
  lines.push(`# Animation State at ${exp.timestamp}`);
1529
1607
  lines.push("");
1530
- if (detail === "forensic") {
1608
+ if (detail === "granular") {
1531
1609
  lines.push("**Environment:**");
1532
1610
  lines.push(`- Viewport: ${window.innerWidth}\xD7${window.innerHeight}`);
1533
1611
  lines.push(`- URL: ${window.location.href}`);
@@ -1594,7 +1672,7 @@ function formatExportForLLM(exp, detail = "standard") {
1594
1672
  lines.push(`Transitions: ${[...transitionSet].join(", ")}`);
1595
1673
  lines.push("");
1596
1674
  for (const line of cssPropLines) lines.push(line);
1597
- if (detail === "detailed" || detail === "forensic") {
1675
+ if (detail === "detailed" || detail === "granular") {
1598
1676
  const allVars = {};
1599
1677
  for (const anim of cssAnims) {
1600
1678
  if (anim.resolvedVars) Object.assign(allVars, anim.resolvedVars);
@@ -1649,6 +1727,22 @@ var SaccadeEngine = class {
1649
1727
  getSpeed() {
1650
1728
  return this.timing.getSpeed();
1651
1729
  }
1730
+ /**
1731
+ * Install the timing patches immediately, without changing speed.
1732
+ *
1733
+ * Call this as early as possible (before app code, GSAP, or Framer Motion
1734
+ * run) so they capture the patched time functions rather than the originals.
1735
+ * Idempotent and harmless to call more than once. `setSpeed` and
1736
+ * `startRecording` also install on demand, so this is only needed to win the
1737
+ * early-load race against libraries that cache `Date.now`/`performance.now`.
1738
+ */
1739
+ install() {
1740
+ this.timing.install();
1741
+ }
1742
+ /** Register a module-imported GSAP instance so saccade can slow it. */
1743
+ registerGSAP(gsap) {
1744
+ this.timing.registerGSAP(gsap);
1745
+ }
1652
1746
  // -- Timeline recording ---------------------------------------------------
1653
1747
  startRecording(boundingBox) {
1654
1748
  if (this._state !== "idle") return;
@@ -1673,7 +1767,7 @@ var SaccadeEngine = class {
1673
1767
  try {
1674
1768
  capture = this.recorder.stopRecording();
1675
1769
  } catch (e) {
1676
- console.error("[Saccade] stopRecording failed:", e);
1770
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1677
1771
  document.getElementById("__lapse-scrub-blocker")?.remove();
1678
1772
  document.getElementById("__lapse-no-transitions")?.remove();
1679
1773
  document.getElementById("__lapse-state-rules")?.remove();
@@ -1694,7 +1788,8 @@ var SaccadeEngine = class {
1694
1788
  frames: capture.frames,
1695
1789
  capturedPortals: this.recorder.capturedPortalIds,
1696
1790
  interceptedAnimations: this.recorder.interceptedAnimations,
1697
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1791
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1792
+ seekableClones: this.recorder.seekableClones
1698
1793
  };
1699
1794
  this.scrubber = new TimelineScrubber(scrubberState);
1700
1795
  this._state = "scrubbing";
@@ -1722,7 +1817,7 @@ var SaccadeEngine = class {
1722
1817
  filter
1723
1818
  );
1724
1819
  }
1725
- exportForLLM(timeMs, filter = "active", detail = "standard") {
1820
+ exportForLLM(timeMs, filter = "active", detail = "moderate") {
1726
1821
  const exp = this.generateExport(timeMs, filter);
1727
1822
  if (!exp) return "";
1728
1823
  return formatExportForLLM(exp, detail);
@@ -1747,13 +1842,29 @@ var SaccadeEngine = class {
1747
1842
  }
1748
1843
  };
1749
1844
 
1845
+ // src/core/shared.ts
1846
+ var KEY = "__saccadeSharedEngine__";
1847
+ function getSharedEngine() {
1848
+ const g = globalThis;
1849
+ if (!g[KEY]) g[KEY] = new SaccadeEngine();
1850
+ return g[KEY];
1851
+ }
1852
+ function resetSharedEngine() {
1853
+ const g = globalThis;
1854
+ g[KEY]?.destroy();
1855
+ g[KEY] = null;
1856
+ }
1857
+
1750
1858
  // src/react/SaccadeContext.tsx
1751
1859
  var import_jsx_runtime = require("react/jsx-runtime");
1752
1860
  var SaccadeContext = (0, import_react.createContext)(null);
1753
- function SaccadeProvider({ children }) {
1861
+ function SaccadeProvider({
1862
+ children,
1863
+ engine
1864
+ }) {
1754
1865
  const engineRef = (0, import_react.useRef)(null);
1755
1866
  if (!engineRef.current) {
1756
- engineRef.current = new SaccadeEngine();
1867
+ engineRef.current = engine ?? getSharedEngine();
1757
1868
  }
1758
1869
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaccadeContext.Provider, { value: engineRef.current, children });
1759
1870
  }
@@ -1770,10 +1881,10 @@ var import_react6 = require("react");
1770
1881
  var import_react2 = require("react");
1771
1882
  var import_jsx_runtime2 = require("react/jsx-runtime");
1772
1883
  var DETAIL_LABELS = {
1773
- compact: "Compact",
1774
- standard: "Standard",
1884
+ brief: "Brief",
1885
+ moderate: "Moderate",
1775
1886
  detailed: "Detailed",
1776
- forensic: "Forensic"
1887
+ granular: "Granular"
1777
1888
  };
1778
1889
  function CopyCheckIcon({ copied }) {
1779
1890
  const spring = "cubic-bezier(0.34, 1.15, 0.64, 1)";
@@ -1827,10 +1938,10 @@ function CopyCheckIcon({ copied }) {
1827
1938
  ] });
1828
1939
  }
1829
1940
  var DETAIL_BRIGHT_COUNT = {
1830
- compact: 1,
1831
- standard: 2,
1941
+ brief: 1,
1942
+ moderate: 2,
1832
1943
  detailed: 3,
1833
- forensic: 4
1944
+ granular: 4
1834
1945
  };
1835
1946
  function DetailIcon({ level }) {
1836
1947
  const bright = DETAIL_BRIGHT_COUNT[level];
@@ -2155,7 +2266,9 @@ function SpeedControl({ speed, isPaused, onSetSpeed, onTogglePause }) {
2155
2266
 
2156
2267
  // src/react/useTimeline.ts
2157
2268
  var import_react4 = require("react");
2158
- var DETAIL_LEVELS = ["compact", "standard", "detailed", "forensic"];
2269
+ var _realSetTimeout = setTimeout.bind(window);
2270
+ var _realClearTimeout = clearTimeout.bind(window);
2271
+ var DETAIL_LEVELS = ["brief", "moderate", "detailed", "granular"];
2159
2272
  function useTimeline() {
2160
2273
  const engine = useSaccadeEngine();
2161
2274
  const state = (0, import_react4.useSyncExternalStore)(
@@ -2166,7 +2279,7 @@ function useTimeline() {
2166
2279
  const [scrubTime, setScrubTime] = (0, import_react4.useState)(0);
2167
2280
  const [copied, setCopied] = (0, import_react4.useState)(false);
2168
2281
  const [exportFilter, setExportFilter] = (0, import_react4.useState)("all-animations");
2169
- const [detailLevel, setDetailLevel] = (0, import_react4.useState)("standard");
2282
+ const [detailLevel, setDetailLevel] = (0, import_react4.useState)("moderate");
2170
2283
  const copiedTimeout = (0, import_react4.useRef)(null);
2171
2284
  const pendingSeek = (0, import_react4.useRef)(null);
2172
2285
  const rafId = (0, import_react4.useRef)(0);
@@ -2240,8 +2353,8 @@ function useTimeline() {
2240
2353
  navigator.clipboard.writeText(text).catch(() => {
2241
2354
  });
2242
2355
  setCopied(true);
2243
- if (copiedTimeout.current) clearTimeout(copiedTimeout.current);
2244
- copiedTimeout.current = setTimeout(() => setCopied(false), 2e3);
2356
+ if (copiedTimeout.current) _realClearTimeout(copiedTimeout.current);
2357
+ copiedTimeout.current = _realSetTimeout(() => setCopied(false), 1800);
2245
2358
  return text;
2246
2359
  },
2247
2360
  [engine, capture, scrubTime, exportFilter, detailLevel]
@@ -2793,15 +2906,16 @@ var PANEL_STYLES = (
2793
2906
 
2794
2907
  // src/react/Saccade.tsx
2795
2908
  var import_jsx_runtime5 = require("react/jsx-runtime");
2796
- function Saccade({ position = "bottom-left" }) {
2797
- const hostRef = (0, import_react7.useRef)(null);
2909
+ function Saccade({ position = "bottom-left", engine }) {
2798
2910
  const [shadowRoot, setShadowRoot] = (0, import_react7.useState)(null);
2911
+ const hostRef = (0, import_react7.useRef)(null);
2799
2912
  (0, import_react7.useEffect)(() => {
2800
- const host = hostRef.current;
2801
- if (!host || host.shadowRoot) {
2802
- if (host?.shadowRoot) setShadowRoot(host.shadowRoot);
2803
- return;
2804
- }
2913
+ const host = document.createElement("div");
2914
+ host.setAttribute("data-lapse-panel", "");
2915
+ 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";
2916
+ host.style.cssText = `position:fixed;z-index:2147483647;pointer-events:auto;${positionOffset}`;
2917
+ document.body.appendChild(host);
2918
+ hostRef.current = host;
2805
2919
  const shadow = host.attachShadow({ mode: "open" });
2806
2920
  const style = document.createElement("style");
2807
2921
  style.textContent = PANEL_STYLES;
@@ -2809,25 +2923,15 @@ function Saccade({ position = "bottom-left" }) {
2809
2923
  const mount = document.createElement("div");
2810
2924
  shadow.appendChild(mount);
2811
2925
  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
- }
2926
+ return () => {
2927
+ host.remove();
2928
+ hostRef.current = null;
2929
+ };
2930
+ }, [position]);
2931
+ if (!shadowRoot) return null;
2932
+ return (0, import_react_dom.createPortal)(
2933
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(SaccadeProvider, { engine, children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(SaccadePanel, {}) }),
2934
+ shadowRoot.lastElementChild || shadowRoot
2831
2935
  );
2832
2936
  }
2833
2937
  // Annotate the CommonJS export names for ESM import in node:
@@ -2835,6 +2939,8 @@ function Saccade({ position = "bottom-left" }) {
2835
2939
  Saccade,
2836
2940
  SaccadeEngine,
2837
2941
  SaccadeProvider,
2942
+ getSharedEngine,
2943
+ resetSharedEngine,
2838
2944
  useSaccadeEngine,
2839
2945
  useSpeed,
2840
2946
  useTimeline