@hyperframes/studio 0.7.58 → 0.7.59

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 (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -6,6 +6,9 @@ import {
6
6
  sdkTimingPersist,
7
7
  sdkGsapTweenPersist,
8
8
  sdkGsapKeyframePersist,
9
+ cutoverCommittedOrThrow,
10
+ persistSdkCandidateMutation,
11
+ persistSdkSerialize,
9
12
  } from "./sdkCutover";
10
13
  import { openComposition } from "@hyperframes/sdk";
11
14
  import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
@@ -44,6 +47,14 @@ const htmlAttrOp = (property: string, value: string): PatchOperation => ({
44
47
  value,
45
48
  });
46
49
 
50
+ const candidateTestDeps = () => ({
51
+ publishSession: vi.fn(),
52
+ createCandidateSession: async (
53
+ _serialized: string,
54
+ live: Parameters<typeof sdkCutoverPersist>[4],
55
+ ) => live!,
56
+ });
57
+
47
58
  describe("shouldUseSdkCutover", () => {
48
59
  it("returns false when flag disabled", () => {
49
60
  expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
@@ -148,6 +159,7 @@ describe("sdkCutoverPersist", () => {
148
159
  writeProjectFile: vi.fn().mockResolvedValue(undefined),
149
160
  reloadPreview: vi.fn(),
150
161
  domEditSaveTimestampRef: makeRef(0),
162
+ ...candidateTestDeps(),
151
163
  ...overrides,
152
164
  });
153
165
 
@@ -175,7 +187,7 @@ describe("sdkCutoverPersist", () => {
175
187
  null,
176
188
  deps,
177
189
  );
178
- expect(result).toBe(false);
190
+ expect(result.status).toBe("declined");
179
191
  });
180
192
 
181
193
  it("returns false when element not found in session", async () => {
@@ -190,7 +202,7 @@ describe("sdkCutoverPersist", () => {
190
202
  session,
191
203
  deps,
192
204
  );
193
- expect(result).toBe(false);
205
+ expect(result.status).toBe("declined");
194
206
  });
195
207
 
196
208
  it("dispatches setStyle for inline-style ops", async () => {
@@ -205,7 +217,7 @@ describe("sdkCutoverPersist", () => {
205
217
  session,
206
218
  deps,
207
219
  );
208
- expect(result).toBe(true);
220
+ expect(result.status).toBe("committed");
209
221
  expect(session!.dispatch).toHaveBeenCalledWith({
210
222
  type: "setStyle",
211
223
  target: "hf-abc",
@@ -227,7 +239,7 @@ describe("sdkCutoverPersist", () => {
227
239
  session,
228
240
  deps,
229
241
  );
230
- expect(result).toBe(true);
242
+ expect(result.status).toBe("committed");
231
243
  expect(session!.dispatch).toHaveBeenCalledWith({
232
244
  type: "setText",
233
245
  target: "hf-abc",
@@ -251,7 +263,7 @@ describe("sdkCutoverPersist", () => {
251
263
  session,
252
264
  deps,
253
265
  );
254
- expect(result).toBe(false);
266
+ expect(result.status).toBe("declined");
255
267
  expect(session!.dispatch).not.toHaveBeenCalled();
256
268
  expect(deps.writeProjectFile).not.toHaveBeenCalled();
257
269
  });
@@ -268,7 +280,7 @@ describe("sdkCutoverPersist", () => {
268
280
  session,
269
281
  deps,
270
282
  );
271
- expect(result).toBe(true);
283
+ expect(result.status).toBe("committed");
272
284
  expect(session!.dispatch).toHaveBeenCalledWith({
273
285
  type: "setAttribute",
274
286
  target: "hf-abc",
@@ -289,7 +301,7 @@ describe("sdkCutoverPersist", () => {
289
301
  session,
290
302
  deps,
291
303
  );
292
- expect(result).toBe(true);
304
+ expect(result.status).toBe("committed");
293
305
  expect(session!.dispatch).toHaveBeenCalledWith({
294
306
  type: "setAttribute",
295
307
  target: "hf-abc",
@@ -337,7 +349,7 @@ describe("sdkCutoverPersist", () => {
337
349
  session,
338
350
  deps,
339
351
  );
340
- expect(result).toBe(false);
352
+ expect(result.status).toBe("failed");
341
353
  expect(deps.reloadPreview).not.toHaveBeenCalled();
342
354
  });
343
355
 
@@ -376,12 +388,430 @@ describe("sdkCutoverPersist", () => {
376
388
  session,
377
389
  deps,
378
390
  );
379
- expect(result).toBe(false);
391
+ expect(result.status).toBe("failed");
380
392
  expect(deps.writeProjectFile).not.toHaveBeenCalled();
381
393
  expect(deps.reloadPreview).not.toHaveBeenCalled();
382
394
  });
383
395
  });
384
396
 
397
+ describe("transactional SDK candidate publication", () => {
398
+ const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
399
+ <div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box" data-start="0" data-duration="1"></div>
400
+ <script>var tl = gsap.timeline({ paused: true });
401
+ tl.to('[data-hf-id="hf-box"]', { duration: 1, x: 100 }, 0);
402
+ window.__timelines = { main: tl };</script></div>
403
+ </body></html>`;
404
+
405
+ it.each([
406
+ [
407
+ "style",
408
+ (session: Awaited<ReturnType<typeof openComposition>>) =>
409
+ session.setStyle("hf-box", { color: "red" }),
410
+ ],
411
+ [
412
+ "timing",
413
+ (session: Awaited<ReturnType<typeof openComposition>>) =>
414
+ session.setTiming("hf-box", { start: 2 }),
415
+ ],
416
+ [
417
+ "delete",
418
+ (session: Awaited<ReturnType<typeof openComposition>>) => session.removeElement("hf-box"),
419
+ ],
420
+ [
421
+ "variables",
422
+ (session: Awaited<ReturnType<typeof openComposition>>) =>
423
+ session.declareVariable({ id: "title", type: "string", label: "Title", default: "Hello" }),
424
+ ],
425
+ [
426
+ "grouping/structure",
427
+ (session: Awaited<ReturnType<typeof openComposition>>) =>
428
+ session.addElement(null, 0, '<div data-hf-group="group-1"></div>'),
429
+ ],
430
+ [
431
+ "GSAP",
432
+ (session: Awaited<ReturnType<typeof openComposition>>) => {
433
+ const animationId = session.getElement("hf-box")?.animationIds[0];
434
+ if (!animationId) throw new Error("missing fixture animation");
435
+ session.setGsapTween(animationId, { ease: "power2.in" });
436
+ },
437
+ ],
438
+ ])(
439
+ "restores disk and keeps the live session unchanged when %s history fails",
440
+ async (_name, mutate) => {
441
+ const live = await openComposition(html, { history: false });
442
+ const liveBefore = live.serialize();
443
+ let disk = html;
444
+ const publishSession = vi.fn();
445
+ const result = await persistSdkCandidateMutation(
446
+ live,
447
+ "/comp.html",
448
+ html,
449
+ {
450
+ editHistory: { recordEdit: vi.fn().mockRejectedValue(new Error("history failed")) },
451
+ writeProjectFile: vi.fn(async (_path: string, content: string) => {
452
+ disk = content;
453
+ }),
454
+ reloadPreview: vi.fn(),
455
+ domEditSaveTimestampRef: { current: 0 },
456
+ publishSession,
457
+ },
458
+ mutate,
459
+ { label: `Edit ${_name}` },
460
+ );
461
+
462
+ expect(result.status).toBe("failed");
463
+ expect(disk).toBe(html);
464
+ expect(live.serialize()).toBe(liveBefore);
465
+ expect(publishSession).not.toHaveBeenCalled();
466
+ expect(() => cutoverCommittedOrThrow(result)).toThrow("history failed");
467
+ live.dispose();
468
+ },
469
+ );
470
+
471
+ it("disposes a mutated real candidate and leaves the live session unpublished when the write fails", async () => {
472
+ const live = await openComposition(html, { history: false });
473
+ const liveBefore = live.serialize();
474
+ let candidate: Awaited<ReturnType<typeof openComposition>> | undefined;
475
+ let disposeCandidate: ReturnType<typeof vi.spyOn> | undefined;
476
+ const publishSession = vi.fn().mockReturnValue("published");
477
+ const writeError = new Error("write failed");
478
+ const writeProjectFile = vi.fn().mockRejectedValue(writeError);
479
+ const result = await persistSdkCandidateMutation(
480
+ live,
481
+ "/comp.html",
482
+ html,
483
+ {
484
+ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
485
+ writeProjectFile,
486
+ reloadPreview: vi.fn(),
487
+ domEditSaveTimestampRef: { current: 0 },
488
+ createCandidateSession: async (source) => {
489
+ candidate = await openComposition(source, { history: false });
490
+ disposeCandidate = vi.spyOn(candidate, "dispose");
491
+ return candidate;
492
+ },
493
+ publishSession,
494
+ },
495
+ (next) => next.setStyle("hf-box", { color: "red" }),
496
+ );
497
+
498
+ expect(result).toMatchObject({ status: "failed", error: writeError });
499
+ expect(writeProjectFile).toHaveBeenCalledOnce();
500
+ expect(writeProjectFile.mock.calls[0]?.[0]).toBe("/comp.html");
501
+ expect(writeProjectFile.mock.calls[0]?.[1]).toContain("color: red");
502
+ expect(live.serialize()).toBe(liveBefore);
503
+ expect(publishSession).not.toHaveBeenCalled();
504
+ expect(disposeCandidate).toHaveBeenCalledOnce();
505
+ live.dispose();
506
+ });
507
+
508
+ it("publishes the candidate only after write and history commit", async () => {
509
+ const live = await openComposition(html, { history: false });
510
+ const liveBefore = live.serialize();
511
+ const order: string[] = [];
512
+ let published: Awaited<ReturnType<typeof openComposition>> | undefined;
513
+ const result = await persistSdkCandidateMutation(
514
+ live,
515
+ "/comp.html",
516
+ html,
517
+ {
518
+ editHistory: {
519
+ recordEdit: vi.fn(async () => {
520
+ order.push("history");
521
+ }),
522
+ },
523
+ writeProjectFile: vi.fn(async () => {
524
+ order.push("write");
525
+ }),
526
+ reloadPreview: vi.fn(() => order.push("refresh")),
527
+ domEditSaveTimestampRef: { current: 0 },
528
+ publishSession: ({ candidate }) => {
529
+ order.push("publish");
530
+ published = candidate;
531
+ return "published";
532
+ },
533
+ },
534
+ (candidate) => candidate.setStyle("hf-box", { color: "red" }),
535
+ );
536
+
537
+ expect(result.status).toBe("committed");
538
+ expect(order).toEqual(["write", "history", "publish", "refresh"]);
539
+ expect(live.serialize()).toBe(liveBefore);
540
+ expect(published?.serialize()).toContain("color: red");
541
+ live.dispose();
542
+ published?.dispose();
543
+ });
544
+
545
+ it("keeps the durable commit authoritative when a publisher throws after publication", async () => {
546
+ const live = await openComposition(html, { history: false });
547
+ let disk = html;
548
+ let published: Awaited<ReturnType<typeof openComposition>> | undefined;
549
+ const recordEdit = vi.fn().mockResolvedValue(undefined);
550
+ const writeProjectFile = vi.fn(async (_path: string, content: string) => {
551
+ disk = content;
552
+ });
553
+ const result = await persistSdkCandidateMutation(
554
+ live,
555
+ "/comp.html",
556
+ html,
557
+ {
558
+ editHistory: { recordEdit },
559
+ writeProjectFile,
560
+ reloadPreview: vi.fn(),
561
+ domEditSaveTimestampRef: { current: 0 },
562
+ publishSession: ({ candidate }) => {
563
+ published = candidate;
564
+ throw new Error("cleanup after publish failed");
565
+ },
566
+ },
567
+ (candidate) => candidate.setStyle("hf-box", { color: "red" }),
568
+ );
569
+
570
+ expect(result.status).toBe("committed");
571
+ expect(disk).toContain("color: red");
572
+ expect(writeProjectFile).toHaveBeenCalledTimes(1);
573
+ expect(recordEdit).toHaveBeenCalledTimes(1);
574
+ expect(published?.serialize()).toContain("color: red");
575
+ live.dispose();
576
+ published?.dispose();
577
+ });
578
+
579
+ it("serializes overlapping SDK edits and rebases each candidate on the latest disk bytes", async () => {
580
+ const live = await openComposition(html, { history: false });
581
+ let disk = html;
582
+ const published: Array<Awaited<ReturnType<typeof openComposition>>> = [];
583
+ const writeProjectFile = vi.fn(async (_path: string, content: string) => {
584
+ // Yield inside the write to make overlap deterministic.
585
+ await Promise.resolve();
586
+ disk = content;
587
+ });
588
+ const deps = {
589
+ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
590
+ writeProjectFile,
591
+ readProjectFile: vi.fn(async () => disk),
592
+ reloadPreview: vi.fn(),
593
+ domEditSaveTimestampRef: { current: 0 },
594
+ publishSession: ({ candidate }) => {
595
+ published.push(candidate);
596
+ return "published";
597
+ },
598
+ };
599
+
600
+ const [first, second] = await Promise.all([
601
+ persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) =>
602
+ candidate.setStyle("hf-box", { color: "red" }),
603
+ ),
604
+ persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) =>
605
+ candidate.setStyle("hf-box", { backgroundColor: "blue" }),
606
+ ),
607
+ ]);
608
+
609
+ expect(first.status).toBe("committed");
610
+ expect(second.status).toBe("committed");
611
+ expect(disk).toContain("color: red");
612
+ expect(disk).toContain("background-color: blue");
613
+ expect(writeProjectFile).toHaveBeenCalledTimes(2);
614
+ live.dispose();
615
+ for (const candidate of published) candidate.dispose();
616
+ });
617
+
618
+ it("fails instead of cloning stale bytes when the authoritative queued read rejects", async () => {
619
+ const live = await openComposition(html, { history: false });
620
+ let disk = html;
621
+ let readCount = 0;
622
+ const writeProjectFile = vi.fn(async (_path: string, content: string) => {
623
+ disk = content;
624
+ });
625
+ const deps = {
626
+ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
627
+ writeProjectFile,
628
+ readProjectFile: vi.fn(async () => {
629
+ readCount++;
630
+ if (readCount === 1) return disk;
631
+ throw new Error("transient read failure");
632
+ }),
633
+ reloadPreview: vi.fn(),
634
+ domEditSaveTimestampRef: { current: 0 },
635
+ publishSession: vi.fn().mockReturnValue("published"),
636
+ };
637
+
638
+ const first = await persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) =>
639
+ candidate.setStyle("hf-box", { color: "red" }),
640
+ );
641
+ const second = await persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) =>
642
+ candidate.setStyle("hf-box", { backgroundColor: "blue" }),
643
+ );
644
+
645
+ expect(first.status).toBe("committed");
646
+ expect(second).toMatchObject({ status: "failed", error: new Error("transient read failure") });
647
+ expect(disk).toContain("color: red");
648
+ expect(disk).not.toContain("background-color: blue");
649
+ expect(writeProjectFile).toHaveBeenCalledTimes(1);
650
+ live.dispose();
651
+ });
652
+
653
+ it("does not publish a delayed candidate after the active composition switches", async () => {
654
+ const liveA = await openComposition(html, { history: false });
655
+ const liveB = await openComposition(html.replace("hf-box", "hf-other"), { history: false });
656
+ const candidateA = await openComposition(html, { history: false });
657
+ const disposeCandidate = vi.spyOn(candidateA, "dispose");
658
+ let activePath = "/a.html";
659
+ let currentSession = liveA;
660
+ let releaseHistory: (() => void) | undefined;
661
+ const historyStarted = new Promise<void>((resolve) => {
662
+ releaseHistory = resolve;
663
+ });
664
+ let notifyHistoryStarted: (() => void) | undefined;
665
+ const didStartHistory = new Promise<void>((resolve) => {
666
+ notifyHistoryStarted = resolve;
667
+ });
668
+ const published: Array<Awaited<ReturnType<typeof openComposition>>> = [];
669
+ const refresh = vi.fn();
670
+ const pending = persistSdkCandidateMutation(
671
+ liveA,
672
+ "/a.html",
673
+ html,
674
+ {
675
+ editHistory: {
676
+ recordEdit: vi.fn(async () => {
677
+ notifyHistoryStarted?.();
678
+ await historyStarted;
679
+ }),
680
+ },
681
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
682
+ readProjectFile: vi.fn().mockResolvedValue(html),
683
+ reloadPreview: vi.fn(),
684
+ refresh,
685
+ domEditSaveTimestampRef: { current: 0 },
686
+ createCandidateSession: vi.fn().mockResolvedValue(candidateA),
687
+ publishSession: ({ candidate, expectedSession, targetPath }) => {
688
+ if (activePath !== targetPath || currentSession !== expectedSession) {
689
+ return "rejected-inactive-target";
690
+ }
691
+ currentSession = candidate;
692
+ published.push(candidate);
693
+ return "published";
694
+ },
695
+ },
696
+ (candidate) => candidate.setStyle("hf-box", { color: "red" }),
697
+ );
698
+
699
+ await didStartHistory;
700
+ activePath = "/b.html";
701
+ currentSession = liveB;
702
+ releaseHistory?.();
703
+
704
+ expect((await pending).status).toBe("committed");
705
+ expect(currentSession).toBe(liveB);
706
+ expect(published).toHaveLength(0);
707
+ expect(disposeCandidate).toHaveBeenCalledOnce();
708
+ expect(refresh).not.toHaveBeenCalled();
709
+ liveA.dispose();
710
+ liveB.dispose();
711
+ });
712
+ });
713
+
714
+ describe("persistSdkSerialize — shared per-file transaction boundary", () => {
715
+ const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
716
+ <div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box" data-start="0" data-duration="1"></div></div>
717
+ </body></html>`;
718
+
719
+ const makeDeps = (disk: { current: string }) => ({
720
+ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
721
+ writeProjectFile: vi.fn(async (_path: string, content: string) => {
722
+ disk.current = content;
723
+ }),
724
+ readProjectFile: vi.fn(async () => disk.current),
725
+ reloadPreview: vi.fn(),
726
+ domEditSaveTimestampRef: { current: 0 },
727
+ });
728
+
729
+ it("rebases overlapping whole-file transforms on the latest committed bytes", async () => {
730
+ const disk = { current: "<html><body></body></html>" };
731
+ let releaseFirstWrite: (() => void) | undefined;
732
+ const firstWriteStarted = new Promise<void>((resolve) => {
733
+ releaseFirstWrite = resolve;
734
+ });
735
+ let notifyFirstWriteStarted: (() => void) | undefined;
736
+ const didStartFirstWrite = new Promise<void>((resolve) => {
737
+ notifyFirstWriteStarted = resolve;
738
+ });
739
+ const deps = makeDeps(disk);
740
+ deps.writeProjectFile.mockImplementationOnce(async (_path: string, content: string) => {
741
+ notifyFirstWriteStarted?.();
742
+ await firstWriteStarted;
743
+ disk.current = content;
744
+ });
745
+
746
+ const first = persistSdkSerialize(
747
+ (before) => before.replace("</body>", "<div>A</div></body>"),
748
+ "/comp.html",
749
+ disk.current,
750
+ deps,
751
+ );
752
+ await didStartFirstWrite;
753
+ const second = persistSdkSerialize(
754
+ (before) => before.replace("</body>", "<div>B</div></body>"),
755
+ "/comp.html",
756
+ disk.current,
757
+ deps,
758
+ );
759
+ releaseFirstWrite?.();
760
+ await Promise.all([first, second]);
761
+
762
+ expect(disk.current).toContain("<div>A</div>");
763
+ expect(disk.current).toContain("<div>B</div>");
764
+ });
765
+
766
+ it("shares the same queue with candidate mutations", async () => {
767
+ const disk = { current: html };
768
+ const live = await openComposition(html, { history: false });
769
+ const published: Array<Awaited<ReturnType<typeof openComposition>>> = [];
770
+ const deps = {
771
+ ...makeDeps(disk),
772
+ publishSession: ({
773
+ candidate,
774
+ }: {
775
+ candidate: Awaited<ReturnType<typeof openComposition>>;
776
+ }) => {
777
+ published.push(candidate);
778
+ return "published";
779
+ },
780
+ };
781
+ let releaseCandidateWrite: (() => void) | undefined;
782
+ const candidateWriteGate = new Promise<void>((resolve) => {
783
+ releaseCandidateWrite = resolve;
784
+ });
785
+ let notifyCandidateWriteStarted: (() => void) | undefined;
786
+ const candidateWriteStarted = new Promise<void>((resolve) => {
787
+ notifyCandidateWriteStarted = resolve;
788
+ });
789
+ deps.writeProjectFile.mockImplementationOnce(async (_path: string, content: string) => {
790
+ notifyCandidateWriteStarted?.();
791
+ await candidateWriteGate;
792
+ disk.current = content;
793
+ });
794
+
795
+ const candidateEdit = persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) =>
796
+ candidate.setStyle("hf-box", { color: "red" }),
797
+ );
798
+ await candidateWriteStarted;
799
+ const islandEdit = persistSdkSerialize(
800
+ (before) => before.replace("</body>", "<script>island</script></body>"),
801
+ "/comp.html",
802
+ html,
803
+ deps,
804
+ );
805
+ releaseCandidateWrite?.();
806
+ await Promise.all([candidateEdit, islandEdit]);
807
+
808
+ expect(disk.current).toContain("color: red");
809
+ expect(disk.current).toContain("<script>island</script>");
810
+ live.dispose();
811
+ for (const candidate of published) candidate.dispose();
812
+ });
813
+ });
814
+
385
815
  describe("sdkDeletePersist", () => {
386
816
  const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
387
817
  const makeDeps = () => ({
@@ -389,6 +819,7 @@ describe("sdkDeletePersist", () => {
389
819
  writeProjectFile: vi.fn().mockResolvedValue(undefined),
390
820
  reloadPreview: vi.fn(),
391
821
  domEditSaveTimestampRef: makeRef(0),
822
+ ...candidateTestDeps(),
392
823
  });
393
824
 
394
825
  const makeSession = (hasEl = true) =>
@@ -403,21 +834,23 @@ describe("sdkDeletePersist", () => {
403
834
  }) as unknown as Parameters<typeof sdkDeletePersist>[3];
404
835
 
405
836
  it("returns false when session is null", async () => {
406
- expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false);
837
+ expect(
838
+ (await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).status,
839
+ ).toBe("declined");
407
840
  });
408
841
 
409
842
  it("returns false when element not found in session", async () => {
410
843
  const session = makeSession(false);
411
- expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe(
412
- false,
413
- );
844
+ expect(
845
+ (await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).status,
846
+ ).toBe("declined");
414
847
  });
415
848
 
416
849
  it("calls removeElement and writes serialized content", async () => {
417
850
  const deps = makeDeps();
418
851
  const session = makeSession(true);
419
852
  const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
420
- expect(result).toBe(true);
853
+ expect(result.status).toBe("committed");
421
854
  expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
422
855
  expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
423
856
  });
@@ -448,7 +881,7 @@ describe("sdkDeletePersist", () => {
448
881
  throw new Error("remove failed");
449
882
  });
450
883
  const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
451
- expect(result).toBe(false);
884
+ expect(result.status).toBe("failed");
452
885
  expect(deps.writeProjectFile).not.toHaveBeenCalled();
453
886
  expect(deps.reloadPreview).not.toHaveBeenCalled();
454
887
  });
@@ -461,6 +894,7 @@ describe("sdkTimingPersist", () => {
461
894
  writeProjectFile: vi.fn().mockResolvedValue(undefined),
462
895
  reloadPreview: vi.fn(),
463
896
  domEditSaveTimestampRef: makeRef(0),
897
+ ...candidateTestDeps(),
464
898
  });
465
899
 
466
900
  const makeSession = (hasEl = true) =>
@@ -475,16 +909,16 @@ describe("sdkTimingPersist", () => {
475
909
  }) as unknown as Parameters<typeof sdkTimingPersist>[3];
476
910
 
477
911
  it("returns false when session is null", async () => {
478
- expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).toBe(
479
- false,
480
- );
912
+ expect(
913
+ (await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).status,
914
+ ).toBe("declined");
481
915
  });
482
916
 
483
917
  it("returns false when element not found in session", async () => {
484
918
  const session = makeSession(false);
485
- expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).toBe(
486
- false,
487
- );
919
+ expect(
920
+ (await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).status,
921
+ ).toBe("declined");
488
922
  });
489
923
 
490
924
  it("calls setTiming with provided update and writes serialized content", async () => {
@@ -497,7 +931,7 @@ describe("sdkTimingPersist", () => {
497
931
  session,
498
932
  deps,
499
933
  );
500
- expect(result).toBe(true);
934
+ expect(result.status).toBe("committed");
501
935
  expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", {
502
936
  start: 2,
503
937
  duration: 5,
@@ -524,7 +958,7 @@ describe("sdkTimingPersist", () => {
524
958
  throw new Error("timing error");
525
959
  });
526
960
  const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps);
527
- expect(result).toBe(false);
961
+ expect(result.status).toBe("failed");
528
962
  expect(deps.writeProjectFile).not.toHaveBeenCalled();
529
963
  });
530
964
 
@@ -539,6 +973,7 @@ describe("sdkTimingPersist", () => {
539
973
  const session = makeSession(true);
540
974
  await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
541
975
  expect(deps.readProjectFile).toHaveBeenCalledWith("/comp.html");
976
+ expect(deps.readProjectFile).toHaveBeenCalledOnce();
542
977
  expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
543
978
  expect.objectContaining({
544
979
  files: {
@@ -548,18 +983,16 @@ describe("sdkTimingPersist", () => {
548
983
  );
549
984
  });
550
985
 
551
- it("falls back to serialize() before when the reader throws", async () => {
986
+ it("fails without writing when the authoritative reader throws", async () => {
552
987
  const deps = {
553
988
  ...makeDeps(),
554
989
  readProjectFile: vi.fn().mockRejectedValue(new Error("read failed")),
555
990
  };
556
991
  const session = makeSession(true);
557
- await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
558
- expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
559
- expect.objectContaining({
560
- files: { "/comp.html": { before: "<html>before</html>", after: "<html>after</html>" } },
561
- }),
562
- );
992
+ const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
993
+ expect(result).toMatchObject({ status: "failed", error: new Error("read failed") });
994
+ expect(deps.writeProjectFile).not.toHaveBeenCalled();
995
+ expect(deps.editHistory.recordEdit).not.toHaveBeenCalled();
563
996
  });
564
997
  });
565
998
 
@@ -583,6 +1016,7 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
583
1016
  reloadPreview: vi.fn(),
584
1017
  domEditSaveTimestampRef: makeRef(0),
585
1018
  readProjectFile: vi.fn().mockResolvedValue("<html>on-disk gsap bytes</html>"),
1019
+ ...candidateTestDeps(),
586
1020
  };
587
1021
  const session = makeSession();
588
1022
  await sdkGsapTweenPersist(
@@ -591,6 +1025,7 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
591
1025
  session,
592
1026
  deps,
593
1027
  );
1028
+ expect(deps.readProjectFile).toHaveBeenCalledOnce();
594
1029
  expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
595
1030
  expect.objectContaining({
596
1031
  files: {
@@ -604,16 +1039,18 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
604
1039
  describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
605
1040
  const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
606
1041
 
607
- it("routes the read-modify-write through the keyed serializer so same-file flushes can't interleave", async () => {
1042
+ it("routes the read-modify-write through the shared file coordinator", async () => {
608
1043
  const order: string[] = [];
609
1044
  let writeResolve: (() => void) | null = null;
1045
+ let writeCall = 0;
610
1046
  const deps = {
611
1047
  editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
612
1048
  // First write blocks until we release it, so without serialization the
613
1049
  // second op's serialize()/dispatch would interleave ahead of it.
614
1050
  writeProjectFile: vi.fn().mockImplementation((_p: string, content: string) => {
1051
+ writeCall++;
615
1052
  order.push(`write-start:${content}`);
616
- if (content === "<html>after-1</html>") {
1053
+ if (writeCall === 1) {
617
1054
  return new Promise<void>((res) => {
618
1055
  writeResolve = () => {
619
1056
  order.push(`write-done:${content}`);
@@ -626,16 +1063,7 @@ describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
626
1063
  }),
627
1064
  reloadPreview: vi.fn(),
628
1065
  domEditSaveTimestampRef: makeRef(0),
629
- // A real per-key serializer: tasks under the same key run strictly in order.
630
- serialize: (() => {
631
- const inFlight = new Map<string, Promise<unknown>>();
632
- return <T>(key: string, task: () => Promise<T>): Promise<T> => {
633
- const prior = inFlight.get(key) ?? Promise.resolve();
634
- const next = prior.then(task, task);
635
- inFlight.set(key, next);
636
- return next as Promise<T>;
637
- };
638
- })(),
1066
+ ...candidateTestDeps(),
639
1067
  };
640
1068
 
641
1069
  let serializeCall = 0;
@@ -662,15 +1090,17 @@ describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
662
1090
  session,
663
1091
  deps,
664
1092
  );
665
- // Let the first op reach its (blocked) write before releasing it.
666
- await Promise.resolve();
667
- await Promise.resolve();
1093
+ // Let the first op finish candidate construction and reach its blocked write.
1094
+ await vi.waitFor(() => expect(writeResolve).not.toBeNull());
668
1095
  writeResolve?.();
669
1096
  await Promise.all([p1, p2]);
670
1097
 
671
1098
  // The second op's write must NOT start before the first op's write completes.
672
- const firstWriteDone = order.indexOf("write-done:<html>after-1</html>");
673
- const secondWriteStart = order.indexOf("write-start:<html>after-2</html>");
1099
+ const firstWriteDone = order.findIndex((entry) => entry.startsWith("write-done:"));
1100
+ const writeStarts = order
1101
+ .map((entry, index) => (entry.startsWith("write-start:") ? index : -1))
1102
+ .filter((index) => index >= 0);
1103
+ const secondWriteStart = writeStarts[1] ?? -1;
674
1104
  expect(firstWriteDone).toBeGreaterThanOrEqual(0);
675
1105
  expect(secondWriteStart).toBeGreaterThan(firstWriteDone);
676
1106
  });
@@ -683,6 +1113,7 @@ describe("sdkGsapTweenPersist", () => {
683
1113
  writeProjectFile: vi.fn().mockResolvedValue(undefined),
684
1114
  reloadPreview: vi.fn(),
685
1115
  domEditSaveTimestampRef: makeRef(0),
1116
+ ...candidateTestDeps(),
686
1117
  });
687
1118
 
688
1119
  const makeSession = (opts?: { addGsapTween?: string; hasEl?: boolean }) =>
@@ -706,7 +1137,7 @@ describe("sdkGsapTweenPersist", () => {
706
1137
  null,
707
1138
  makeDeps(),
708
1139
  ),
709
- ).toBe(false);
1140
+ ).toMatchObject({ status: "declined" });
710
1141
  });
711
1142
 
712
1143
  it("calls addGsapTween and writes for kind=add", async () => {
@@ -722,7 +1153,7 @@ describe("sdkGsapTweenPersist", () => {
722
1153
  session,
723
1154
  deps,
724
1155
  );
725
- expect(result).toBe(true);
1156
+ expect(result.status).toBe("committed");
726
1157
  expect(session!.addGsapTween).toHaveBeenCalledWith(
727
1158
  "hf-box",
728
1159
  expect.objectContaining({ method: "to" }),
@@ -739,7 +1170,7 @@ describe("sdkGsapTweenPersist", () => {
739
1170
  session,
740
1171
  deps,
741
1172
  );
742
- expect(result).toBe(false);
1173
+ expect(result.status).toBe("declined");
743
1174
  expect(deps.writeProjectFile).not.toHaveBeenCalled();
744
1175
  });
745
1176
 
@@ -752,7 +1183,7 @@ describe("sdkGsapTweenPersist", () => {
752
1183
  session,
753
1184
  deps,
754
1185
  );
755
- expect(result).toBe(true);
1186
+ expect(result.status).toBe("committed");
756
1187
  expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" });
757
1188
  expect(deps.reloadPreview).toHaveBeenCalled();
758
1189
  });
@@ -766,7 +1197,7 @@ describe("sdkGsapTweenPersist", () => {
766
1197
  session,
767
1198
  deps,
768
1199
  );
769
- expect(result).toBe(true);
1200
+ expect(result.status).toBe("committed");
770
1201
  expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1");
771
1202
  });
772
1203
 
@@ -782,7 +1213,7 @@ describe("sdkGsapTweenPersist", () => {
782
1213
  session,
783
1214
  deps,
784
1215
  );
785
- expect(result).toBe(false);
1216
+ expect(result.status).toBe("failed");
786
1217
  expect(deps.writeProjectFile).not.toHaveBeenCalled();
787
1218
  });
788
1219
  });
@@ -794,6 +1225,7 @@ describe("sdkGsapKeyframePersist", () => {
794
1225
  writeProjectFile: vi.fn().mockResolvedValue(undefined),
795
1226
  reloadPreview: vi.fn(),
796
1227
  domEditSaveTimestampRef: makeRef(0),
1228
+ ...candidateTestDeps(),
797
1229
  });
798
1230
 
799
1231
  const makeSession = () =>
@@ -809,7 +1241,7 @@ describe("sdkGsapKeyframePersist", () => {
809
1241
  it("returns false when session is null", async () => {
810
1242
  expect(
811
1243
  await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()),
812
- ).toBe(false);
1244
+ ).toMatchObject({ status: "declined" });
813
1245
  });
814
1246
 
815
1247
  it("dispatches addGsapKeyframe and writes serialized content", async () => {
@@ -823,7 +1255,7 @@ describe("sdkGsapKeyframePersist", () => {
823
1255
  session,
824
1256
  deps,
825
1257
  );
826
- expect(result).toBe(true);
1258
+ expect(result.status).toBe("committed");
827
1259
  expect(session!.dispatch).toHaveBeenCalledWith({
828
1260
  type: "addGsapKeyframe",
829
1261
  animationId: "tw-1",
@@ -848,7 +1280,7 @@ describe("sdkGsapKeyframePersist", () => {
848
1280
  session,
849
1281
  deps,
850
1282
  );
851
- expect(result).toBe(false);
1283
+ expect(result.status).toBe("failed");
852
1284
  expect(deps.writeProjectFile).not.toHaveBeenCalled();
853
1285
  });
854
1286
  });
@@ -860,6 +1292,7 @@ describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
860
1292
  writeProjectFile: vi.fn().mockResolvedValue(undefined),
861
1293
  reloadPreview: vi.fn(),
862
1294
  domEditSaveTimestampRef: makeRef(0),
1295
+ ...candidateTestDeps(),
863
1296
  });
864
1297
 
865
1298
  it("preserves GSAP <script> block and data-position-mode through setStyle dispatch", async () => {
@@ -880,7 +1313,7 @@ gsap.timeline().to('[data-hf-id="hf-layer"]', { duration: 1, x: 100 });
880
1313
  comp,
881
1314
  deps,
882
1315
  );
883
- expect(result).toBe(true);
1316
+ expect(result.status).toBe("committed");
884
1317
  const written = (deps.writeProjectFile as ReturnType<typeof vi.fn>).mock
885
1318
  .calls[0]?.[1] as string;
886
1319
  expect(written).toContain("data-hf-gsap");