@hyperframes/studio 0.7.58 → 0.7.60

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 (173) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
  3. package/dist/assets/index-DXbu6IPT.css +1 -0
  4. package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
  5. package/dist/assets/index-cH6NfVV_.js +426 -0
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13755 -8627
  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 +4 -4
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +42 -53
  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 +107 -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/FileManagerContext.tsx +3 -0
  96. package/src/contexts/PanelLayoutContext.tsx +3 -0
  97. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  98. package/src/contexts/VariablePromoteContext.tsx +10 -5
  99. package/src/hooks/domEditCommitRunner.ts +11 -0
  100. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  101. package/src/hooks/timelineEditingHelpers.ts +2 -2
  102. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  103. package/src/hooks/timelineTrackVisibility.ts +27 -10
  104. package/src/hooks/useAppHotkeys.ts +9 -0
  105. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  106. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  107. package/src/hooks/useDomEditCommits.ts +31 -177
  108. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  109. package/src/hooks/useDomEditSession.ts +18 -6
  110. package/src/hooks/useDomEditTextCommits.ts +14 -9
  111. package/src/hooks/useDomEditWiring.ts +10 -9
  112. package/src/hooks/useEditorSave.ts +1 -1
  113. package/src/hooks/useElementLifecycleOps.ts +8 -3
  114. package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
  115. package/src/hooks/useFileManager.ts +151 -65
  116. package/src/hooks/useGsapAnimationOps.ts +7 -6
  117. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  118. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  119. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  120. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  121. package/src/hooks/useGsapScriptCommits.ts +150 -50
  122. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  123. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  124. package/src/hooks/useInspectorSplitResize.ts +51 -0
  125. package/src/hooks/usePanelLayout.test.ts +118 -0
  126. package/src/hooks/usePanelLayout.ts +24 -1
  127. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  128. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  129. package/src/hooks/usePersistentEditHistory.ts +71 -31
  130. package/src/hooks/usePreviewPersistence.ts +4 -1
  131. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  132. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  133. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  134. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  135. package/src/hooks/useRazorSplit.test.tsx +88 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  137. package/src/hooks/useRazorSplit.ts +50 -16
  138. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  139. package/src/hooks/useSdkSession.ts +155 -14
  140. package/src/hooks/useSlideshowPersist.ts +7 -1
  141. package/src/hooks/useStoryboard.ts +23 -4
  142. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  143. package/src/hooks/useTimelineEditing.ts +18 -10
  144. package/src/hooks/useTimelineEditingTypes.ts +5 -1
  145. package/src/hooks/useTimelineGroupEditing.ts +17 -3
  146. package/src/hooks/useVariablesPersist.ts +9 -7
  147. package/src/icons/SystemIcons.tsx +2 -0
  148. package/src/styles/studio.css +22 -0
  149. package/src/styles/tailwind-preset.shared.js +7 -0
  150. package/src/utils/designInputTracking.test.ts +97 -0
  151. package/src/utils/designInputTracking.ts +80 -0
  152. package/src/utils/domEditSaveQueue.test.ts +19 -0
  153. package/src/utils/domEditSaveQueue.ts +2 -1
  154. package/src/utils/sdkCutover.gate.test.ts +7 -7
  155. package/src/utils/sdkCutover.test.ts +511 -62
  156. package/src/utils/sdkCutover.ts +181 -219
  157. package/src/utils/sdkEditTransaction.ts +293 -0
  158. package/src/utils/sdkResolverAttempts.ts +94 -0
  159. package/src/utils/sdkResolverShadow.test.ts +148 -0
  160. package/src/utils/sdkResolverShadow.ts +139 -106
  161. package/src/utils/setSlideshowManifest.test.ts +0 -9
  162. package/src/utils/setSlideshowManifest.ts +20 -27
  163. package/src/utils/studioFileHistory.test.ts +41 -0
  164. package/src/utils/studioFileHistory.ts +34 -29
  165. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  166. package/src/utils/studioFileVersion.test.ts +36 -0
  167. package/src/utils/studioFileVersion.ts +23 -0
  168. package/src/utils/studioHelpers.test.ts +45 -0
  169. package/src/utils/studioHelpers.ts +45 -14
  170. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  171. package/src/utils/studioSaveDiagnostics.ts +38 -0
  172. package/dist/assets/index-B_UvTX3E.js +0 -423
  173. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -30,6 +30,7 @@ interface RecordEditInput {
30
30
  interface ApplyCallbacks {
31
31
  readFile: (path: string) => Promise<string>;
32
32
  writeFile: (path: string, content: string) => Promise<void>;
33
+ serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
33
34
  }
34
35
 
35
36
  interface UsePersistentEditHistoryOptions {
@@ -167,31 +168,32 @@ async function applyHistoryStep(
167
168
  if (!entry) {
168
169
  return { state: currentState, result: { ok: false, reason: "empty" } };
169
170
  }
170
- const { currentFiles, currentHashes } = await readCurrentFileHashes(
171
- Object.keys(entry.files),
172
- callbacks.readFile,
173
- );
174
- const result = transition(currentState, currentHashes, now());
175
- if (!result.ok) {
171
+ const paths = Object.keys(entry.files);
172
+ const apply = async (): Promise<{ state: EditHistoryState; result: ApplyResult }> => {
173
+ const { currentFiles, currentHashes } = await readCurrentFileHashes(paths, callbacks.readFile);
174
+ const result = transition(currentState, currentHashes, now());
175
+ if (!result.ok) {
176
+ return {
177
+ state: currentState,
178
+ result: { ok: false, reason: result.reason },
179
+ };
180
+ }
181
+ await writeFilesWithRollback({
182
+ files: result.filesToWrite,
183
+ rollbackFiles: currentFiles,
184
+ writeFile: callbacks.writeFile,
185
+ });
176
186
  return {
177
- state: currentState,
178
- result: { ok: false, reason: result.reason },
187
+ state: result.state,
188
+ result: {
189
+ ok: true,
190
+ label: result.entry.label,
191
+ paths: Object.keys(result.entry.files),
192
+ files: restoredFilesMap(result.filesToWrite, currentFiles),
193
+ },
179
194
  };
180
- }
181
- await writeFilesWithRollback({
182
- files: result.filesToWrite,
183
- rollbackFiles: currentFiles,
184
- writeFile: callbacks.writeFile,
185
- });
186
- return {
187
- state: result.state,
188
- result: {
189
- ok: true,
190
- label: result.entry.label,
191
- paths: Object.keys(result.entry.files),
192
- files: restoredFilesMap(result.filesToWrite, currentFiles),
193
- },
194
195
  };
196
+ return callbacks.serialize ? callbacks.serialize(paths, apply) : apply();
195
197
  }
196
198
 
197
199
  export function createPersistentEditHistoryStore({
@@ -305,11 +307,15 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
305
307
  const [loaded, setLoaded] = useState(false);
306
308
  const projectId = options.projectId;
307
309
  const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
310
+ const storeProjectIdRef = useRef<string | null>(null);
311
+ const activeProjectIdRef = useRef(projectId);
312
+ activeProjectIdRef.current = projectId;
308
313
 
309
314
  useEffect(() => {
310
315
  let cancelled = false;
311
316
  const emptyState = createEmptyEditHistory();
312
317
  storeRef.current = null;
318
+ storeProjectIdRef.current = null;
313
319
  setState(emptyState);
314
320
  setLoaded(false);
315
321
  if (!projectId) {
@@ -327,6 +333,7 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
327
333
  now,
328
334
  onChange: setState,
329
335
  });
336
+ storeProjectIdRef.current = projectId;
330
337
  setState(loadedState);
331
338
  })
332
339
  .catch(() => {
@@ -338,6 +345,7 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
338
345
  now,
339
346
  onChange: setState,
340
347
  });
348
+ storeProjectIdRef.current = projectId;
341
349
  setState(emptyState);
342
350
  })
343
351
  .finally(() => {
@@ -349,17 +357,49 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
349
357
  };
350
358
  }, [now, projectId, storage]);
351
359
 
352
- const recordEdit = useCallback(async (input: RecordEditInput) => {
353
- await storeRef.current?.recordEdit(input);
354
- }, []);
360
+ const recordEdit = useCallback(
361
+ async (input: RecordEditInput) => {
362
+ if (!projectId) return;
363
+ if (activeProjectIdRef.current !== projectId) {
364
+ throw new Error(`Cannot record an edit for inactive project ${projectId}`);
365
+ }
366
+ const store = storeRef.current;
367
+ if (!store) return;
368
+ if (storeProjectIdRef.current !== projectId) {
369
+ throw new Error(`Edit history store does not belong to project ${projectId}`);
370
+ }
371
+ await store.recordEdit(input);
372
+ },
373
+ [projectId],
374
+ );
355
375
 
356
- const undo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
357
- return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
358
- }, []);
376
+ const undo = useCallback(
377
+ async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
378
+ if (
379
+ !projectId ||
380
+ activeProjectIdRef.current !== projectId ||
381
+ storeProjectIdRef.current !== projectId
382
+ ) {
383
+ return { ok: false, reason: "empty" };
384
+ }
385
+ return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
386
+ },
387
+ [projectId],
388
+ );
359
389
 
