@hyperframes/studio 0.6.109 → 0.6.111

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.
Files changed (66) hide show
  1. package/dist/assets/hyperframes-player-BRjQ5a5_.js +418 -0
  2. package/dist/assets/index-B7S86vK1.js +370 -0
  3. package/dist/assets/index-DP8pPIk2.css +1 -0
  4. package/dist/assets/{index-SlMJloLR.js → index-_IV-vm9l.js} +1 -1
  5. package/dist/assets/{index-CFCe0Xvn.js → index-x0c2-zQN.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/package.json +8 -5
  8. package/src/App.tsx +133 -141
  9. package/src/components/StudioHeader.tsx +44 -0
  10. package/src/components/StudioOverlays.tsx +70 -0
  11. package/src/components/editor/SourceEditor.tsx +19 -16
  12. package/src/components/editor/gsapAnimationConstants.ts +24 -0
  13. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  14. package/src/components/editor/manualEditingAvailability.ts +25 -7
  15. package/src/components/storyboard/FramePoster.tsx +56 -0
  16. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  17. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  18. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  19. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  20. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  21. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  22. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  23. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  24. package/src/components/storyboard/StoryboardView.tsx +78 -0
  25. package/src/components/storyboard/frameStatus.ts +36 -0
  26. package/src/contexts/ViewModeContext.tsx +98 -0
  27. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  28. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  29. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  30. package/src/hooks/timelineEditingHelpers.ts +2 -0
  31. package/src/hooks/useAppHotkeys.ts +20 -0
  32. package/src/hooks/useDomEditCommits.ts +43 -14
  33. package/src/hooks/useDomEditSession.test.ts +41 -0
  34. package/src/hooks/useDomEditSession.ts +38 -6
  35. package/src/hooks/useDomGeometryCommits.ts +5 -9
  36. package/src/hooks/useElementLifecycleOps.ts +30 -0
  37. package/src/hooks/useGsapAnimationOps.ts +78 -51
  38. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  39. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  40. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  41. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  42. package/src/hooks/useGsapScriptCommits.ts +95 -40
  43. package/src/hooks/useGsapTweenCache.ts +0 -27
  44. package/src/hooks/useRazorSplit.ts +4 -10
  45. package/src/hooks/useSdkSession.test.ts +12 -0
  46. package/src/hooks/useSdkSession.ts +91 -25
  47. package/src/hooks/useStoryboard.ts +80 -0
  48. package/src/hooks/useTimelineEditing.ts +163 -68
  49. package/src/utils/gsapSoftReload.ts +14 -0
  50. package/src/utils/sdkCutover.gate.test.ts +61 -0
  51. package/src/utils/sdkCutover.test.ts +839 -0
  52. package/src/utils/sdkCutover.ts +465 -0
  53. package/src/utils/sdkOpMapping.ts +43 -0
  54. package/src/utils/sdkResolverShadow.test.ts +366 -0
  55. package/src/utils/sdkResolverShadow.ts +313 -0
  56. package/src/utils/timelineElementSplit.test.ts +61 -1
  57. package/src/utils/timelineElementSplit.ts +18 -0
  58. package/dist/assets/hyperframes-player-67pq7USK.js +0 -418
  59. package/dist/assets/index-BVqybwMG.css +0 -1
  60. package/dist/assets/index-ho_f4axK.js +0 -296
  61. package/src/utils/sdkShadow.test.ts +0 -606
  62. package/src/utils/sdkShadow.ts +0 -517
  63. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  64. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  65. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  66. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -1,11 +1,7 @@
1
1
  // Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale +
2
- // playback-start resolution); this PR adds guarded shadow-timing dispatches in
3
- // the move/resize .then() chains, which nudges several callbacks over the CC
4
- // threshold. The added branches are telemetry-only.
2
+ // playback-start resolution).
5
3
  // fallow-ignore-file complexity
6
4
  import { useCallback, useRef } from "react";
7
- import type { Composition } from "@hyperframes/sdk";
8
- import { runShadowDelete, runShadowTiming } from "../utils/sdkShadow";
9
5
  import type { TimelineElement } from "../player";
10
6
  import { usePlayerStore } from "../player";
11
7
  import { useRazorSplit } from "./useRazorSplit";
@@ -37,6 +33,8 @@ import {
37
33
  scaleGsapPositions,
38
34
  } from "./timelineEditingHelpers";
39
35
  import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
36
+ import { sdkTimingPersist } from "../utils/sdkCutover";
37
+ import type { Composition } from "@hyperframes/sdk";
40
38
 
41
39
  // ── Types ──
42
40
 
@@ -60,8 +58,10 @@ interface UseTimelineEditingOptions {
60
58
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
61
59
  uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
62
60
  isRecordingRef?: React.RefObject<boolean>;
63
- /** Stage 7 Step 3b: SDK session for shadow timing dispatch (server stays authoritative). */
61
+ /** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
64
62
  sdkSession?: Composition | null;
63
+ /** Resync the SDK session after a server-authoritative timeline write. */
64
+ forceReloadSdkSession?: () => void;
65
65
  }
66
66
 
67
67
  // ── Hook ──
@@ -80,6 +80,7 @@ export function useTimelineEditing({
80
80
  uploadProjectFiles,
81
81
  isRecordingRef,
82
82
  sdkSession,
83
+ forceReloadSdkSession,
83
84
  }: UseTimelineEditingOptions) {
84
85
  const projectIdRef = useRef(projectId);
85
86
  projectIdRef.current = projectId;
@@ -92,6 +93,7 @@ export function useTimelineEditing({
92
93
  element: TimelineElement,
93
94
  label: string,
94
95
  buildPatches: PersistTimelineEditInput["buildPatches"],
96
+ coalesceKey?: string,
95
97
  ): Promise<void> => {
96
98
  if (isRecordingRef?.current) {
97
99
  showToast("Cannot edit timeline while recording", "error");
@@ -99,19 +101,25 @@ export function useTimelineEditing({
99
101
  }
100
102
  const pid = projectIdRef.current;
101
103
  if (!pid) return Promise.resolve();
102
- const queued = editQueueRef.current.then(() =>
103
- persistTimelineEdit({
104
- projectId: pid,
105
- element,
106
- activeCompPath,
107
- label,
108
- buildPatches,
109
- writeProjectFile,
110
- recordEdit,
111
- domEditSaveTimestampRef,
112
- pendingTimelineEditPathRef,
113
- }),
114
- );
104
+ const queued = editQueueRef.current
105
+ .then(() =>
106
+ persistTimelineEdit({
107
+ projectId: pid,
108
+ element,
109
+ activeCompPath,
110
+ label,
111
+ buildPatches,
112
+ writeProjectFile,
113
+ recordEdit,
114
+ domEditSaveTimestampRef,
115
+ pendingTimelineEditPathRef,
116
+ coalesceKey,
117
+ }),
118
+ )
119
+ .then(() => {
120
+ // Server wrote the file; resync the stale in-memory SDK doc.
121
+ forceReloadSdkSession?.();
122
+ });
115
123
  editQueueRef.current = queued.catch((error) => {
116
124
  console.error(`[Timeline] Failed to persist: ${label}`, error);
117
125
  });
@@ -125,18 +133,20 @@ export function useTimelineEditing({
125
133
  pendingTimelineEditPathRef,
126
134
  showToast,
127
135
  isRecordingRef,
136
+ forceReloadSdkSession,
128
137
  ],
129
138
  );
130
139
 
140
+ // fallow-ignore-next-line complexity
131
141
  const handleTimelineElementMove = useCallback(
142
+ // fallow-ignore-next-line complexity
132
143
  (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
133
144
  patchIframeDomTiming(previewIframeRef.current, element, [
134
145
  ["data-start", formatTimelineAttributeNumber(updates.start)],
135
146
  ["data-track-index", String(updates.track)],
136
147
  ]);
137
- const delta = updates.start - element.start;
138
- const filePath = element.sourceFile || activeCompPath || "index.html";
139
- return enqueueEdit(element, "Move timeline clip", (original, target) => {
148
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
149
+ const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
140
150
  let patched = applyPatchByTarget(original, target, {
141
151
  type: "attribute",
142
152
  property: "start",
@@ -147,24 +157,63 @@ export function useTimelineEditing({
147
157
  property: "track-index",
148
158
  value: String(updates.track),
149
159
  });
150
- }).then(() => {
151
- if (sdkSession)
152
- runShadowTiming(sdkSession, element.hfId, {
153
- start: updates.start,
154
- trackIndex: updates.track,
155
- });
156
- const pid = projectIdRef.current;
157
- if (delta !== 0 && element.domId && pid) {
158
- return shiftGsapPositions(pid, filePath, element.domId, delta)
159
- .then(() => reloadPreview())
160
- .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
161
- }
162
- });
160
+ };
161
+ // Server-path fallback (no SDK session): persist the attr patch, then
162
+ // shift GSAP tween positions on the server and reload the preview — the
163
+ // SDK path folds both into setTiming, but the fallback must do them
164
+ // explicitly or the clip moves while its GSAP tweens stay put + the
165
+ // preview never refreshes. coalesceKey mirrors the SDK branch so undo
166
+ // granularity is identical on either path.
167
+ const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
168
+ const moveFallback = () =>
169
+ enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
170
+ const pid = projectIdRef.current;
171
+ const delta = updates.start - element.start;
172
+ if (delta !== 0 && element.domId && pid) {
173
+ return shiftGsapPositions(pid, targetPath, element.domId, delta)
174
+ .then(() => reloadPreview())
175
+ .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
176
+ }
177
+ return reloadPreview();
178
+ });
179
+ if (sdkSession && element.hfId) {
180
+ return sdkTimingPersist(
181
+ element.hfId,
182
+ targetPath,
183
+ { start: updates.start, trackIndex: updates.track },
184
+ sdkSession,
185
+ {
186
+ editHistory: { recordEdit },
187
+ writeProjectFile,
188
+ reloadPreview,
189
+ domEditSaveTimestampRef,
190
+ compositionPath: activeCompPath,
191
+ // Capture on-disk bytes as the undo `before` so undoing a timing move
192
+ // restores the file verbatim, not a normalized full-DOM re-emit.
193
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
194
+ },
195
+ { label: "Move timeline clip", coalesceKey },
196
+ ).then((handled) => {
197
+ if (!handled) return moveFallback();
198
+ });
199
+ }
200
+ return moveFallback();
163
201
  },
164
- [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession],
202
+ [
203
+ previewIframeRef,
204
+ enqueueEdit,
205
+ activeCompPath,
206
+ sdkSession,
207
+ recordEdit,
208
+ writeProjectFile,
209
+ reloadPreview,
210
+ domEditSaveTimestampRef,
211
+ ],
165
212
  );
166
213
 
214
+ // fallow-ignore-next-line complexity
167
215
  const handleTimelineElementResize = useCallback(
216
+ // fallow-ignore-next-line complexity
168
217
  (
169
218
  element: TimelineElement,
170
219
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
@@ -173,6 +222,9 @@ export function useTimelineEditing({
173
222
  ["data-start", formatTimelineAttributeNumber(updates.start)],
174
223
  ["data-duration", formatTimelineAttributeNumber(updates.duration)],
175
224
  ];
225
+ // Patch the live playback-start/media-start attr too, or a resize that
226
+ // trims the playback start leaves the preview showing the old in-point
227
+ // until the next reload (the persisted patch handles it via pbs below).
176
228
  if (updates.playbackStart != null) {
177
229
  const liveAttr =
178
230
  element.playbackStartAttr === "playback-start"
@@ -181,10 +233,8 @@ export function useTimelineEditing({
181
233
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
182
234
  }
183
235
  patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
184
- const filePath = element.sourceFile || activeCompPath || "index.html";
185
- const timingChanged =
186
- updates.start !== element.start || updates.duration !== element.duration;
187
- return enqueueEdit(element, "Resize timeline clip", (original, target) => {
236
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
237
+ const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
188
238
  const pbs = resolveResizePlaybackStart(original, target, element, updates);
189
239
  let patched = applyPatchByTarget(original, target, {
190
240
  type: "attribute",
@@ -204,34 +254,77 @@ export function useTimelineEditing({
204
254
  });
205
255
  }
206
256
  return patched;
207
- }).then(() => {
208
- if (sdkSession)
209
- runShadowTiming(sdkSession, element.hfId, {
210
- start: updates.start,
211
- duration: updates.duration,
212
- });
213
- const pid = projectIdRef.current;
214
- if (timingChanged && element.domId && pid) {
215
- return scaleGsapPositions(
216
- pid,
217
- filePath,
218
- element.domId,
219
- element.start,
220
- element.duration,
221
- updates.start,
222
- updates.duration,
223
- )
224
- .then(() => reloadPreview())
225
- .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
226
- }
227
- return reloadPreview();
228
- });
257
+ };
258
+ // SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field).
259
+ // The second clause fires because trimming the start of a clip that has a
260
+ // playback-start attribute implicitly shifts that in-point — which the SDK
261
+ // setTiming op can't express — so those resizes must take the server path.
262
+ const hasPbsAdjustment =
263
+ updates.playbackStart != null ||
264
+ (updates.start !== element.start && element.playbackStart != null);
265
+ // Server-path fallback: after persisting the attr patch, scale GSAP tween
266
+ // positions/durations on the server and reload the preview. The SDK path
267
+ // folds both into setTiming; the fallback must do them explicitly or the
268
+ // clip resizes while its GSAP tweens keep their old timing + the preview
269
+ // never refreshes. coalesceKey mirrors the SDK branch for undo parity.
270
+ const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
271
+ const timingChanged =
272
+ updates.start !== element.start || updates.duration !== element.duration;
273
+ const resizeFallback = () =>
274
+ enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
275
+ const pid = projectIdRef.current;
276
+ if (timingChanged && element.domId && pid) {
277
+ return scaleGsapPositions(
278
+ pid,
279
+ targetPath,
280
+ element.domId,
281
+ element.start,
282
+ element.duration,
283
+ updates.start,
284
+ updates.duration,
285
+ )
286
+ .then(() => reloadPreview())
287
+ .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
288
+ }
289
+ return reloadPreview();
290
+ });
291
+ if (sdkSession && element.hfId && !hasPbsAdjustment) {
292
+ return sdkTimingPersist(
293
+ element.hfId,
294
+ targetPath,
295
+ { start: updates.start, duration: updates.duration },
296
+ sdkSession,
297
+ {
298
+ editHistory: { recordEdit },
299
+ writeProjectFile,
300
+ reloadPreview,
301
+ domEditSaveTimestampRef,
302
+ compositionPath: activeCompPath,
303
+ // Capture on-disk bytes as the undo `before` so undoing a timing
304
+ // resize restores the file verbatim, not a normalized full-DOM re-emit.
305
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
306
+ },
307
+ { label: "Resize timeline clip", coalesceKey },
308
+ ).then((handled) => {
309
+ if (!handled) return resizeFallback();
310
+ });
311
+ }
312
+ return resizeFallback();
229
313
  },
230
- [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession],
314
+ [
315
+ previewIframeRef,
316
+ enqueueEdit,
317
+ activeCompPath,
318
+ sdkSession,
319
+ recordEdit,
320
+ writeProjectFile,
321
+ reloadPreview,
322
+ domEditSaveTimestampRef,
323
+ ],
231
324
  );
232
325
 
326
+ // fallow-ignore-next-line complexity
233
327
  const handleTimelineElementDelete = useCallback(
234
- // Pre-existing handler complexity, unchanged by this PR.
235
328
  // fallow-ignore-next-line complexity
236
329
  async (element: TimelineElement) => {
237
330
  if (isRecordingRef?.current) {
@@ -287,8 +380,8 @@ export function useTimelineEditing({
287
380
  timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
288
381
  );
289
382
  usePlayerStore.getState().setSelectedElementId(null);
383
+ forceReloadSdkSession?.();
290
384
  reloadPreview();
291
- if (sdkSession) runShadowDelete(sdkSession, element.hfId);
292
385
  showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
293
386
  } catch (error) {
294
387
  const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
@@ -304,12 +397,12 @@ export function useTimelineEditing({
304
397
  domEditSaveTimestampRef,
305
398
  reloadPreview,
306
399
  isRecordingRef,
307
- sdkSession,
400
+ forceReloadSdkSession,
308
401
  ],
309
402
  );
310
403
 
404
+ // fallow-ignore-next-line complexity
311
405
  const handleTimelineAssetDrop = useCallback(
312
- // Pre-existing handler complexity, unchanged by this PR.
313
406
  // fallow-ignore-next-line complexity
314
407
  async (
315
408
  assetPath: string,
@@ -373,6 +466,7 @@ export function useTimelineEditing({
373
466
  recordEdit,
374
467
  });
375
468
 
469
+ forceReloadSdkSession?.();
376
470
  reloadPreview();
377
471
  } catch (error) {
378
472
  const message =
@@ -389,11 +483,12 @@ export function useTimelineEditing({
389
483
  domEditSaveTimestampRef,
390
484
  reloadPreview,
391
485
  isRecordingRef,
486
+ forceReloadSdkSession,
392
487
  ],
393
488
  );
394
489
 
490
+ // fallow-ignore-next-line complexity
395
491
  const handleTimelineFileDrop = useCallback(
396
- // Pre-existing handler complexity, unchanged by this PR.
397
492
  // fallow-ignore-next-line complexity
398
493
  async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
399
494
  if (isRecordingRef?.current) {
@@ -31,6 +31,19 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
31
31
  return results;
32
32
  }
33
33
 
34
+ /**
35
+ * Extract the GSAP timeline script text from a serialized HTML document, for
36
+ * feeding into applySoftReload. Returns null when zero or multiple GSAP scripts
37
+ * are present (ambiguous — caller should fall back to a full reload), matching
38
+ * applySoftReload's own single-script requirement.
39
+ */
40
+ export function extractGsapScriptText(html: string): string | null {
41
+ const doc = new DOMParser().parseFromString(html, "text/html");
42
+ const scripts = findGsapScriptElements(doc);
43
+ if (scripts.length !== 1) return null;
44
+ return scripts[0].textContent || null;
45
+ }
46
+
34
47
  /** Check that the new script repopulated __timelines with at least one entry. */
35
48
  function verifyTimelinesPopulated(win: IframeWindow): boolean {
36
49
  const tlKeys = win.__timelines
@@ -73,6 +86,7 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
73
86
  // full iframe reload that destroys the very WebGL context we're preserving.
74
87
  let deferredToAsync = false;
75
88
 
89
+ // fallow-ignore-next-line complexity
76
90
  const doReload = () => {
77
91
  const timelines = win.__timelines;
78
92
  const allTargets: Element[] = [];
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ // Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
4
+ // persist chokepoint must return false so the caller takes the legacy server
5
+ // path — even when a valid SDK session exists (one always does, for
6
+ // shadow/selection). This is the contract the prod flag-flip rests on; a future
7
+ // refactor of the gate guards that silently re-enables cutover on flag-off
8
+ // turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
9
+ vi.mock("../components/editor/manualEditingAvailability", () => ({
10
+ STUDIO_SDK_CUTOVER_ENABLED: false,
11
+ STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
12
+ }));
13
+ vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
14
+
15
+ import { sdkTimingPersist, sdkGsapTweenPersist, sdkDeletePersist } from "./sdkCutover";
16
+
17
+ const makeSession = () =>
18
+ ({
19
+ getElement: () => ({ inlineStyles: {} }),
20
+ serialize: () => "<html></html>",
21
+ batch: (fn: () => void) => fn(),
22
+ setTiming: vi.fn(),
23
+ dispatch: vi.fn(),
24
+ }) as never;
25
+
26
+ const makeDeps = () =>
27
+ ({
28
+ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
29
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
30
+ reloadPreview: vi.fn(),
31
+ domEditSaveTimestampRef: { current: 0 },
32
+ }) as never;
33
+
34
+ describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist returns false", () => {
35
+ it("sdkTimingPersist falls back without writing", async () => {
36
+ const deps = makeDeps();
37
+ expect(await sdkTimingPersist("hf-a", "/c.html", { start: 1 }, makeSession(), deps)).toBe(
38
+ false,
39
+ );
40
+ expect(
41
+ (deps as unknown as { writeProjectFile: ReturnType<typeof vi.fn> }).writeProjectFile,
42
+ ).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it("sdkGsapTweenPersist (shared GSAP-op chokepoint) falls back", async () => {
46
+ expect(
47
+ await sdkGsapTweenPersist(
48
+ "/c.html",
49
+ { kind: "remove", animationId: "a" },
50
+ makeSession(),
51
+ makeDeps(),
52
+ ),
53
+ ).toBe(false);
54
+ });
55
+
56
+ it("sdkDeletePersist falls back", async () => {
57
+ expect(
58
+ await sdkDeletePersist("hf-a", "<html></html>", "/c.html", makeSession(), makeDeps()),
59
+ ).toBe(false);
60
+ });
61
+ });