saccade 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -21,33 +21,38 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  // src/index.ts
22
22
  var src_exports = {};
23
23
  __export(src_exports, {
24
- Lapse: () => Lapse,
25
- LapseEngine: () => LapseEngine,
26
- LapseProvider: () => LapseProvider,
27
- useLapseEngine: () => useLapseEngine,
24
+ Saccade: () => Saccade,
25
+ SaccadeEngine: () => SaccadeEngine,
26
+ SaccadeProvider: () => SaccadeProvider,
27
+ useSaccadeEngine: () => useSaccadeEngine,
28
28
  useSpeed: () => useSpeed,
29
29
  useTimeline: () => useTimeline
30
30
  });
31
31
  module.exports = __toCommonJS(src_exports);
32
32
 
33
- // src/react/Lapse.tsx
33
+ // src/react/Saccade.tsx
34
34
  var import_react7 = require("react");
35
35
  var import_react_dom = require("react-dom");
36
36
 
37
- // src/react/LapseContext.tsx
37
+ // src/react/SaccadeContext.tsx
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;
167
+ }
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);
151
179
  }
152
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
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?.() || [];
@@ -1060,96 +1148,83 @@ var _TimelineRecorder = class _TimelineRecorder {
1060
1148
  blocker.title = "Clear the timeline to interact with the page";
1061
1149
  document.body.appendChild(blocker);
1062
1150
  this.blockerEl = blocker;
1063
- try {
1064
- const lapseStyle = document.createElement("style");
1065
- lapseStyle.id = "__lapse-state-rules";
1066
- let allCss = "";
1067
- for (const style of document.querySelectorAll("style")) {
1068
- if (style.id === "__lapse-state-rules") continue;
1069
- allCss += style.textContent + "\n";
1070
- }
1071
- for (const sheet of document.styleSheets) {
1072
- try {
1073
- let walk2 = function(rules) {
1074
- for (const rule of rules) {
1075
- if (rule.cssRules) {
1076
- walk2(rule.cssRules);
1077
- continue;
1078
- }
1079
- const t = rule.cssText;
1080
- if (t && (t.includes(":hover") || t.includes(":focus"))) {
1081
- allCss += t + "\n";
1151
+ setTimeout(() => {
1152
+ try {
1153
+ const lapseStyle = document.createElement("style");
1154
+ lapseStyle.id = "__lapse-state-rules";
1155
+ const hoverFocusRules = [];
1156
+ for (const sheet of document.styleSheets) {
1157
+ try {
1158
+ const walk = (rules) => {
1159
+ for (const rule of rules) {
1160
+ if (rule.cssRules) {
1161
+ walk(rule.cssRules);
1162
+ continue;
1163
+ }
1164
+ const t = rule.cssText;
1165
+ if (t && (t.includes(":hover") || t.includes(":focus"))) {
1166
+ hoverFocusRules.push(t);
1167
+ }
1082
1168
  }
1083
- }
1084
- };
1085
- var walk = walk2;
1086
- walk2(sheet.cssRules);
1087
- } catch (_) {
1088
- }
1089
- }
1090
- const stateRegex = /([^{}]*(?::hover|:focus-visible|:focus-within|:focus(?!-))[^{}]*)\{([^{}]*)\}/g;
1091
- let match;
1092
- while ((match = stateRegex.exec(allCss)) !== null) {
1093
- const selector = match[1].trim();
1094
- const body = match[2].trim();
1095
- if (!body) continue;
1096
- const newBody = body.replace(
1097
- /([^;:]+):\s*([^;]+)(;|$)/g,
1098
- (m, prop, val, end) => {
1099
- if (val.includes("!important")) return m;
1100
- return prop + ": " + val.trim() + " !important" + end;
1169
+ };
1170
+ walk(sheet.cssRules);
1171
+ } catch (_) {
1101
1172
  }
1102
- );
1103
- if (selector.includes(":hover")) {
1104
- lapseStyle.textContent += selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }\n";
1105
- }
1106
- if (selector.includes(":focus-visible")) {
1107
- lapseStyle.textContent += selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }\n";
1108
- } else if (selector.includes(":focus-within")) {
1109
- lapseStyle.textContent += selector.replace(
1110
- /:focus-within/g,
1111
- ":has([data-lapse-focus])"
1112
- ) + " { " + newBody + " }\n";
1113
- } else if (selector.includes(":focus")) {
1114
- lapseStyle.textContent += selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }\n";
1115
1173
  }
1116
- }
1117
- document.head.appendChild(lapseStyle);
1118
- this.lapseStyleEl = lapseStyle;
1119
- } catch (_) {
1120
- }
1121
- if (this.frames.length > 0) {
1122
- const frame0 = this.frames[0];
1123
- if (frame0.elementSnapshots) {
1124
- for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
1125
- const el = this.elements.get(sel);
1126
- if (!el || !el.isConnected) continue;
1127
- if (snap.__styles) {
1128
- for (const [prop, value] of Object.entries(snap.__styles)) {
1129
- if (SAFE_PROPS_SET.has(prop)) {
1130
- el.style.setProperty(prop, value, "important");
1131
- }
1174
+ const parts = [];
1175
+ for (const ruleText of hoverFocusRules) {
1176
+ const braceIdx = ruleText.indexOf("{");
1177
+ if (braceIdx === -1) continue;
1178
+ const selector = ruleText.slice(0, braceIdx).trim();
1179
+ const bodyEnd = ruleText.lastIndexOf("}");
1180
+ const body = ruleText.slice(braceIdx + 1, bodyEnd).trim();
1181
+ if (!body) continue;
1182
+ const newBody = body.replace(
1183
+ /([^;:]+):\s*([^;]+)(;|$)/g,
1184
+ (m, prop, val, end) => {
1185
+ if (val.includes("!important")) return m;
1186
+ return prop + ": " + val.trim() + " !important" + end;
1132
1187
  }
1188
+ );
1189
+ if (selector.includes(":hover")) {
1190
+ parts.push(selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }");
1133
1191
  }
1134
- if (snap.__attrs) {
1135
- for (const [attr, value] of Object.entries(snap.__attrs)) {
1136
- if (attr === "checked") {
1137
- ;
1138
- el.checked = value === "true";
1139
- } else if (attr === "class" && value != null) {
1140
- el.className = value;
1141
- } else if (attr === "style") {
1142
- } else if (attr === "value" && value != null) {
1143
- ;
1144
- el.value = value;
1145
- } else if (value == null) {
1146
- el.removeAttribute(attr);
1147
- } else {
1148
- el.setAttribute(attr, value);
1149
- }
1150
- }
1192
+ if (selector.includes(":focus-visible")) {
1193
+ parts.push(selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }");
1194
+ } else if (selector.includes(":focus-within")) {
1195
+ parts.push(selector.replace(/:focus-within/g, ":has([data-lapse-focus])") + " { " + newBody + " }");
1196
+ } else if (selector.includes(":focus")) {
1197
+ parts.push(selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }");
1151
1198
  }
1152
1199
  }
1200
+ lapseStyle.textContent = parts.join("\n");
1201
+ document.head.appendChild(lapseStyle);
1202
+ this.lapseStyleEl = lapseStyle;
1203
+ } catch (_) {
1204
+ }
1205
+ }, 0);
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 (_) {
1153
1228
  }
1154
1229
  }
1155
1230
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1172,6 +1247,8 @@ var TimelineRecorder = _TimelineRecorder;
1172
1247
  // src/core/scrubber.ts
1173
1248
  var TimelineScrubber = class {
1174
1249
  constructor(state) {
1250
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1251
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1175
1252
  /** Saved originals for restore on release */
1176
1253
  this._originalAnimate = null;
1177
1254
  this._originalRaf = null;
@@ -1181,36 +1258,24 @@ var TimelineScrubber = class {
1181
1258
  this.frames = state.frames;
1182
1259
  this.capturedPortals = state.capturedPortals;
1183
1260
  this.interceptedAnimations = state.interceptedAnimations;
1184
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1261
+ this.seekableClones = state.seekableClones;
1185
1262
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1186
1263
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1187
1264
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1188
1265
  this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1189
- }
1190
- // ---------------------------------------------------------------------------
1191
- // Selector helper mirrors the recorder's getSelector so we can look up
1192
- // elements by the same key the recorder used in frame.animations[].animationId
1193
- // ---------------------------------------------------------------------------
1194
- getSelector(el) {
1195
- if (!el || !el.tagName) return null;
1196
- const parts = [];
1197
- let current = el;
1198
- for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
1199
- const tag = current.tagName.toLowerCase();
1200
- const parent = current.parentElement;
1201
- if (parent) {
1202
- const siblings = Array.from(parent.children);
1203
- const idx = siblings.indexOf(current) + 1;
1204
- parts.unshift(`${tag}:nth-child(${idx})`);
1205
- } else {
1206
- 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
+ }
1207
1274
  }
1208
- current = parent;
1209
1275
  }
1210
- return parts.join(" > ");
1211
1276
  }
1212
1277
  // ---------------------------------------------------------------------------
1213
- // seekTo — scrub the DOM to match a specific timestamp
1278
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1214
1279
  // ---------------------------------------------------------------------------
1215
1280
  seekTo(timeMs) {
1216
1281
  if (!this.frames.length) return;
@@ -1258,6 +1323,34 @@ var TimelineScrubber = class {
1258
1323
  }
1259
1324
  }
1260
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
+ }
1261
1354
  for (const entry of this.interceptedAnimations) {
1262
1355
  try {
1263
1356
  const anim = entry.animation;
@@ -1272,34 +1365,13 @@ var TimelineScrubber = class {
1272
1365
  const el = this.elements.get(sel);
1273
1366
  if (!el || !el.isConnected) continue;
1274
1367
  if (el.closest?.("[data-lapse-panel]")) continue;
1275
- const hasAnimation = (frame.animations || []).some(
1276
- (a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
1277
- );
1278
1368
  const snapTyped = snap;
1279
- if (snapTyped.__styles) {
1280
- for (const [prop, value] of Object.entries(snapTyped.__styles)) {
1281
- if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
1282
- el.style.setProperty(prop, value, "important");
1283
- }
1284
- }
1285
- }
1286
1369
  if (snapTyped.__attrs) {
1287
1370
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1371
+ if (attr === "class" || attr === "style") continue;
1288
1372
  if (attr === "checked") {
1289
1373
  ;
1290
1374
  el.checked = value === "true";
1291
- } else if (attr === "class") {
1292
- if (value != null) el.className = value;
1293
- } else if (attr === "style") {
1294
- if (value) {
1295
- el.setAttribute("style", value);
1296
- el.style.transition = "none";
1297
- if (snapTyped.__styles) {
1298
- for (const [prop, val] of Object.entries(snapTyped.__styles)) {
1299
- el.style.setProperty(prop, val, "important");
1300
- }
1301
- }
1302
- }
1303
1375
  } else if (attr === "value") {
1304
1376
  if (value != null) el.value = value;
1305
1377
  } else if (value == null) {
@@ -1310,39 +1382,31 @@ var TimelineScrubber = class {
1310
1382
  }
1311
1383
  }
1312
1384
  }
1313
- for (const anim of frame.animations || []) {
1314
- const firstColon = anim.animationId.indexOf(":");
1315
- const secondColon = anim.animationId.indexOf(":", firstColon + 1);
1316
- const animSel = secondColon >= 0 ? anim.animationId.substring(secondColon + 1) : "";
1317
- const animEl = this.elements.get(animSel);
1318
- if (!animEl || !animEl.isConnected) continue;
1319
- 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) {
1320
1393
  if (prop.value) {
1321
- animEl.style.setProperty(prop.property, prop.value, "important");
1394
+ el.style.setProperty(prop.property, prop.value, "important");
1322
1395
  }
1323
1396
  }
1324
1397
  }
1325
- const animatedSels = /* @__PURE__ */ new Set();
1326
- for (const anim of frame.animations || []) {
1327
- const fc = anim.animationId.indexOf(":");
1328
- const sc = anim.animationId.indexOf(":", fc + 1);
1329
- if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
1330
- }
1331
- document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
1332
- const el = rawEl;
1333
- const sel = this.getSelector(el);
1334
- if (sel && !animatedSels.has(sel)) {
1335
- el.style.removeProperty("opacity");
1336
- el.style.removeProperty("transform");
1337
- el.style.removeProperty("filter");
1338
- el.style.removeProperty("stroke-dashoffset");
1339
- }
1340
- });
1341
1398
  }
1342
1399
  // ---------------------------------------------------------------------------
1343
1400
  // release — tear down all scrub state and restore the page to normal
1344
1401
  // ---------------------------------------------------------------------------
1345
1402
  release() {
1403
+ for (const [, clone] of this.seekableClones) {
1404
+ try {
1405
+ clone.animation.cancel();
1406
+ } catch {
1407
+ }
1408
+ }
1409
+ this.seekableClones.clear();
1346
1410
  for (const entry of this.interceptedAnimations) {
1347
1411
  try {
1348
1412
  entry.animation.cancel();
@@ -1404,6 +1468,7 @@ var TimelineScrubber = class {
1404
1468
  this.elements.clear();
1405
1469
  this.frames.length = 0;
1406
1470
  this.capturedPortals.clear();
1471
+ this.animFrameRanges.clear();
1407
1472
  }
1408
1473
  };
1409
1474
 
@@ -1630,7 +1695,7 @@ function formatExportForLLM(exp, detail = "standard") {
1630
1695
  }
1631
1696
 
1632
1697
  // src/core/engine.ts
1633
- var LapseEngine = class {
1698
+ var SaccadeEngine = class {
1634
1699
  constructor() {
1635
1700
  this.timing = new TimingController();
1636
1701
  this.recorder = new TimelineRecorder();
@@ -1672,14 +1737,33 @@ var LapseEngine = class {
1672
1737
  boundingBox: null
1673
1738
  };
1674
1739
  }
1675
- const capture = this.recorder.stopRecording();
1740
+ let capture;
1741
+ try {
1742
+ capture = this.recorder.stopRecording();
1743
+ } catch (e) {
1744
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1745
+ document.getElementById("__lapse-scrub-blocker")?.remove();
1746
+ document.getElementById("__lapse-no-transitions")?.remove();
1747
+ document.getElementById("__lapse-state-rules")?.remove();
1748
+ this._state = "idle";
1749
+ this.notify();
1750
+ return {
1751
+ startTime: 0,
1752
+ endTime: 0,
1753
+ duration: 0,
1754
+ animations: [],
1755
+ frames: [],
1756
+ boundingBox: null
1757
+ };
1758
+ }
1676
1759
  this.capture = capture;
1677
1760
  const scrubberState = {
1678
1761
  elements: this.recorder.elements,
1679
1762
  frames: capture.frames,
1680
1763
  capturedPortals: this.recorder.capturedPortalIds,
1681
1764
  interceptedAnimations: this.recorder.interceptedAnimations,
1682
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1765
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1766
+ seekableClones: this.recorder.seekableClones
1683
1767
  };
1684
1768
  this.scrubber = new TimelineScrubber(scrubberState);
1685
1769
  this._state = "scrubbing";
@@ -1732,23 +1816,23 @@ var LapseEngine = class {
1732
1816
  }
1733
1817
  };
1734
1818
 
1735
- // src/react/LapseContext.tsx
1819
+ // src/react/SaccadeContext.tsx
1736
1820
  var import_jsx_runtime = require("react/jsx-runtime");
1737
- var LapseContext = (0, import_react.createContext)(null);
1738
- function LapseProvider({ children }) {
1821
+ var SaccadeContext = (0, import_react.createContext)(null);
1822
+ function SaccadeProvider({ children }) {
1739
1823
  const engineRef = (0, import_react.useRef)(null);
1740
1824
  if (!engineRef.current) {
1741
- engineRef.current = new LapseEngine();
1825
+ engineRef.current = new SaccadeEngine();
1742
1826
  }
1743
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LapseContext.Provider, { value: engineRef.current, children });
1827
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaccadeContext.Provider, { value: engineRef.current, children });
1744
1828
  }
1745
- function useLapseEngine() {
1746
- const engine = (0, import_react.useContext)(LapseContext);
1747
- if (!engine) throw new Error("useLapseEngine must be used within <LapseProvider>");
1829
+ function useSaccadeEngine() {
1830
+ const engine = (0, import_react.useContext)(SaccadeContext);
1831
+ if (!engine) throw new Error("useSaccadeEngine must be used within <SaccadeProvider>");
1748
1832
  return engine;
1749
1833
  }
1750
1834
 
1751
- // src/react/LapsePanel.tsx
1835
+ // src/react/SaccadePanel.tsx
1752
1836
  var import_react6 = require("react");
1753
1837
 
1754
1838
  // src/react/Timeline.tsx
@@ -2142,7 +2226,7 @@ function SpeedControl({ speed, isPaused, onSetSpeed, onTogglePause }) {
2142
2226
  var import_react4 = require("react");
2143
2227
  var DETAIL_LEVELS = ["compact", "standard", "detailed", "forensic"];
2144
2228
  function useTimeline() {
2145
- const engine = useLapseEngine();
2229
+ const engine = useSaccadeEngine();
2146
2230
  const state = (0, import_react4.useSyncExternalStore)(
2147
2231
  (cb) => engine.subscribe(cb),
2148
2232
  () => engine.state
@@ -2176,9 +2260,13 @@ function useTimeline() {
2176
2260
  [engine]
2177
2261
  );
2178
2262
  const stopRecording = (0, import_react4.useCallback)(() => {
2179
- const result = engine.stopRecording();
2180
- setCapture(result);
2181
- setScrubTime(0);
2263
+ try {
2264
+ const result = engine.stopRecording();
2265
+ setCapture(result);
2266
+ setScrubTime(0);
2267
+ } catch (e) {
2268
+ console.error("[Saccade] stopRecording failed:", e);
2269
+ }
2182
2270
  }, [engine]);
2183
2271
  const seek = (0, import_react4.useCallback)(
2184
2272
  (timeMs) => {
@@ -2247,7 +2335,7 @@ function useTimeline() {
2247
2335
  // src/react/useSpeed.ts
2248
2336
  var import_react5 = require("react");
2249
2337
  function useSpeed() {
2250
- const engine = useLapseEngine();
2338
+ const engine = useSaccadeEngine();
2251
2339
  const [speed, setSpeedState] = (0, import_react5.useState)(1);
2252
2340
  const [previousSpeed, setPreviousSpeed] = (0, import_react5.useState)(1);
2253
2341
  const [isPaused, setIsPaused] = (0, import_react5.useState)(false);
@@ -2310,9 +2398,9 @@ function useSpeed() {
2310
2398
  return { speed, isPaused, setSpeed, togglePause };
2311
2399
  }
2312
2400
 
2313
- // src/react/LapsePanel.tsx
2401
+ // src/react/SaccadePanel.tsx
2314
2402
  var import_jsx_runtime4 = require("react/jsx-runtime");
2315
- function LapsePanel() {
2403
+ function SaccadePanel() {
2316
2404
  const timeline = useTimeline();
2317
2405
  const { speed, isPaused, setSpeed, togglePause } = useSpeed();
2318
2406
  const panelRef = (0, import_react6.useRef)(null);
@@ -2772,17 +2860,18 @@ var PANEL_STYLES = (
2772
2860
  `
2773
2861
  );
2774
2862
 
2775
- // src/react/Lapse.tsx
2863
+ // src/react/Saccade.tsx
2776
2864
  var import_jsx_runtime5 = require("react/jsx-runtime");
2777
- function Lapse({ position = "bottom-left" }) {
2778
- const hostRef = (0, import_react7.useRef)(null);
2865
+ function Saccade({ position = "bottom-left" }) {
2779
2866
  const [shadowRoot, setShadowRoot] = (0, import_react7.useState)(null);
2867
+ const hostRef = (0, import_react7.useRef)(null);
2780
2868
  (0, import_react7.useEffect)(() => {
2781
- const host = hostRef.current;
2782
- if (!host || host.shadowRoot) {
2783
- if (host?.shadowRoot) setShadowRoot(host.shadowRoot);
2784
- return;
2785
- }
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;
2786
2875
  const shadow = host.attachShadow({ mode: "open" });
2787
2876
  const style = document.createElement("style");
2788
2877
  style.textContent = PANEL_STYLES;
@@ -2790,33 +2879,23 @@ function Lapse({ position = "bottom-left" }) {
2790
2879
  const mount = document.createElement("div");
2791
2880
  shadow.appendChild(mount);
2792
2881
  setShadowRoot(shadow);
2793
- }, []);
2794
- 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 };
2795
- return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
2796
- "div",
2797
- {
2798
- ref: hostRef,
2799
- "data-lapse-panel": "",
2800
- style: {
2801
- position: "fixed",
2802
- zIndex: 2147483647,
2803
- // max int — must sit above the scrub blocker (z-index: 999999)
2804
- pointerEvents: "auto",
2805
- ...positionOffset
2806
- },
2807
- children: shadowRoot && (0, import_react_dom.createPortal)(
2808
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(LapseProvider, { children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(LapsePanel, {}) }),
2809
- shadowRoot.lastElementChild || shadowRoot
2810
- )
2811
- }
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
2812
2891
  );
2813
2892
  }
2814
2893
  // Annotate the CommonJS export names for ESM import in node:
2815
2894
  0 && (module.exports = {
2816
- Lapse,
2817
- LapseEngine,
2818
- LapseProvider,
2819
- useLapseEngine,
2895
+ Saccade,
2896
+ SaccadeEngine,
2897
+ SaccadeProvider,
2898
+ useSaccadeEngine,
2820
2899
  useSpeed,
2821
2900
  useTimeline
2822
2901
  });