360
- const redo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
361
- return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
362
- }, []);
390
+ const redo = useCallback(
391
+ async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
392
+ if (
393
+ !projectId ||
394
+ activeProjectIdRef.current !== projectId ||
395
+ storeProjectIdRef.current !== projectId
396
+ ) {
397
+ return { ok: false, reason: "empty" };
398
+ }
399
+ return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
400
+ },
401
+ [projectId],
402
+ );
363
403
 
364
404
  return {
365
405
  loaded,
@@ -133,7 +133,10 @@ export function usePreviewPersistence({
133
133
  if (!domEditSaveQueueRef.current) {
134
134
  domEditSaveQueueRef.current = createDomEditSaveQueue({
135
135
  onOpen: (event) => {
136
- const message = "Auto-save is paused. Check your connection.";
136
+ const message =
137
+ event.statusCode === 409
138
+ ? "Save paused: this file changed elsewhere. Reload and review the latest version before reapplying your edit."
139
+ : "Auto-save is paused. Check your connection.";
137
140
  setDomEditSaveQueuePaused(message);
138
141
  showToastRef.current(message, "error");
139
142
  trackStudioEvent("save_queue_paused", {
@@ -103,17 +103,16 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) {
103
103
  return useCallback(
104
104
  async (path: string, label: string, mutate: (session: Composition) => void): Promise<void> => {
105
105
  const originalContent = await readProjectFile(path);
106
- const comp = await openComposition(originalContent, { history: false });
107
- let after: string;
108
- try {
109
- mutate(comp);
110
- after = comp.serialize();
111
- } finally {
112
- comp.dispose();
113
- }
114
- if (after === originalContent) return;
115
106
  await persistSdkSerialize(
116
- after,
107
+ async (onDiskBefore) => {
108
+ const comp = await openComposition(onDiskBefore, { history: false });
109
+ try {
110
+ mutate(comp);
111
+ return comp.serialize();
112
+ } finally {
113
+ comp.dispose();
114
+ }
115
+ },
117
116
  path,
118
117
  originalContent,
119
118
  {
@@ -122,6 +121,7 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) {
122
121
  reloadPreview,
123
122
  domEditSaveTimestampRef,
124
123
  compositionPath: path,
124
+ readProjectFile,
125
125
  },
126
126
  { label },
127
127
  );
@@ -0,0 +1,135 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { useProjectSignaturePoll } from "./useProjectSignaturePoll";
7
+
8
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
+
10
+ function HookHost({
11
+ signature,
12
+ onChange,
13
+ }: {
14
+ signature: string | undefined;
15
+ onChange: () => void;
16
+ }) {
17
+ useProjectSignaturePoll("demo", signature, onChange);
18
+ return null;
19
+ }
20
+
21
+ let root: Root | null = null;
22
+ let container: HTMLElement | null = null;
23
+
24
+ function mount(signature: string | undefined, onChange: () => void): void {
25
+ act(() => {
26
+ if (!root) {
27
+ container = document.createElement("div");
28
+ document.body.appendChild(container);
29
+ root = createRoot(container);
30
+ }
31
+ root.render(<HookHost signature={signature} onChange={onChange} />);
32
+ });
33
+ }
34
+
35
+ function unmount(): void {
36
+ if (root) {
37
+ act(() => root?.unmount());
38
+ root = null;
39
+ }
40
+ container?.remove();
41
+ container = null;
42
+ }
43
+
44
+ function mockSignatureResponse(signature: string): ReturnType<typeof vi.fn> {
45
+ const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ signature }) }));
46
+ vi.stubGlobal("fetch", fetchMock);
47
+ return fetchMock;
48
+ }
49
+
50
+ describe("useProjectSignaturePoll", () => {
51
+ beforeEach(() => {
52
+ vi.useFakeTimers();
53
+ });
54
+
55
+ afterEach(() => {
56
+ unmount();
57
+ vi.useRealTimers();
58
+ vi.unstubAllGlobals();
59
+ vi.restoreAllMocks();
60
+ });
61
+
62
+ it("fires onChange when the polled signature moves past the loaded one", async () => {
63
+ const fetchMock = mockSignatureResponse("sig-b");
64
+ const onChange = vi.fn();
65
+ mount("sig-a", onChange);
66
+
67
+ await vi.advanceTimersByTimeAsync(2000);
68
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/signature");
69
+ expect(onChange).toHaveBeenCalledTimes(1);
70
+ });
71
+
72
+ it("stays quiet while the polled signature matches", async () => {
73
+ mockSignatureResponse("sig-a");
74
+ const onChange = vi.fn();
75
+ mount("sig-a", onChange);
76
+
77
+ await vi.advanceTimersByTimeAsync(6000);
78
+ expect(onChange).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("does not poll before a baseline signature exists", async () => {
82
+ const fetchMock = mockSignatureResponse("sig-a");
83
+ const onChange = vi.fn();
84
+ mount(undefined, onChange);
85
+
86
+ await vi.advanceTimersByTimeAsync(6000);
87
+ expect(fetchMock).not.toHaveBeenCalled();
88
+ expect(onChange).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it("re-arms against the caller's refreshed signature without re-mounting", async () => {
92
+ mockSignatureResponse("sig-b");
93
+ const onChange = vi.fn();
94
+ mount("sig-a", onChange);
95
+
96
+ await vi.advanceTimersByTimeAsync(2000);
97
+ expect(onChange).toHaveBeenCalledTimes(1);
98
+
99
+ // The caller refetched and now holds the polled signature — no more firing.
100
+ mount("sig-b", onChange);
101
+ await vi.advanceTimersByTimeAsync(6000);
102
+ expect(onChange).toHaveBeenCalledTimes(1);
103
+ });
104
+
105
+ it("skips ticks while the tab is hidden and re-checks on visibility", async () => {
106
+ const fetchMock = mockSignatureResponse("sig-b");
107
+ const onChange = vi.fn();
108
+ let hidden = true;
109
+ vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden);
110
+ mount("sig-a", onChange);
111
+
112
+ await vi.advanceTimersByTimeAsync(6000);
113
+ expect(fetchMock).not.toHaveBeenCalled();
114
+
115
+ hidden = false;
116
+ await act(async () => {
117
+ document.dispatchEvent(new Event("visibilitychange"));
118
+ await vi.advanceTimersByTimeAsync(0);
119
+ });
120
+ expect(onChange).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ it("stops polling after unmount", async () => {
124
+ mockSignatureResponse("sig-b");
125
+ const onChange = vi.fn();
126
+ mount("sig-a", onChange);
127
+
128
+ await vi.advanceTimersByTimeAsync(2000);
129
+ expect(onChange).toHaveBeenCalledTimes(1);
130
+
131
+ unmount();
132
+ await vi.advanceTimersByTimeAsync(10_000);
133
+ expect(onChange).toHaveBeenCalledTimes(1);
134
+ });
135
+ });
@@ -0,0 +1,68 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { buildProjectApiPath } from "../utils/projectRouting";
3
+
4
+ const POLL_INTERVAL_MS = 2000;
5
+
6
+ /** One poll: the current signature, or null on any failure (skip this tick). */
7
+ async function fetchProjectSignature(projectId: string): Promise<string | null> {
8
+ try {
9
+ const res = await fetch(buildProjectApiPath(projectId, "/signature"));
10
+ if (!res.ok) return null;
11
+ const body = (await res.json()) as { signature?: string };
12
+ return typeof body.signature === "string" ? body.signature : null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Poll the project's content signature and fire `onChange` when it no longer
20
+ * matches `currentSignature` — the storyboard board uses this to refresh itself
21
+ * while an agent writes sketch frames to disk.
22
+ *
23
+ * The comparison baseline is the signature the caller's data was loaded with,
24
+ * so a refetch triggered by `onChange` naturally re-arms the poll with the new
25
+ * value. Ticks are skipped while the tab is hidden (a visibility flip re-checks
26
+ * immediately) and while a previous request is still in flight; request
27
+ * failures are ignored — polling degrades to today's manual-reload behavior.
28
+ */
29
+ export function useProjectSignaturePoll(
30
+ projectId: string | null,
31
+ currentSignature: string | undefined,
32
+ onChange: () => void,
33
+ ): void {
34
+ const signatureRef = useRef(currentSignature);
35
+ const onChangeRef = useRef(onChange);
36
+ signatureRef.current = currentSignature;
37
+ onChangeRef.current = onChange;
38
+
39
+ useEffect(() => {
40
+ if (!projectId) return;
41
+ let disposed = false;
42
+ let inFlight = false;
43
+
44
+ const tick = async () => {
45
+ if (disposed || inFlight || document.hidden) return;
46
+ // No baseline yet (initial storyboard fetch still loading, or an older
47
+ // server without the signature field) — nothing to compare against.
48
+ if (signatureRef.current === undefined) return;
49
+ inFlight = true;
50
+ const latest = await fetchProjectSignature(projectId);
51
+ inFlight = false;
52
+ if (disposed || latest === null) return;
53
+ if (latest !== signatureRef.current) onChangeRef.current();
54
+ };
55
+
56
+ const interval = window.setInterval(() => void tick(), POLL_INTERVAL_MS);
57
+ const onVisibilityChange = () => {
58
+ if (!document.hidden) void tick();
59
+ };
60
+ document.addEventListener("visibilitychange", onVisibilityChange);
61
+
62
+ return () => {
63
+ disposed = true;
64
+ window.clearInterval(interval);
65
+ document.removeEventListener("visibilitychange", onVisibilityChange);
66
+ };
67
+ }, [projectId]);
68
+ }
@@ -76,9 +76,10 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
76
76
  // Mirror the server: rewrites the GSAP script for the new id, writes to
77
77
  // disk, returns the final content.
78
78
  disk["index.html"] = SPLIT_GSAP;
79
- return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), {
79
+ const version = `"test-gsap-${SPLIT_GSAP.length}"`;
80
+ return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP, version }), {
80
81
  status: 200,
81
- headers: { "Content-Type": "application/json" },
82
+ headers: { "Content-Type": "application/json", ETag: version },
82
83
  });
83
84
  }
84
85
  // The fixture has no GSAP script — mirror the server's 400 response.
@@ -89,9 +90,16 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
89
90
  }
90
91
  if (u.includes("/file-mutations/split-element/")) {
91
92
  disk["index.html"] = SPLIT;
93
+ const version = `"test-split-${SPLIT.length}"`;
92
94
  return new Response(
93
- JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }),
94
- { status: 200, headers: { "Content-Type": "application/json" } },
95
+ JSON.stringify({
96
+ ok: true,
97
+ changed: true,
98
+ content: SPLIT,
99
+ newId: "clip1-split",
100
+ version,
101
+ }),
102
+ { status: 200, headers: { "Content-Type": "application/json", ETag: version } },
95
103
  );
96
104
  }
97
105
  if (u.includes("/files/")) {
@@ -0,0 +1,88 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../player";
6
+ import { useRazorSplit } from "./useRazorSplit";
7
+
8
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ function jsonResponse(body: unknown, status = 200): Response {
11
+ return new Response(JSON.stringify(body), {
12
+ status,
13
+ headers: { "Content-Type": "application/json" },
14
+ });
15
+ }
16
+
17
+ describe("useRazorSplit mutation versions", () => {
18
+ afterEach(() => vi.restoreAllMocks());
19
+
20
+ it("observes each out-of-band mutation version before the OCC writer runs", async () => {
21
+ const original = '<div id="clip" data-start="0" data-duration="4">Clip</div>';
22
+ const htmlSplit =
23
+ '<div id="clip" data-start="0" data-duration="2">Clip</div><div id="clip-split" data-start="2" data-duration="2">Clip</div>';
24
+ const final = `${htmlSplit}<script>window.__timelines = {}</script>`;
25
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
26
+ const url = String(input);
27
+ if (url.includes("/files/")) return jsonResponse({ content: original });
28
+ if (url.includes("/file-mutations/split-element/")) {
29
+ return jsonResponse({ ok: true, changed: true, content: htmlSplit, version: '"v-html"' });
30
+ }
31
+ if (url.includes("/gsap-mutations/")) {
32
+ return jsonResponse({ ok: true, changed: true, after: final, version: '"v-gsap"' });
33
+ }
34
+ throw new Error(`Unexpected request: ${url}`);
35
+ });
36
+
37
+ const order: string[] = [];
38
+ const observeProjectFileVersion = vi.fn((path: string, version: string | null) => {
39
+ order.push(`observe:${path}:${version}`);
40
+ });
41
+ const writeProjectFile = vi.fn(async () => {
42
+ order.push("write");
43
+ });
44
+ const recordEdit = vi.fn().mockResolvedValue(undefined);
45
+ let split: ((element: TimelineElement, splitTime: number) => Promise<void>) | undefined;
46
+ const container = document.createElement("div");
47
+ document.body.appendChild(container);
48
+ const root = createRoot(container);
49
+
50
+ function Harness() {
51
+ split = useRazorSplit({
52
+ projectId: "p1",
53
+ activeCompPath: "index.html",
54
+ showToast: vi.fn(),
55
+ writeProjectFile,
56
+ observeProjectFileVersion,
57
+ recordEdit,
58
+ domEditSaveTimestampRef: { current: 0 },
59
+ reloadPreview: vi.fn(),
60
+ }).handleRazorSplit;
61
+ return null;
62
+ }
63
+
64
+ await act(async () => root.render(React.createElement(Harness)));
65
+ await act(async () => {
66
+ await split?.(
67
+ {
68
+ id: "clip",
69
+ domId: "clip",
70
+ hfId: "clip",
71
+ tag: "div",
72
+ start: 0,
73
+ duration: 4,
74
+ track: 0,
75
+ timingSource: "authored",
76
+ },
77
+ 2,
78
+ );
79
+ });
80
+
81
+ expect(order).toEqual(['observe:index.html:"v-html"', 'observe:index.html:"v-gsap"', "write"]);
82
+ expect(writeProjectFile).toHaveBeenCalledWith("index.html", final, original);
83
+ expect(recordEdit).toHaveBeenCalledTimes(1);
84
+
85
+ act(() => root.unmount());
86
+ container.remove();
87
+ });
88
+ });
@@ -36,10 +36,11 @@ export function createSplitFetchMock(
36
36
  onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
37
37
  // Return content that differs from the original so `changed` is true.
38
38
  const after = `${disk[path]}<!--split-->`;
39
+ const version = `"test-${path}-${after.length}"`;
39
40
  disk[path] = after; // server writes the split to disk
40
- return new Response(JSON.stringify({ ok: true, changed: true, content: after }), {
41
+ return new Response(JSON.stringify({ ok: true, changed: true, content: after, version }), {
41
42
  status: 200,
42
- headers: { "Content-Type": "application/json" },
43
+ headers: { "Content-Type": "application/json", ETag: version },
43
44
  });
44
45
  }
45
46
  if (u.includes("/files/")) {