@hyperframes/studio 0.8.115 → 0.8.116

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 (149) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-BoUM6Xqb.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-CAvZ0AA-.js → index-DYNaXPVf.js} +201 -201
  4. package/dist/assets/{index-CVuorIMv.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +104 -22
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1131 -1044
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  16. package/src/components/editor/domGroupMoveBy.ts +95 -0
  17. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  18. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  19. package/src/components/editor/manualEditingAvailability.ts +8 -0
  20. package/src/components/editor/manualOffsetDrag.ts +5 -6
  21. package/src/components/editor/motionPathSelection.test.ts +6 -31
  22. package/src/components/editor/probeSourceElement.ts +2 -1
  23. package/src/components/editor/propertyPanelFont.tsx +3 -2
  24. package/src/components/editor/useColorGradingController.ts +2 -1
  25. package/src/components/editor/useFxCarveNodes.ts +2 -1
  26. package/src/components/editor/useFxLevelling.ts +2 -1
  27. package/src/components/feedback/projectProvenance.ts +2 -1
  28. package/src/components/nle/NLEContext.test.ts +1 -0
  29. package/src/components/nle/NLEContext.tsx +2 -1
  30. package/src/components/nle/NLEPreview.tsx +12 -10
  31. package/src/components/nle/PreviewPoster.tsx +2 -1
  32. package/src/components/renders/useFfmpegStatus.ts +2 -1
  33. package/src/components/renders/useRenderQueue.ts +5 -4
  34. package/src/components/sidebar/AssetsTab.tsx +2 -1
  35. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  36. package/src/contexts/DomEditContext.tsx +4 -0
  37. package/src/hooks/domSelectionTestHarness.ts +23 -0
  38. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  39. package/src/hooks/domStyleCommit.ts +8 -1
  40. package/src/hooks/draggedGsapPosition.ts +10 -0
  41. package/src/hooks/gsapDragCommit.test.ts +75 -164
  42. package/src/hooks/gsapDragCommit.ts +2 -105
  43. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  44. package/src/hooks/gsapEditOutcome.ts +22 -2
  45. package/src/hooks/gsapGroupPreflight.ts +34 -0
  46. package/src/hooks/gsapMutationClient.ts +4 -3
  47. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  48. package/src/hooks/gsapParsedTween.test.ts +62 -0
  49. package/src/hooks/gsapParsedTween.ts +243 -0
  50. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  51. package/src/hooks/gsapPositionDetection.ts +52 -33
  52. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  53. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  54. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  55. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  56. package/src/hooks/gsapResizeIntercept.ts +79 -169
  57. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  58. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  59. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  60. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  61. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  62. package/src/hooks/gsapScriptCommitTypes.ts +8 -0
  63. package/src/hooks/gsapShared.ts +7 -0
  64. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  65. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  66. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  67. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  68. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  69. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  70. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  71. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  72. package/src/hooks/newTweenTarget.test.ts +41 -76
  73. package/src/hooks/timelineTimingSync.test.ts +1 -0
  74. package/src/hooks/timelineTimingSync.ts +2 -1
  75. package/src/hooks/useAskAgentModal.ts +2 -1
  76. package/src/hooks/useBlockCatalog.ts +2 -1
  77. package/src/hooks/useCaptionDetection.ts +2 -1
  78. package/src/hooks/useCompositionContentLoader.ts +2 -1
  79. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  80. package/src/hooks/useDomEditSession.test.tsx +30 -1
  81. package/src/hooks/useDomEditSession.ts +10 -0
  82. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  83. package/src/hooks/useDomEditTextCommits.ts +8 -2
  84. package/src/hooks/useDomGeometryCommit.ts +1 -0
  85. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  86. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  87. package/src/hooks/useEditHistoryActions.ts +16 -5
  88. package/src/hooks/useElementLifecycleOps.ts +2 -1
  89. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  90. package/src/hooks/useEnableKeyframes.ts +2 -1
  91. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  92. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  94. package/src/hooks/useFileManager.ts +14 -10
  95. package/src/hooks/useFileTree.ts +3 -2
  96. package/src/hooks/useFrameCapture.ts +5 -1
  97. package/src/hooks/useFreezeFrame.ts +3 -2
  98. package/src/hooks/useGroupCommits.ts +2 -1
  99. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  100. package/src/hooks/useGsapAwareEditing.test.tsx +17 -29
  101. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  102. package/src/hooks/useGsapAwareEditing.ts +5 -0
  103. package/src/hooks/useGsapAwareGroupMove.ts +16 -16
  104. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  105. package/src/hooks/useGsapScriptCommits.test.tsx +3 -1
  106. package/src/hooks/useGsapScriptCommits.ts +12 -10
  107. package/src/hooks/useLintModal.ts +2 -1
  108. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  109. package/src/hooks/usePersistentEditHistory.ts +47 -8
  110. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  111. package/src/hooks/usePreviewPersistence.ts +13 -11
  112. package/src/hooks/useProjectFileWriter.ts +20 -19
  113. package/src/hooks/useRemoveBackground.ts +2 -1
  114. package/src/hooks/useSdkSession.ts +2 -1
  115. package/src/hooks/useServerConnection.ts +4 -3
  116. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  117. package/src/hooks/useThumbnailStill.test.ts +100 -0
  118. package/src/hooks/useThumbnailStill.ts +37 -0
  119. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  120. package/src/index.ts +4 -0
  121. package/src/player/components/AudioWaveform.tsx +3 -2
  122. package/src/player/components/CompositionThumbnail.tsx +2 -1
  123. package/src/player/components/clipMenuNormalize.ts +2 -1
  124. package/src/player/components/clipPeakMap.ts +2 -1
  125. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  126. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  127. package/src/player/lib/thumbnailScheduler.ts +10 -2
  128. package/src/player/sceneSwap.ts +2 -1
  129. package/src/telemetry/client.ts +2 -1
  130. package/src/utils/blockInstaller.ts +2 -1
  131. package/src/utils/elementGsap.test.ts +9 -0
  132. package/src/utils/elementGsap.ts +7 -0
  133. package/src/utils/gsapSoftReload.ts +2 -3
  134. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  135. package/src/utils/gsapUndoRestore.ts +3 -6
  136. package/src/utils/previewPrefetch.ts +2 -1
  137. package/src/utils/razorSplitTransaction.ts +12 -8
  138. package/src/utils/softReloadTargets.ts +1 -3
  139. package/src/utils/studioApiFetch.test.ts +75 -0
  140. package/src/utils/studioApiFetch.ts +13 -0
  141. package/src/utils/studioFileHistory.ts +4 -1
  142. package/src/utils/studioHelpers.ts +7 -1
  143. package/src/utils/studioPendingEdits.test.ts +23 -6
  144. package/src/utils/studioPendingEdits.ts +32 -8
  145. package/src/utils/studioTelemetry.ts +2 -1
  146. package/src/utils/timelineCompositionInsert.ts +3 -2
  147. package/src/webmcp/StudioAgentTools.tsx +2 -1
  148. package/dist/chunk-TXZMMOMA.js.map +0 -1
  149. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -698,7 +698,9 @@ describe("runCommit — instantPatch wiring", () => {
698
698
  await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
699
699
  });
700
700
 
701
- expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
701
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"), {
702
+ credentials: "omit",
703
+ });
702
704
  expect(applySoftReload).toHaveBeenCalledWith(
703
705
  expect.anything(),
704
706
  NESTED_SCRIPT,
@@ -28,12 +28,13 @@ import {
28
28
  formatGsapMutationRejectionToast,
29
29
  readJsonResponseBody,
30
30
  } from "./gsapScriptCommitHelpers";
31
- import type {
32
- CommitMutation,
33
- CommitMutationCall,
34
- CommitMutationOptions,
35
- GsapScriptCommitsParams,
36
- MutationResult,
31
+ import {
32
+ gsapWriteFile,
33
+ type CommitMutation,
34
+ type CommitMutationCall,
35
+ type CommitMutationOptions,
36
+ type GsapScriptCommitsParams,
37
+ type MutationResult,
37
38
  } from "./gsapScriptCommitTypes";
38
39
  import { useGsapAnimationOps } from "./useGsapAnimationOps";
39
40
  import { useGsapArcPathOps } from "./useGsapArcPathOps";
@@ -44,13 +45,14 @@ import {
44
45
  useSafeGsapCommitMutation,
45
46
  } from "./useSafeGsapCommitMutation";
46
47
  import { studioWriteHeaders } from "../utils/studioFileVersion";
48
+ import { studioApiFetch } from "../utils/studioApiFetch";
47
49
 
48
50
  async function mutateGsapScript(
49
51
  projectId: string,
50
52
  sourceFile: string,
51
53
  mutation: Record<string, unknown>,
52
54
  ): Promise<MutationResult> {
53
- const res = await fetch(
55
+ const res = await studioApiFetch(
54
56
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
55
57
  {
56
58
  method: "POST",
@@ -69,7 +71,7 @@ async function mutateGsapScriptBatch(
69
71
  sourceFile: string,
70
72
  mutations: Record<string, unknown>[],
71
73
  ): Promise<MutationResult> {
72
- const res = await fetch(
74
+ const res = await studioApiFetch(
73
75
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`,
74
76
  {
75
77
  method: "POST",
@@ -442,14 +444,14 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
442
444
  };
443
445
  const commit: CommitMutation = trackedStudioEdit((selection, mutation, options) => {
444
446
  if (!activeProjectId) return Promise.resolve();
445
- const file = selection.sourceFile || activeCompPath || "index.html";
447
+ const file = gsapWriteFile(selection, activeCompPath);
446
448
  return serializeCommit(file, options.serializeKey, () =>
447
449
  runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
448
450
  );
449
451
  }) as CommitMutation;
450
452
  commit.batch = trackedStudioEdit((calls: CommitMutationCall[], options: CommitMutationOptions) => {
451
453
  if (!activeProjectId) return Promise.resolve();
452
- const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
454
+ const file = calls[0] ? gsapWriteFile(calls[0].selection, activeCompPath) : "index.html";
453
455
  return serializeCommit(file, options.serializeKey, () =>
454
456
  runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
455
457
  );
@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react";
3
3
  import type { LintFinding } from "../components/LintModal";
4
4
  import { usePlayerStore } from "../player";
5
5
  import { isPreviewBooted, whenPreviewBooted } from "../player/store/playerStore";
6
+ import { studioApiFetch } from "../utils/studioApiFetch";
6
7
 
7
8
  interface RawFinding {
8
9
  severity?: string;
@@ -25,7 +26,7 @@ function parseFinding(f: RawFinding): LintFinding & { elementId?: string; file?:
25
26
  }
26
27
 
27
28
  async function fetchLintFindings(projectId: string) {
28
- const res = await fetch(buildProjectApiPath(projectId, `/lint`));
29
+ const res = await studioApiFetch(buildProjectApiPath(projectId, `/lint`));
29
30
  const data = await res.json();
30
31
  return ((data.findings ?? []) as RawFinding[]).map(parseFinding);
31
32
  }
@@ -88,6 +88,234 @@ it("an edit Studio saved is undone and redone by the project's history, with the
88
88
  expect(file()).toBe("B");
89
89
  });
90
90
 
91
+ it("undoes the edit claimed since the key, not a later edit the server took in first", async () => {
92
+ const { dir, hook, file, save, readFile } = await studio();
93
+ const atKey = hook().claims();
94
+ save("B");
95
+ await act(() =>
96
+ hook().recordEdit({
97
+ label: "Moved Title",
98
+ files: { "index.html": { before: "A", after: "B" } },
99
+ }),
100
+ );
101
+ writeFileSync(join(dir, "card.html"), "Y");
102
+ const later = hook().recordEdit({
103
+ label: "Added Card",
104
+ files: { "card.html": { before: "", after: "Y" } },
105
+ });
106
+ const afterLater = async <T>(_paths: readonly string[], task: () => Promise<T>) => {
107
+ await later;
108
+ return task();
109
+ };
110
+
111
+ const undone = await act(() =>
112
+ hook().undo({ readFile, serialize: afterLater, claimedAfter: atKey }),
113
+ );
114
+ expect(undone).toMatchObject({ ok: true, label: "Undid: Moved Title" });
115
+ expect(file()).toBe("A");
116
+ expect(readFileSync(join(dir, "card.html"), "utf8")).toBe("Y");
117
+ });
118
+
119
+ it("a second undo pressed while the same edit saves undoes the edit before it", async () => {
120
+ const { hook, file, save, readFile } = await studio();
121
+ save("B");
122
+ await act(() =>
123
+ hook().recordEdit({
124
+ label: "Moved Title",
125
+ files: { "index.html": { before: "A", after: "B" } },
126
+ }),
127
+ );
128
+ await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
129
+ const atKey = hook().claims();
130
+ save("C");
131
+ await act(() =>
132
+ hook().recordEdit({
133
+ label: "Moved Card",
134
+ files: { "index.html": { before: "B", after: "C" } },
135
+ }),
136
+ );
137
+
138
+ let queue: Promise<unknown> = Promise.resolve();
139
+ const serialize = <T>(_paths: readonly string[], task: () => Promise<T>) => {
140
+ const run = queue.then(task);
141
+ queue = run.catch(() => undefined);
142
+ return run;
143
+ };
144
+ const press = () => hook().undo({ readFile, claimedAfter: atKey, serialize });
145
+
146
+ const [first, second] = await act(() => Promise.all([press(), press()]));
147
+
148
+ expect([first, second]).toMatchObject([
149
+ { ok: true, label: "Undid: Moved Card" },
150
+ { ok: true, label: "Undid: Moved Title" },
151
+ ]);
152
+ expect(file()).toBe("A");
153
+ });
154
+
155
+ it("keeps the claim when its undo fails, so the next press targets that edit again", async () => {
156
+ const { hook, save, readFile } = await studio();
157
+ save("B");
158
+ await act(() =>
159
+ hook().recordEdit({
160
+ label: "Moved Title",
161
+ files: { "index.html": { before: "A", after: "B" } },
162
+ }),
163
+ );
164
+ await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
165
+ const atKey = hook().claims();
166
+ save("C");
167
+ await act(() =>
168
+ hook().recordEdit({
169
+ label: "Moved Card",
170
+ files: { "index.html": { before: "B", after: "C" } },
171
+ }),
172
+ );
173
+ const posted: string[] = [];
174
+ const fetch = globalThis.fetch;
175
+ vi.stubGlobal("fetch", (url: string, init?: RequestInit) => {
176
+ if (init?.method === "POST") posted.push(url.slice(url.lastIndexOf("/") + 1));
177
+ return fetch(url, init);
178
+ });
179
+
180
+ save("edited elsewhere");
181
+ const failed = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
182
+ save("C");
183
+ const retried = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
184
+
185
+ expect(failed.ok).toBe(false);
186
+ expect(retried).toMatchObject({ ok: true, label: "Undid: Moved Card" });
187
+ expect(posted.filter((p) => p === "undo" || p === "step")).toEqual(["undo", "undo"]);
188
+ });
189
+
190
+ it("does not undo again an edit another press already undid", async () => {
191
+ const { hook, file, save, readFile } = await studio();
192
+ save("B");
193
+ await act(() =>
194
+ hook().recordEdit({
195
+ label: "Moved Title",
196
+ files: { "index.html": { before: "A", after: "B" } },
197
+ }),
198
+ );
199
+ await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
200
+ const atKey = hook().claims();
201
+ save("C");
202
+ await act(() =>
203
+ hook().recordEdit({
204
+ label: "Moved Card",
205
+ files: { "index.html": { before: "B", after: "C" } },
206
+ }),
207
+ );
208
+ let calls = 0;
209
+ let queue: Promise<unknown> = Promise.resolve();
210
+ const serialize = <T>(_paths: readonly string[], task: () => Promise<T>) => {
211
+ calls += 1;
212
+ if (calls === 1) return Promise.reject(new Error("save failed"));
213
+ const run = queue.then(task);
214
+ queue = run.catch(() => undefined);
215
+ return run;
216
+ };
217
+ const press = () => hook().undo({ readFile, claimedAfter: atKey, serialize });
218
+
219
+ const [first, second] = await act(() => Promise.allSettled([press(), press()]));
220
+ const third = await act(() => press());
221
+
222
+ expect(first.status).toBe("rejected");
223
+ expect(second).toMatchObject({
224
+ status: "fulfilled",
225
+ value: { ok: true, label: "Undid: Moved Card" },
226
+ });
227
+ expect(third).toMatchObject({ ok: true, label: "Undid: Moved Title" });
228
+ expect(file()).toBe("A");
229
+ });
230
+
231
+ it("does not undo again an edit a later press undid first", async () => {
232
+ const { hook, file, save, readFile } = await studio();
233
+ save("B");
234
+ await act(() =>
235
+ hook().recordEdit({
236
+ label: "Moved Title",
237
+ files: { "index.html": { before: "A", after: "B" } },
238
+ }),
239
+ );
240
+ await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
241
+ const atKey = hook().claims();
242
+ save("C");
243
+ await act(() =>
244
+ hook().recordEdit({
245
+ label: "Moved Card",
246
+ files: { "index.html": { before: "B", after: "C" } },
247
+ }),
248
+ );
249
+ let fail!: (error: Error) => void;
250
+ const held = <T>(_paths: readonly string[], _task: () => Promise<T>) =>
251
+ new Promise<T>((_resolve, reject) => {
252
+ fail = reject;
253
+ });
254
+ const failed = hook()
255
+ .undo({ readFile, claimedAfter: atKey, serialize: held })
256
+ .catch(() => undefined);
257
+
258
+ const second = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
259
+ fail(new Error("save failed"));
260
+ await failed;
261
+ const third = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
262
+
263
+ expect(second).toMatchObject({ ok: true, label: "Undid: Moved Card" });
264
+ expect(third).toMatchObject({ ok: true, label: "Undid: Moved Title" });
265
+ expect(file()).toBe("A");
266
+ });
267
+
268
+ it("refuses a second press whose edit is undone once an edit was made after the press", async () => {
269
+ const { dir, hook, file, save, readFile } = await studio();
270
+ save("B");
271
+ await act(() =>
272
+ hook().recordEdit({
273
+ label: "Moved Title",
274
+ files: { "index.html": { before: "A", after: "B" } },
275
+ }),
276
+ );
277
+ await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
278
+ const atKey = hook().claims();
279
+ save("C");
280
+ await act(() =>
281
+ hook().recordEdit({
282
+ label: "Moved Card",
283
+ files: { "index.html": { before: "B", after: "C" } },
284
+ }),
285
+ );
286
+ let open!: () => void;
287
+ const opened = new Promise<void>((resolve) => {
288
+ open = resolve;
289
+ });
290
+ let queue: Promise<unknown> = Promise.resolve();
291
+ let queued = 0;
292
+ const serialize = <T>(_paths: readonly string[], task: () => Promise<T>) => {
293
+ queued += 1;
294
+ const wait = queued === 2 ? opened : Promise.resolve();
295
+ const run = queue.then(() => wait).then(task);
296
+ queue = run.catch(() => undefined);
297
+ return run;
298
+ };
299
+ const press = () => hook().undo({ readFile, claimedAfter: atKey, serialize });
300
+ const presses = Promise.all([press(), press()]);
301
+ await vi.waitFor(() => expect(file()).toBe("B"));
302
+ writeFileSync(join(dir, "card.html"), "Y");
303
+ await act(() =>
304
+ hook().recordEdit({
305
+ label: "Added Card",
306
+ files: { "card.html": { before: "", after: "Y" } },
307
+ }),
308
+ );
309
+
310
+ open();
311
+ const [first, second] = await act(() => presses);
312
+
313
+ expect(first).toMatchObject({ ok: true, label: "Undid: Moved Card" });
314
+ expect(second).toMatchObject({ ok: false, reason: "content-mismatch" });
315
+ expect(readFileSync(join(dir, "card.html"), "utf8")).toBe("Y");
316
+ expect(file()).toBe("B");
317
+ });
318
+
91
319
  it("a drag's edits under one key undo as one step, even before the drag goes idle", async () => {
92
320
  const { hook, file, save, readFile } = await studio();
93
321
  const writes: Array<[before: string, after: string]> = [
@@ -115,6 +343,32 @@ it("a drag's edits under one key undo as one step, even before the drag goes idl
115
343
  expect(file()).toBe("A");
116
344
  });
117
345
 
346
+ it("an undo before the history view shows a drag's held claim still waits on the files it wrote", async () => {
347
+ const { hook, save, readFile } = await studio();
348
+ const server = globalThis.fetch;
349
+ vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
350
+ init?.method === "POST"
351
+ ? server(url, init)
352
+ : Promise.resolve(new Response(null, { status: 503 })),
353
+ );
354
+ save("B");
355
+ await act(() =>
356
+ hook().recordEdit({
357
+ label: "Dragged Title",
358
+ coalesceKey: "drag",
359
+ files: { "index.html": { before: "A", after: "B" } },
360
+ }),
361
+ );
362
+ let waitedOn: readonly string[] = [];
363
+ const serialize = <T>(paths: readonly string[], task: () => Promise<T>) => {
364
+ waitedOn = paths;
365
+ return task();
366
+ };
367
+
368
+ await act(() => hook().undo({ readFile, serialize }));
369
+ expect(waitedOn).toEqual(["index.html"]);
370
+ });
371
+
118
372
  it("an agent's edit made seconds before Studio's stays the agent's: Cmd+Z undoes only Studio's", async () => {
119
373
  const { hook, file, save, readFile } = await studio();
120
374
  save("B");
@@ -159,7 +413,11 @@ it("without a history on the server an edit still saves, and there is nothing to
159
413
  });
160
414
 
161
415
  /** Answers requests to `route` with `reply` instead of the engine, every other request as before; returns the undo. */
162
- function answer(route: "/history/step" | "/history/claim", status: number, reply: object | string) {
416
+ function answer(
417
+ route: "/history" | "/history/step" | "/history/claim",
418
+ status: number,
419
+ reply: object | string,
420
+ ) {
163
421
  const real = globalThis.fetch;
164
422
  vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
165
423
  url.endsWith(route)
@@ -230,6 +488,24 @@ it("a step whose reply cannot be read says so, instead of throwing", async () =>
230
488
  });
231
489
  });
232
490
 
491
+ it("an edit whose history reply cannot be read is still saved, and the reply is its own error", async () => {
492
+ const { hook, file, save } = await studio();
493
+ const logged = vi.spyOn(console, "error").mockImplementation(() => {});
494
+ cleanup.push(() => logged.mockRestore());
495
+ answer("/history", 200, "<html>");
496
+ save("B");
497
+ await act(() =>
498
+ hook().recordEdit({
499
+ label: "Moved Title",
500
+ files: { "index.html": { before: "A", after: "B" } },
501
+ }),
502
+ );
503
+ await vi.waitFor(() =>
504
+ expect(logged).toHaveBeenCalledWith("The history's reply was unreadable."),
505
+ );
506
+ expect(file()).toBe("B");
507
+ });
508
+
233
509
  it("a step that cannot reach the server says so", async () => {
234
510
  const { hook, readFile } = await studio();
235
511
  const real = globalThis.fetch;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server";
3
3
  import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
4
4
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
5
+ import { studioApiFetch } from "../utils/studioApiFetch";
5
6
 
6
7
  interface RecordEditInput {
7
8
  label: string;
@@ -13,6 +14,7 @@ interface RecordEditInput {
13
14
  interface ApplyCallbacks {
14
15
  readFile: (path: string) => Promise<string>;
15
16
  serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
17
+ claimedAfter?: number;
16
18
  }
17
19
 
18
20
  export interface UsePersistentEditHistoryOptions {
@@ -53,6 +55,8 @@ function createOwnHistory() {
53
55
  const own = new Map<string, OwnFiles>();
54
56
  let next: Record<"undo" | "redo", NextStep | null> | null = null;
55
57
  let changes = 0;
58
+ let claimed = { count: 0, id: "" };
59
+ const undoneIds = new Set<string>();
56
60
  const remember = (id: string, files: OwnFiles) => {
57
61
  const known = own.get(id) ?? {};
58
62
  for (const [path, { before, after }] of Object.entries(files)) {
@@ -73,7 +77,16 @@ function createOwnHistory() {
73
77
  if (seen === changes) next = { undo: view.back, redo: view.forward };
74
78
  },
75
79
  changes: () => changes,
80
+ claimCount: () => claimed.count,
81
+ claimedAfter: (count: number) => (claimed.count > count && claimed.id ? claimed.id : null),
82
+ targetFor: (id: string | null, pressedAt: number) =>
83
+ !id || !undoneIds.has(id) ? id : claimed.count > pressedAt ? false : null,
84
+ noteClaim: (id: string) => {
85
+ claimed = { count: claimed.count + 1, id };
86
+ },
76
87
  stepped: (entry: { id: string; undoes?: string }) => {
88
+ if (entry.undoes) undoneIds.add(entry.undoes);
89
+ if (undoneIds.size > OWN_ENTRIES_KEPT) undoneIds.delete(undoneIds.values().next().value!);
77
90
  const undone = entry.undoes ? own.get(entry.undoes) : undefined;
78
91
  if (!undone) return;
79
92
  const swapped = Object.entries(undone).map(([path, f]) => [
@@ -102,6 +115,7 @@ function createOwnHistory() {
102
115
  },
103
116
  clear: () => {
104
117
  own.clear();
118
+ undoneIds.clear();
105
119
  next = null;
106
120
  changes += 1;
107
121
  },
@@ -113,12 +127,20 @@ function historyUrl(projectId: string, path = ""): string {
113
127
  return `/api/projects/${encodeURIComponent(projectId)}/history${path}`;
114
128
  }
115
129
 
130
+ async function cantUndo(paths: string[] | undefined): Promise<ApplyResult> {
131
+ return { ok: false, reason: "content-mismatch", paths: paths ?? [] };
132
+ }
133
+
134
+ function unionPaths(...lists: Array<readonly string[] | undefined>): string[] {
135
+ return [...new Set(lists.flatMap((list) => list ?? []))];
136
+ }
137
+
116
138
  async function post(
117
139
  url: string,
118
140
  body: object,
119
141
  headers: Record<string, string> = {},
120
142
  ): Promise<{ ok: true; body: unknown } | { ok: false; status: number; error: string }> {
121
- const response = await fetch(url, {
143
+ const response = await studioApiFetch(url, {
122
144
  method: "POST",
123
145
  headers: { "Content-Type": "application/json", ...headers },
124
146
  body: JSON.stringify(body),
@@ -194,9 +216,12 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
194
216
  const refresh = useCallback(async () => {
195
217
  if (!projectId) return;
196
218
  const seen = own.changes();
197
- const response = await fetch(historyUrl(projectId)).catch(() => null);
198
- const next = response?.ok ? ((await response.json()) as HistoryView) : EMPTY;
219
+ const response = await studioApiFetch(historyUrl(projectId)).catch(() => null);
220
+ const next = response?.ok
221
+ ? ((await response.json().catch(() => null)) as HistoryView | null)
222
+ : EMPTY;
199
223
  if (projectIdRef.current !== projectId) return;
224
+ if (!next) return console.error("The history's reply was unreadable.");
200
225
  setView(next);
201
226
  own.offered(seen, next);
202
227
  }, [projectId, own]);
@@ -221,7 +246,10 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
221
246
  ...(coalesceKey && { coalesceKey, idleMs: coalesceMs ?? DEFAULT_COALESCE_MS }),
222
247
  });
223
248
  const claimed = claimHeld(reply, label);
224
- if (claimed) own.remember(claimed, files);
249
+ if (claimed) {
250
+ own.remember(claimed, files);
251
+ own.noteClaim(claimed);
252
+ }
225
253
  heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
226
254
  await refresh();
227
255
  },
@@ -231,14 +259,20 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
231
259
  const step = useCallback(
232
260
  async (direction: "undo" | "redo", callbacks: ApplyCallbacks): Promise<ApplyResult> => {
233
261
  if (!projectId) return { ok: false, reason: "empty" };
262
+ const candidate =
263
+ direction === "undo" && callbacks.claimedAfter !== undefined
264
+ ? own.claimedAfter(callbacks.claimedAfter)
265
+ : null;
234
266
  const next = direction === "undo" ? view.back : view.forward;
235
- const paths = [...new Set([...(next?.paths ?? []), ...(heldClaimRef.current?.paths ?? [])])];
267
+ const stepPaths = candidate ? Object.keys(own.afterOf(candidate)) : next?.paths;
268
+ const paths = unionPaths(stepPaths, heldClaimRef.current?.paths);
269
+ const pressedAt = own.claimCount();
236
270
  own.overtake();
237
- const run = async (): Promise<ApplyResult> => {
271
+ const attempt = async (target: string | null): Promise<ApplyResult> => {
238
272
  const previous = await readAll(paths, callbacks.readFile);
239
273
  const posted = await post(
240
- historyUrl(projectId, "/step"),
241
- { direction: direction === "undo" ? "back" : "forward" },
274
+ historyUrl(projectId, target ? "/undo" : "/step"),
275
+ target ? { entryId: target } : { direction: direction === "undo" ? "back" : "forward" },
242
276
  studioWriteHeaders(),
243
277
  );
244
278
  heldClaimRef.current = null;
@@ -266,6 +300,10 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
266
300
  ),
267
301
  };
268
302
  };
303
+ const run = () => {
304
+ const target = own.targetFor(candidate, pressedAt);
305
+ return target === false ? cantUndo(stepPaths) : attempt(target);
306
+ };
269
307
  return callbacks.serialize ? callbacks.serialize(paths, run) : run();
270
308
  },
271
309
  [projectId, view, refresh, own],
@@ -301,6 +339,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
301
339
  undo,
302
340
  redo,
303
341
  predict: own.predict,
342
+ claims: own.claimCount,
304
343
  noteOutsideChange,
305
344
  };
306
345
  }
@@ -79,12 +79,13 @@ describe("undo that re-runs the top-level script over an element of a nested com
79
79
  };
80
80
  }
81
81
 
82
- async function undo(fileOk: boolean) {
82
+ async function undo(fileOk: boolean, projectId: string | null = "p1") {
83
83
  const { iframe, nwid } = nestedPreview();
84
84
  const reloadPreview = vi.fn();
85
85
  const fetch = vi.fn(async () => ({ ok: fileOk, json: async () => ({ content: SUB }) }));
86
86
  vi.stubGlobal("fetch", fetch);
87
- usePlayerStore.getState().beginTimelineSession("p1");
87
+ if (projectId) usePlayerStore.getState().beginTimelineSession(projectId);
88
+ else usePlayerStore.setState({ timelineProjectId: null });
88
89
  let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
89
90
  null;
90
91
  function Harness() {
@@ -109,7 +110,9 @@ describe("undo that re-runs the top-level script over an element of a nested com
109
110
  it("restores the element from its own composition file", async () => {
110
111
  const { nwid, reloadPreview, fetch } = await undo(true);
111
112
 
112
- expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
113
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"), {
114
+ credentials: "omit",
115
+ });
113
116
  expect(reloadPreview).not.toHaveBeenCalled();
114
117
  expect(nwid.getAttribute("style")).toBe("left: 40px;");
115
118
  });
@@ -120,4 +123,12 @@ describe("undo that re-runs the top-level script over an element of a nested com
120
123
  expect(reloadPreview).toHaveBeenCalledTimes(1);
121
124
  expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
122
125
  });
126
+
127
+ it("reloads the preview in full when no project is open to read that file from", async () => {
128
+ const { nwid, reloadPreview, fetch } = await undo(true, null);
129
+
130
+ expect(fetch).not.toHaveBeenCalled();
131
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
132
+ expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
133
+ });
123
134
  });
@@ -163,8 +163,9 @@ export function usePreviewPersistence({
163
163
  }, [drainPendingDomEditSaves]);
164
164
 
165
165
  const settlePendingEdits = useCallback(async (): Promise<void> => {
166
- await flushStudioPendingEdits();
167
- await domEditSaveQueueRef.current?.waitForIdle();
166
+ const queued = domEditSaveQueueRef.current?.waitForIdle();
167
+ await flushStudioPendingEdits({ onlyCurrent: true });
168
+ await queued;
168
169
  }, []);
169
170
 
170
171
  const resetDomEditSaveQueueBreaker = useCallback(() => {
@@ -209,14 +210,15 @@ export function usePreviewPersistence({
209
210
  // multi-file, sub-comp, or a permanent soft-reload failure.
210
211
  const projectId = usePlayerStore.getState().timelineProjectId;
211
212
  const nestedFiles = await settleNestedReads(
212
- projectId
213
- ? readUndoNestedFiles(
214
- previewIframeRef.current,
215
- activeCompPathRef.current,
216
- restore.files,
217
- (path) => readProjectFileContent(projectId, path),
218
- )
219
- : null,
213
+ readUndoNestedFiles(
214
+ previewIframeRef.current,
215
+ activeCompPathRef.current,
216
+ restore.files,
217
+ (path) =>
218
+ projectId
219
+ ? readProjectFileContent(projectId, path)
220
+ : Promise.reject(new Error("No project is open to read nested files from.")),
221
+ ),
220
222
  );
221
223
  const strategy = applyUndoRestoreToPreview(
222
224
  previewIframeRef.current,
@@ -258,7 +260,7 @@ export function usePreviewPersistence({
258
260
 
259
261
  // ── Migrate legacy studio-motion.json ──
260
262
  // Projects that used the old JSON-file approach may still have a populated
261
- // `.hyperframes/studio-motion.json`. The studio no longer reads from it, but
263
+ // studio-motion.json in the project's .hyperframes folder. The studio no longer reads it, but
262
264
  // the legacy render-script injection in `preview.ts` / `vite.studioMotion.ts`
263
265
  // could still fire alongside the new seek-reapply runtime. Empty the file so
264
266
  // the legacy codepath no-ops.