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/core.cjs CHANGED
@@ -20,7 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/core/index.ts
21
21
  var core_exports = {};
22
22
  __export(core_exports, {
23
- LapseEngine: () => LapseEngine,
23
+ SaccadeEngine: () => SaccadeEngine,
24
24
  TimelineRecorder: () => TimelineRecorder,
25
25
  TimelineScrubber: () => TimelineScrubber,
26
26
  TimingController: () => TimingController,
@@ -31,16 +31,21 @@ __export(core_exports, {
31
31
  module.exports = __toCommonJS(core_exports);
32
32
 
33
33
  // src/core/timing.ts
34
+ var MEDIA_RATE_MIN = 0.0625;
35
+ var MEDIA_RATE_MAX = 16;
36
+ var ZERO_SPEED_DIVISOR = 1e-4;
34
37
  var TimingController = class {
35
38
  constructor() {
36
39
  this.speed = 1;
37
40
  this.virtualBaseline = 0;
38
41
  this.intervalMap = /* @__PURE__ */ new Map();
39
42
  this.nextIntervalId = 1e6;
40
- this.mediaObserver = null;
41
- this.animObserver = null;
42
43
  this._origAnimate = null;
43
44
  this.installed = false;
45
+ this.animPollId = 0;
46
+ // WeakMap tracking for animations and media
47
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
48
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
44
49
  this._raf = requestAnimationFrame.bind(window);
45
50
  this._caf = cancelAnimationFrame.bind(window);
46
51
  this._setTimeout = setTimeout.bind(window);
@@ -50,19 +55,34 @@ var TimingController = class {
50
55
  this._perfNow = performance.now.bind(performance);
51
56
  this._dateNow = Date.now;
52
57
  this.realBaseline = this._perfNow();
58
+ this.dateRealBaseline = this._dateNow();
59
+ this.dateVirtualBaseline = this.dateRealBaseline;
53
60
  }
54
61
  getVirtualTime() {
55
62
  const realElapsed = this._perfNow() - this.realBaseline;
56
63
  return this.virtualBaseline + realElapsed * this.speed;
57
64
  }
65
+ getVirtualDateNow() {
66
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
67
+ return this.dateVirtualBaseline + realElapsed * this.speed;
68
+ }
58
69
  reanchor() {
59
70
  const virtualNow = this.getVirtualTime();
60
71
  this.realBaseline = this._perfNow();
61
72
  this.virtualBaseline = virtualNow;
73
+ const virtualDateNow = this.getVirtualDateNow();
74
+ this.dateRealBaseline = this._dateNow();
75
+ this.dateVirtualBaseline = virtualDateNow;
76
+ }
77
+ /** Effective speed divisor — avoids division by zero at speed=0. */
78
+ get speedDivisor() {
79
+ return this.speed || ZERO_SPEED_DIVISOR;
62
80
  }
63
81
  /** Install timing patches. Safe to call multiple times. */
64
82
  install() {
65
83
  if (this.installed) return;
84
+ if (window.__saccadeInstalled) return;
85
+ window.__saccadeInstalled = true;
66
86
  this.installed = true;
67
87
  const self = this;
68
88
  window.__LAPSE_ORIGINAL_RAF__ = this._raf;
@@ -71,21 +91,27 @@ var TimingController = class {
71
91
  Element.prototype.animate = function(...args) {
72
92
  const anim = origAnimate.apply(this, args);
73
93
  if (self.speed !== 1) {
74
- anim.playbackRate = self.speed || 1e-3;
94
+ const originalRate = anim.playbackRate;
95
+ const applied = originalRate * (self.speed || 1e-3);
96
+ anim.playbackRate = applied;
97
+ self.trackedAnims.set(anim, { original: originalRate, applied });
75
98
  }
76
99
  return anim;
77
100
  };
78
101
  performance.now = () => self.getVirtualTime();
79
- const dateBaseline = this._dateNow();
80
- Date.now = () => dateBaseline + self.getVirtualTime();
102
+ Date.now = () => self.getVirtualDateNow();
81
103
  window.requestAnimationFrame = (callback) => {
82
104
  return self._raf(() => {
105
+ if (self.speed === 0) {
106
+ window.requestAnimationFrame(callback);
107
+ return;
108
+ }
83
109
  callback(self.getVirtualTime());
84
110
  });
85
111
  };
86
112
  window.cancelAnimationFrame = this._caf;
87
113
  window.setTimeout = ((handler, delay, ...args) => {
88
- const scaledDelay = (delay ?? 0) / (self.speed || 1);
114
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
89
115
  return self._setTimeout(handler, scaledDelay, ...args);
90
116
  });
91
117
  window.clearTimeout = this._clearTimeout;
@@ -93,7 +119,7 @@ var TimingController = class {
93
119
  const id = self.nextIntervalId++;
94
120
  const baseDelay = delay ?? 0;
95
121
  function tick() {
96
- const scaledDelay = baseDelay / (self.speed || 1);
122
+ const scaledDelay = baseDelay / self.speedDivisor;
97
123
  const realId = self._setTimeout(() => {
98
124
  if (typeof handler === "function") {
99
125
  ;
@@ -118,61 +144,93 @@ var TimingController = class {
118
144
  self._clearInterval(id);
119
145
  }
120
146
  });
121
- this.mediaObserver = new MutationObserver((mutations) => {
122
- for (const mutation of mutations) {
123
- for (const node of mutation.addedNodes) {
124
- if (node instanceof HTMLVideoElement || node instanceof HTMLAudioElement) {
125
- node.playbackRate = self.speed;
126
- }
127
- }
128
- }
129
- });
130
- if (document.body) {
131
- this.mediaObserver.observe(document.body, { childList: true, subtree: true });
132
- }
147
+ this.startAnimationPoll();
133
148
  }
134
149
  /** Set playback speed. Requires install() first. */
135
150
  setSpeed(newSpeed) {
136
151
  if (!this.installed) this.install();
137
152
  this.reanchor();
138
153
  this.speed = newSpeed;
139
- document.querySelectorAll("video, audio").forEach((el) => {
140
- ;
141
- el.playbackRate = newSpeed;
142
- });
143
154
  this.patchAnimations();
155
+ this.patchMedia();
156
+ this.patchGSAP();
157
+ }
158
+ getSpeed() {
159
+ return this.speed;
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // Animation polling — per-frame via original rAF
163
+ // ---------------------------------------------------------------------------
164
+ startAnimationPoll() {
165
+ const poll = () => {
166
+ if (!this.installed) return;
167
+ this.patchAnimations();
168
+ this.patchMedia();
169
+ this.animPollId = this._raf(poll);
170
+ };
171
+ this.animPollId = this._raf(poll);
144
172
  }
145
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
173
+ /** Patch playbackRate on all active animations via WAAPI. */
146
174
  patchAnimations() {
147
175
  try {
148
176
  const anims = document.getAnimations();
149
177
  for (const anim of anims) {
150
178
  const target = anim.effect?.target;
151
179
  if (target?.closest?.("[data-lapse-panel]")) continue;
152
- anim.playbackRate = this.speed || 1e-3;
180
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
181
+ const effectiveSpeed = this.speed || 1e-3;
182
+ let tracked = this.trackedAnims.get(anim);
183
+ if (!tracked) {
184
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
185
+ this.trackedAnims.set(anim, tracked);
186
+ } else if (anim.playbackRate !== tracked.applied) {
187
+ tracked.original = anim.playbackRate;
188
+ }
189
+ const desired = tracked.original * effectiveSpeed;
190
+ if (anim.playbackRate !== desired) {
191
+ anim.playbackRate = desired;
192
+ tracked.applied = desired;
193
+ }
153
194
  }
154
195
  } catch {
155
196
  }
156
- if (!this.animObserver) {
157
- this.animObserver = this._setInterval(() => {
158
- if (!this.installed) return;
159
- try {
160
- const anims = document.getAnimations();
161
- for (const anim of anims) {
162
- const target = anim.effect?.target;
163
- if (target?.closest?.("[data-lapse-panel]")) continue;
164
- if (anim.playbackRate !== this.speed) {
165
- anim.playbackRate = this.speed || 1e-3;
166
- }
167
- }
168
- } catch {
197
+ }
198
+ /** Patch playbackRate on all video/audio elements. */
199
+ patchMedia() {
200
+ try {
201
+ document.querySelectorAll("video, audio").forEach((node) => {
202
+ const el = node;
203
+ if (el.closest?.("[data-lapse-panel]")) return;
204
+ if (el.closest?.("[data-saccade-exclude]")) return;
205
+ let tracked = this.trackedMedia.get(el);
206
+ if (!tracked) {
207
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
208
+ this.trackedMedia.set(el, tracked);
209
+ } else if (el.playbackRate !== tracked.applied) {
210
+ tracked.original = el.playbackRate;
169
211
  }
170
- }, 100);
212
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
213
+ if (el.playbackRate !== desired) {
214
+ el.playbackRate = desired;
215
+ tracked.applied = desired;
216
+ }
217
+ });
218
+ } catch {
171
219
  }
172
220
  }
173
- getSpeed() {
174
- return this.speed;
221
+ /** Sync GSAP's global timeline if present. */
222
+ patchGSAP() {
223
+ try {
224
+ const gsap = window.gsap;
225
+ if (gsap?.globalTimeline) {
226
+ gsap.globalTimeline.timeScale(this.speed || 1e-3);
227
+ }
228
+ } catch {
229
+ }
175
230
  }
231
+ // ---------------------------------------------------------------------------
232
+ // Cleanup
233
+ // ---------------------------------------------------------------------------
176
234
  /** Restore all patched APIs to originals. */
177
235
  destroy() {
178
236
  if (!this.installed) return;
@@ -192,19 +250,32 @@ var TimingController = class {
192
250
  Element.prototype.animate = this._origAnimate;
193
251
  this._origAnimate = null;
194
252
  }
195
- this.mediaObserver?.disconnect();
196
- this.mediaObserver = null;
197
- if (this.animObserver != null) {
198
- this._clearInterval(this.animObserver);
199
- this.animObserver = null;
253
+ if (this.animPollId) {
254
+ this._caf(this.animPollId);
255
+ this.animPollId = 0;
200
256
  }
201
257
  try {
202
258
  for (const anim of document.getAnimations()) {
203
- anim.playbackRate = 1;
259
+ const tracked = this.trackedAnims.get(anim);
260
+ anim.playbackRate = tracked?.original ?? 1;
204
261
  }
205
262
  } catch {
206
263
  }
264
+ try {
265
+ document.querySelectorAll("video, audio").forEach((node) => {
266
+ const el = node;
267
+ const tracked = this.trackedMedia.get(el);
268
+ el.playbackRate = tracked?.original ?? 1;
269
+ });
270
+ } catch {
271
+ }
272
+ try {
273
+ const gsap = window.gsap;
274
+ if (gsap?.globalTimeline) gsap.globalTimeline.timeScale(1);
275
+ } catch {
276
+ }
207
277
  delete window.__LAPSE_ORIGINAL_RAF__;
278
+ delete window.__saccadeInstalled;
208
279
  this.installed = false;
209
280
  }
210
281
  };
@@ -274,6 +345,10 @@ var SNAPSHOT_ATTRS = [
274
345
  "data-hover",
275
346
  "data-at-boundary",
276
347
  "data-scrubbing",
348
+ "data-starting-style",
349
+ "data-ending-style",
350
+ "data-panel-open",
351
+ "data-hidden",
277
352
  "aria-checked",
278
353
  "aria-selected",
279
354
  "aria-expanded",
@@ -287,6 +362,7 @@ var SNAPSHOT_ATTRS = [
287
362
  "checked",
288
363
  "disabled",
289
364
  "hidden",
365
+ "inert",
290
366
  "value",
291
367
  "class",
292
368
  "style"
@@ -418,6 +494,8 @@ var _TimelineRecorder = class _TimelineRecorder {
418
494
  // ---- WAAPI interception --------------------------------------------------
419
495
  /** Animations captured via Element.prototype.animate monkey-patch. */
420
496
  this.interceptedAnimations = [];
497
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
498
+ this.seekableClones = /* @__PURE__ */ new Map();
421
499
  this.hiddenSince = null;
422
500
  this.onVisibilityChange = null;
423
501
  /** Set to true when the capture loop self-terminates due to limits. */
@@ -468,6 +546,7 @@ var _TimelineRecorder = class _TimelineRecorder {
468
546
  this.portalIdCounter = 0;
469
547
  this.currentPortalIds.clear();
470
548
  this.capturedPortals.clear();
549
+ this.seekableClones.clear();
471
550
  this.prevInlineStyles.clear();
472
551
  this.jsAnimStartTimes.clear();
473
552
  this.jsAnimLastSeen.clear();
@@ -821,6 +900,13 @@ var _TimelineRecorder = class _TimelineRecorder {
821
900
  } catch (_) {
822
901
  }
823
902
  }
903
+ const cleanedKeyframes = keyframes2.map((kf) => {
904
+ const clean = {};
905
+ for (const [k, v] of Object.entries(kf)) {
906
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
907
+ }
908
+ return clean;
909
+ });
824
910
  this.animations.set(id, {
825
911
  id,
826
912
  name,
@@ -832,7 +918,9 @@ var _TimelineRecorder = class _TimelineRecorder {
832
918
  type,
833
919
  source,
834
920
  resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
835
- conflicts
921
+ conflicts,
922
+ rawKeyframes: cleanedKeyframes,
923
+ rawTiming: { ...timing, fill: "both" }
836
924
  });
837
925
  }
838
926
  const keyframes = a.effect?.getKeyframes?.() || [];
@@ -1053,96 +1141,83 @@ var _TimelineRecorder = class _TimelineRecorder {
1053
1141
  blocker.title = "Clear the timeline to interact with the page";
1054
1142
  document.body.appendChild(blocker);
1055
1143
  this.blockerEl = blocker;
1056
- try {
1057
- const lapseStyle = document.createElement("style");
1058
- lapseStyle.id = "__lapse-state-rules";
1059
- let allCss = "";
1060
- for (const style of document.querySelectorAll("style")) {
1061
- if (style.id === "__lapse-state-rules") continue;
1062
- allCss += style.textContent + "\n";
1063
- }
1064
- for (const sheet of document.styleSheets) {
1065
- try {
1066
- let walk2 = function(rules) {
1067
- for (const rule of rules) {
1068
- if (rule.cssRules) {
1069
- walk2(rule.cssRules);
1070
- continue;
1071
- }
1072
- const t = rule.cssText;
1073
- if (t && (t.includes(":hover") || t.includes(":focus"))) {
1074
- allCss += t + "\n";
1144
+ setTimeout(() => {
1145
+ try {
1146
+ const lapseStyle = document.createElement("style");
1147
+ lapseStyle.id = "__lapse-state-rules";
1148
+ const hoverFocusRules = [];
1149
+ for (const sheet of document.styleSheets) {
1150
+ try {
1151
+ const walk = (rules) => {
1152
+ for (const rule of rules) {
1153
+ if (rule.cssRules) {
1154
+ walk(rule.cssRules);
1155
+ continue;
1156
+ }
1157
+ const t = rule.cssText;
1158
+ if (t && (t.includes(":hover") || t.includes(":focus"))) {
1159
+ hoverFocusRules.push(t);
1160
+ }
1075
1161
  }
1076
- }
1077
- };
1078
- var walk = walk2;
1079
- walk2(sheet.cssRules);
1080
- } catch (_) {
1081
- }
1082
- }
1083
- const stateRegex = /([^{}]*(?::hover|:focus-visible|:focus-within|:focus(?!-))[^{}]*)\{([^{}]*)\}/g;
1084
- let match;
1085
- while ((match = stateRegex.exec(allCss)) !== null) {
1086
- const selector = match[1].trim();
1087
- const body = match[2].trim();
1088
- if (!body) continue;
1089
- const newBody = body.replace(
1090
- /([^;:]+):\s*([^;]+)(;|$)/g,
1091
- (m, prop, val, end) => {
1092
- if (val.includes("!important")) return m;
1093
- return prop + ": " + val.trim() + " !important" + end;
1162
+ };
1163
+ walk(sheet.cssRules);
1164
+ } catch (_) {
1094
1165
  }
1095
- );
1096
- if (selector.includes(":hover")) {
1097
- lapseStyle.textContent += selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }\n";
1098
1166
  }
1099
- if (selector.includes(":focus-visible")) {
1100
- lapseStyle.textContent += selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }\n";
1101
- } else if (selector.includes(":focus-within")) {
1102
- lapseStyle.textContent += selector.replace(
1103
- /:focus-within/g,
1104
- ":has([data-lapse-focus])"
1105
- ) + " { " + newBody + " }\n";
1106
- } else if (selector.includes(":focus")) {
1107
- lapseStyle.textContent += selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }\n";
1108
- }
1109
- }
1110
- document.head.appendChild(lapseStyle);
1111
- this.lapseStyleEl = lapseStyle;
1112
- } catch (_) {
1113
- }
1114
- if (this.frames.length > 0) {
1115
- const frame0 = this.frames[0];
1116
- if (frame0.elementSnapshots) {
1117
- for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
1118
- const el = this.elements.get(sel);
1119
- if (!el || !el.isConnected) continue;
1120
- if (snap.__styles) {
1121
- for (const [prop, value] of Object.entries(snap.__styles)) {
1122
- if (SAFE_PROPS_SET.has(prop)) {
1123
- el.style.setProperty(prop, value, "important");
1124
- }
1167
+ const parts = [];
1168
+ for (const ruleText of hoverFocusRules) {
1169
+ const braceIdx = ruleText.indexOf("{");
1170
+ if (braceIdx === -1) continue;
1171
+ const selector = ruleText.slice(0, braceIdx).trim();
1172
+ const bodyEnd = ruleText.lastIndexOf("}");
1173
+ const body = ruleText.slice(braceIdx + 1, bodyEnd).trim();
1174
+ if (!body) continue;
1175
+ const newBody = body.replace(
1176
+ /([^;:]+):\s*([^;]+)(;|$)/g,
1177
+ (m, prop, val, end) => {
1178
+ if (val.includes("!important")) return m;
1179
+ return prop + ": " + val.trim() + " !important" + end;
1125
1180
  }
1181
+ );
1182
+ if (selector.includes(":hover")) {
1183
+ parts.push(selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }");
1126
1184
  }
1127
- if (snap.__attrs) {
1128
- for (const [attr, value] of Object.entries(snap.__attrs)) {
1129
- if (attr === "checked") {
1130
- ;
1131
- el.checked = value === "true";
1132
- } else if (attr === "class" && value != null) {
1133
- el.className = value;
1134
- } else if (attr === "style") {
1135
- } else if (attr === "value" && value != null) {
1136
- ;
1137
- el.value = value;
1138
- } else if (value == null) {
1139
- el.removeAttribute(attr);
1140
- } else {
1141
- el.setAttribute(attr, value);
1142
- }
1143
- }
1185
+ if (selector.includes(":focus-visible")) {
1186
+ parts.push(selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }");
1187
+ } else if (selector.includes(":focus-within")) {
1188
+ parts.push(selector.replace(/:focus-within/g, ":has([data-lapse-focus])") + " { " + newBody + " }");
1189
+ } else if (selector.includes(":focus")) {
1190
+ parts.push(selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }");
1144
1191
  }
1145
1192
  }
1193
+ lapseStyle.textContent = parts.join("\n");
1194
+ document.head.appendChild(lapseStyle);
1195
+ this.lapseStyleEl = lapseStyle;
1196
+ } catch (_) {
1197
+ }
1198
+ }, 0);
1199
+ this.seekableClones.clear();
1200
+ for (const [animId, animInfo] of this.animations) {
1201
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1202
+ if (animInfo.type === "JSAnimation") continue;
1203
+ const firstColon = animId.indexOf(":");
1204
+ const secondColon = animId.indexOf(":", firstColon + 1);
1205
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1206
+ const el = this.elements.get(elSelector);
1207
+ if (!el?.isConnected) continue;
1208
+ try {
1209
+ const clone = el.animate(animInfo.rawKeyframes, {
1210
+ ...animInfo.rawTiming,
1211
+ fill: "both"
1212
+ });
1213
+ clone.pause();
1214
+ clone.currentTime = 0;
1215
+ this.seekableClones.set(animId, {
1216
+ animation: clone,
1217
+ element: el,
1218
+ effect: clone.effect
1219
+ });
1220
+ } catch (_) {
1146
1221
  }
1147
1222
  }
1148
1223
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1165,6 +1240,8 @@ var TimelineRecorder = _TimelineRecorder;
1165
1240
  // src/core/scrubber.ts
1166
1241
  var TimelineScrubber = class {
1167
1242
  constructor(state) {
1243
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1244
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1168
1245
  /** Saved originals for restore on release */
1169
1246
  this._originalAnimate = null;
1170
1247
  this._originalRaf = null;
@@ -1174,36 +1251,24 @@ var TimelineScrubber = class {
1174
1251
  this.frames = state.frames;
1175
1252
  this.capturedPortals = state.capturedPortals;
1176
1253
  this.interceptedAnimations = state.interceptedAnimations;
1177
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1254
+ this.seekableClones = state.seekableClones;
1178
1255
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1179
1256
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1180
1257
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1181
1258
  this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1182
- }
1183
- // ---------------------------------------------------------------------------
1184
- // Selector helper mirrors the recorder's getSelector so we can look up
1185
- // elements by the same key the recorder used in frame.animations[].animationId
1186
- // ---------------------------------------------------------------------------
1187
- getSelector(el) {
1188
- if (!el || !el.tagName) return null;
1189
- const parts = [];
1190
- let current = el;
1191
- for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
1192
- const tag = current.tagName.toLowerCase();
1193
- const parent = current.parentElement;
1194
- if (parent) {
1195
- const siblings = Array.from(parent.children);
1196
- const idx = siblings.indexOf(current) + 1;
1197
- parts.unshift(`${tag}:nth-child(${idx})`);
1198
- } else {
1199
- parts.unshift(tag);
1259
+ for (let i = 0; i < this.frames.length; i++) {
1260
+ for (const fa of this.frames[i].animations) {
1261
+ const range = this.animFrameRanges.get(fa.animationId);
1262
+ if (!range) {
1263
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1264
+ } else {
1265
+ range.last = i;
1266
+ }
1200
1267
  }
1201
- current = parent;
1202
1268
  }
1203
- return parts.join(" > ");
1204
1269
  }
1205
1270
  // ---------------------------------------------------------------------------
1206
- // seekTo — scrub the DOM to match a specific timestamp
1271
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1207
1272
  // ---------------------------------------------------------------------------
1208
1273
  seekTo(timeMs) {
1209
1274
  if (!this.frames.length) return;
@@ -1251,6 +1316,34 @@ var TimelineScrubber = class {
1251
1316
  }
1252
1317
  }
1253
1318
  }
1319
+ const activeAnimIds = /* @__PURE__ */ new Map();
1320
+ for (const fa of frame.animations || []) {
1321
+ activeAnimIds.set(fa.animationId, fa);
1322
+ }
1323
+ for (const [animId, clone] of this.seekableClones) {
1324
+ const frameAnim = activeAnimIds.get(animId);
1325
+ try {
1326
+ if (frameAnim) {
1327
+ if (!clone.animation.effect) {
1328
+ clone.animation.effect = clone.effect;
1329
+ }
1330
+ clone.animation.currentTime = frameAnim.currentTime;
1331
+ } else {
1332
+ const range = this.animFrameRanges.get(animId);
1333
+ if (!range || lo < range.first) {
1334
+ clone.animation.effect = null;
1335
+ } else {
1336
+ if (!clone.animation.effect) {
1337
+ clone.animation.effect = clone.effect;
1338
+ }
1339
+ const timing = clone.effect.getTiming();
1340
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1341
+ clone.animation.currentTime = endTime;
1342
+ }
1343
+ }
1344
+ } catch {
1345
+ }
1346
+ }
1254
1347
  for (const entry of this.interceptedAnimations) {
1255
1348
  try {
1256
1349
  const anim = entry.animation;
@@ -1265,34 +1358,13 @@ var TimelineScrubber = class {
1265
1358
  const el = this.elements.get(sel);
1266
1359
  if (!el || !el.isConnected) continue;
1267
1360
  if (el.closest?.("[data-lapse-panel]")) continue;
1268
- const hasAnimation = (frame.animations || []).some(
1269
- (a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
1270
- );
1271
1361
  const snapTyped = snap;
1272
- if (snapTyped.__styles) {
1273
- for (const [prop, value] of Object.entries(snapTyped.__styles)) {
1274
- if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
1275
- el.style.setProperty(prop, value, "important");
1276
- }
1277
- }
1278
- }
1279
1362
  if (snapTyped.__attrs) {
1280
1363
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1364
+ if (attr === "class" || attr === "style") continue;
1281
1365
  if (attr === "checked") {
1282
1366
  ;
1283
1367
  el.checked = value === "true";
1284
- } else if (attr === "class") {
1285
- if (value != null) el.className = value;
1286
- } else if (attr === "style") {
1287
- if (value) {
1288
- el.setAttribute("style", value);
1289
- el.style.transition = "none";
1290
- if (snapTyped.__styles) {
1291
- for (const [prop, val] of Object.entries(snapTyped.__styles)) {
1292
- el.style.setProperty(prop, val, "important");
1293
- }
1294
- }
1295
- }
1296
1368
  } else if (attr === "value") {
1297
1369
  if (value != null) el.value = value;
1298
1370
  } else if (value == null) {
@@ -1303,39 +1375,31 @@ var TimelineScrubber = class {
1303
1375
  }
1304
1376
  }
1305
1377
  }
1306
- for (const anim of frame.animations || []) {
1307
- const firstColon = anim.animationId.indexOf(":");
1308
- const secondColon = anim.animationId.indexOf(":", firstColon + 1);
1309
- const animSel = secondColon >= 0 ? anim.animationId.substring(secondColon + 1) : "";
1310
- const animEl = this.elements.get(animSel);
1311
- if (!animEl || !animEl.isConnected) continue;
1312
- for (const prop of anim.properties || []) {
1378
+ for (const fa of frame.animations || []) {
1379
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1380
+ const firstColon = fa.animationId.indexOf(":");
1381
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1382
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1383
+ const el = this.elements.get(elSel);
1384
+ if (!el || !el.isConnected) continue;
1385
+ for (const prop of fa.properties) {
1313
1386
  if (prop.value) {
1314
- animEl.style.setProperty(prop.property, prop.value, "important");
1387
+ el.style.setProperty(prop.property, prop.value, "important");
1315
1388
  }
1316
1389
  }
1317
1390
  }
1318
- const animatedSels = /* @__PURE__ */ new Set();
1319
- for (const anim of frame.animations || []) {
1320
- const fc = anim.animationId.indexOf(":");
1321
- const sc = anim.animationId.indexOf(":", fc + 1);
1322
- if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
1323
- }
1324
- document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
1325
- const el = rawEl;
1326
- const sel = this.getSelector(el);
1327
- if (sel && !animatedSels.has(sel)) {
1328
- el.style.removeProperty("opacity");
1329
- el.style.removeProperty("transform");
1330
- el.style.removeProperty("filter");
1331
- el.style.removeProperty("stroke-dashoffset");
1332
- }
1333
- });
1334
1391
  }
1335
1392
  // ---------------------------------------------------------------------------
1336
1393
  // release — tear down all scrub state and restore the page to normal
1337
1394
  // ---------------------------------------------------------------------------
1338
1395
  release() {
1396
+ for (const [, clone] of this.seekableClones) {
1397
+ try {
1398
+ clone.animation.cancel();
1399
+ } catch {
1400
+ }
1401
+ }
1402
+ this.seekableClones.clear();
1339
1403
  for (const entry of this.interceptedAnimations) {
1340
1404
  try {
1341
1405
  entry.animation.cancel();
@@ -1397,6 +1461,7 @@ var TimelineScrubber = class {
1397
1461
  this.elements.clear();
1398
1462
  this.frames.length = 0;
1399
1463
  this.capturedPortals.clear();
1464
+ this.animFrameRanges.clear();
1400
1465
  }
1401
1466
  };
1402
1467
 
@@ -1623,7 +1688,7 @@ function formatExportForLLM(exp, detail = "standard") {
1623
1688
  }
1624
1689
 
1625
1690
  // src/core/engine.ts
1626
- var LapseEngine = class {
1691
+ var SaccadeEngine = class {
1627
1692
  constructor() {
1628
1693
  this.timing = new TimingController();
1629
1694
  this.recorder = new TimelineRecorder();
@@ -1665,14 +1730,33 @@ var LapseEngine = class {
1665
1730
  boundingBox: null
1666
1731
  };
1667
1732
  }
1668
- const capture = this.recorder.stopRecording();
1733
+ let capture;
1734
+ try {
1735
+ capture = this.recorder.stopRecording();
1736
+ } catch (e) {
1737
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1738
+ document.getElementById("__lapse-scrub-blocker")?.remove();
1739
+ document.getElementById("__lapse-no-transitions")?.remove();
1740
+ document.getElementById("__lapse-state-rules")?.remove();
1741
+ this._state = "idle";
1742
+ this.notify();
1743
+ return {
1744
+ startTime: 0,
1745
+ endTime: 0,
1746
+ duration: 0,
1747
+ animations: [],
1748
+ frames: [],
1749
+ boundingBox: null
1750
+ };
1751
+ }
1669
1752
  this.capture = capture;
1670
1753
  const scrubberState = {
1671
1754
  elements: this.recorder.elements,
1672
1755
  frames: capture.frames,
1673
1756
  capturedPortals: this.recorder.capturedPortalIds,
1674
1757
  interceptedAnimations: this.recorder.interceptedAnimations,
1675
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1758
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1759
+ seekableClones: this.recorder.seekableClones
1676
1760
  };
1677
1761
  this.scrubber = new TimelineScrubber(scrubberState);
1678
1762
  this._state = "scrubbing";
@@ -1726,7 +1810,7 @@ var LapseEngine = class {
1726
1810
  };
1727
1811
  // Annotate the CommonJS export names for ESM import in node:
1728
1812
  0 && (module.exports = {
1729
- LapseEngine,
1813
+ SaccadeEngine,
1730
1814
  TimelineRecorder,
1731
1815
  TimelineScrubber,
1732
1816
  TimingController,