@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
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef } from "react";
1
+ import { useState, useCallback, useMemo, useRef } from "react";
2
2
  import type { EditingFile } from "../utils/studioHelpers";
3
3
  import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
@@ -7,8 +7,10 @@ import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
7
7
  import {
8
8
  createStudioSaveHttpError,
9
9
  retryStudioSave,
10
+ StudioFileConflictError,
10
11
  StudioSaveNetworkError,
11
12
  } from "../utils/studioSaveDiagnostics";
13
+ import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
12
14
  import { useFileTree } from "./useFileTree";
13
15
  import { useEditorSave } from "./useEditorSave";
14
16
 
@@ -50,6 +52,17 @@ export function useFileManager({
50
52
  projectIdRef.current = projectId;
51
53
 
52
54
  const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
55
+ const fileVersionScope = useMemo(
56
+ () => ({ projectId, versions: new Map<string, string | null>() }),
57
+ [projectId],
58
+ );
59
+ const fileVersions = fileVersionScope.versions;
60
+ const observeProjectFileVersion = useCallback(
61
+ (path: string, version: string | null) => {
62
+ fileVersions.set(path, version);
63
+ },
64
+ [fileVersions],
65
+ );
53
66
 
54
67
  // ── File tree ──
55
68
 
@@ -66,38 +79,95 @@ export function useFileManager({
66
79
 
67
80
  // ── Core file I/O ──
68
81
 
69
- const readProjectFile = useCallback(async (path: string): Promise<string> => {
70
- const pid = projectIdRef.current;
71
- if (!pid) throw new Error("No active project");
72
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
73
- if (!response.ok) throw new Error(`Failed to read ${path}`);
74
- const data = (await response.json()) as { content?: string };
75
- if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
76
- return data.content;
77
- }, []);
82
+ const readProjectFile = useCallback(
83
+ async (path: string): Promise<string> => {
84
+ if (!projectId) throw new Error("No active project");
85
+ const response = await fetch(
86
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
87
+ );
88
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
89
+ const data = (await response.json()) as { content?: string; version?: string };
90
+ if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
91
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
92
+ return data.content;
93
+ },
94
+ [fileVersions, projectId],
95
+ );
78
96
 
79
- const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
80
- const pid = projectIdRef.current;
81
- if (!pid) throw new Error("No active project");
82
- await retryStudioSave(async () => {
83
- let response: Response;
84
- try {
85
- response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
86
- method: "PUT",
87
- headers: { "Content-Type": "text/plain" },
88
- body: content,
89
- });
90
- } catch (error) {
91
- throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
92
- cause: error,
93
- });
97
+ const writeProjectFile = useCallback(
98
+ async (path: string, content: string, expectedContent?: string): Promise<void> => {
99
+ if (!projectId) throw new Error("No active project");
100
+ const writeProjectId = projectId;
101
+ let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
102
+ if (expectedVersion === undefined) {
103
+ const preflight = await fetch(
104
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
105
+ );
106
+ if (preflight.ok) {
107
+ const data = (await preflight.json()) as { content?: string; version?: string };
108
+ throw new StudioFileConflictError({
109
+ filePath: path,
110
+ currentVersion: data.version ?? preflight.headers.get("etag"),
111
+ currentContent: data.content ?? null,
112
+ attemptedContent: content,
113
+ });
114
+ } else if (preflight.status === 404) {
115
+ expectedVersion = null;
116
+ } else {
117
+ throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
118
+ }
94
119
  }
95
- if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
96
- });
97
- if (editingPathRef.current === path) {
98
- setEditingFile({ path, content });
99
- }
100
- }, []);
120
+ const writeToken = createStudioWriteToken();
121
+ await retryStudioSave(async () => {
122
+ let response: Response;
123
+ try {
124
+ response = await fetch(
125
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
126
+ {
127
+ method: "PUT",
128
+ headers: {
129
+ "Content-Type": "text/plain",
130
+ "X-Hyperframes-Write-Token": writeToken,
131
+ ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
132
+ },
133
+ body: content,
134
+ },
135
+ );
136
+ } catch (error) {
137
+ throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
138
+ cause: error,
139
+ });
140
+ }
141
+ if (response.status === 409) {
142
+ const conflict = (await response.json().catch(() => null)) as {
143
+ currentVersion?: string | null;
144
+ currentContent?: string | null;
145
+ } | null;
146
+ const currentVersion = conflict?.currentVersion ?? null;
147
+ if (currentVersion && conflict?.currentContent === content) {
148
+ fileVersions.set(path, currentVersion);
149
+ return;
150
+ }
151
+ throw new StudioFileConflictError({
152
+ filePath: path,
153
+ currentVersion,
154
+ currentContent: conflict?.currentContent ?? null,
155
+ attemptedContent: content,
156
+ });
157
+ }
158
+ if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
159
+ const result = (await response.json()) as { version?: string };
160
+ const version = result.version ?? response.headers.get("etag");
161
+ if (!version)
162
+ throw new Error(`Save response for ${path} did not include a content version`);
163
+ fileVersions.set(path, version);
164
+ });
165
+ if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
166
+ setEditingFile({ path, content });
167
+ }
168
+ },
169
+ [fileVersions, projectId],
170
+ );
101
171
 
102
172
  const updateEditingFileContent = useCallback((path: string, content: string) => {
103
173
  if (editingPathRef.current === path) {
@@ -105,16 +175,19 @@ export function useFileManager({
105
175
  }
106
176
  }, []);
107
177
 
108
- const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
109
- const pid = projectIdRef.current;
110
- if (!pid) throw new Error("No active project");
111
- const response = await fetch(
112
- `/api/projects/${pid}/files/${encodeURIComponent(path)}?optional=1`,
113
- );
114
- if (!response.ok) throw new Error(`Failed to read ${path}`);
115
- const data = (await response.json()) as { content?: string };
116
- return typeof data.content === "string" ? data.content : "";
117
- }, []);
178
+ const readOptionalProjectFile = useCallback(
179
+ async (path: string): Promise<string> => {
180
+ if (!projectId) throw new Error("No active project");
181
+ const response = await fetch(
182
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
183
+ );
184
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
185
+ const data = (await response.json()) as { content?: string; version?: string };
186
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
187
+ return typeof data.content === "string" ? data.content : "";
188
+ },
189
+ [fileVersions, projectId],
190
+ );
118
191
 
119
192
  // ── Editor save (debounced content change) ──
120
193
 
@@ -146,13 +219,14 @@ export function useFileManager({
146
219
  setEditingFile({ path, content: null });
147
220
  return;
148
221
  }
149
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
222
+ fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
150
223
  .then((r) => {
151
224
  if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
152
225
  return r.json();
153
226
  })
154
- .then((data: { content?: string }) => {
227
+ .then((data: { content?: string; version?: string }) => {
155
228
  if (data.content != null) {
229
+ fileVersions.set(path, data.version ?? null);
156
230
  setEditingFile({ path, content: data.content });
157
231
  }
158
232
  })
@@ -160,7 +234,7 @@ export function useFileManager({
160
234
  showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
161
235
  });
162
236
  },
163
- [showToast],
237
+ [fileVersions, showToast],
164
238
  );
165
239
 
166
240
  // ── Click-to-source ──
@@ -179,13 +253,14 @@ export function useFileManager({
179
253
  const requestId = ++revealRequestIdRef.current;
180
254
  const controller = new AbortController();
181
255
  revealAbortRef.current = controller;
182
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(sourceFile)}`, {
256
+ fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`, {
183
257
  signal: controller.signal,
184
258
  })
185
259
  .then((r) => r.json())
186
- .then((data: { content?: string }) => {
260
+ .then((data: { content?: string; version?: string }) => {
187
261
  if (requestId !== revealRequestIdRef.current) return;
188
262
  if (data.content != null) {
263
+ fileVersions.set(sourceFile, data.version ?? null);
189
264
  setEditingFile({ path: sourceFile, content: data.content });
190
265
  const match = findTagByTarget(data.content, target);
191
266
  setRevealSourceOffset(match ? match.start : null);
@@ -193,7 +268,7 @@ export function useFileManager({
193
268
  })
194
269
  .catch(() => {});
195
270
  },
196
- [editingFile?.content],
271
+ [editingFile?.content, fileVersions],
197
272
  );
198
273
 
199
274
  // ── Upload ──
@@ -211,7 +286,7 @@ export function useFileManager({
211
286
 
212
287
  const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
213
288
  try {
214
- const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
289
+ const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
215
290
  method: "POST",
216
291
  body: formData,
217
292
  });
@@ -251,11 +326,14 @@ export function useFileManager({
251
326
  content =
252
327
  '<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
253
328
  }
254
- const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
255
- method: "POST",
256
- headers: { "Content-Type": "text/plain" },
257
- body: content,
258
- });
329
+ const res = await fetch(
330
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
331
+ {
332
+ method: "POST",
333
+ headers: { "Content-Type": "text/plain" },
334
+ body: content,
335
+ },
336
+ );
259
337
  if (res.ok) {
260
338
  await refreshFileTree();
261
339
  handleFileSelect(path);
@@ -273,7 +351,7 @@ export function useFileManager({
273
351
  const pid = projectIdRef.current;
274
352
  if (!pid) return;
275
353
  const res = await fetch(
276
- `/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`,
354
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path + "/.gitkeep")}`,
277
355
  {
278
356
  method: "POST",
279
357
  headers: { "Content-Type": "text/plain" },
@@ -295,9 +373,12 @@ export function useFileManager({
295
373
  async (path: string) => {
296
374
  const pid = projectIdRef.current;
297
375
  if (!pid) return;
298
- const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
299
- method: "DELETE",
300
- });
376
+ const res = await fetch(
377
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
378
+ {
379
+ method: "DELETE",
380
+ },
381
+ );
301
382
  if (res.ok) {
302
383
  if (editingPathRef.current === path) setEditingFile(null);
303
384
  await refreshFileTree();
@@ -314,11 +395,14 @@ export function useFileManager({
314
395
  async (oldPath: string, newPath: string) => {
315
396
  const pid = projectIdRef.current;
316
397
  if (!pid) return;
317
- const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(oldPath)}`, {
318
- method: "PATCH",
319
- headers: { "Content-Type": "application/json" },
320
- body: JSON.stringify({ newPath }),
321
- });
398
+ const res = await fetch(
399
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(oldPath)}`,
400
+ {
401
+ method: "PATCH",
402
+ headers: { "Content-Type": "application/json" },
403
+ body: JSON.stringify({ newPath }),
404
+ },
405
+ );
322
406
  if (res.ok) {
323
407
  if (editingPathRef.current === oldPath) {
324
408
  handleFileSelect(newPath);
@@ -338,7 +422,7 @@ export function useFileManager({
338
422
  async (path: string) => {
339
423
  const pid = projectIdRef.current;
340
424
  if (!pid) return;
341
- const res = await fetch(`/api/projects/${pid}/duplicate-file`, {
425
+ const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
342
426
  method: "POST",
343
427
  headers: { "Content-Type": "application/json" },
344
428
  body: JSON.stringify({ path }),
@@ -367,17 +451,18 @@ export function useFileManager({
367
451
 
368
452
  const handleImportFonts = useCallback(
369
453
  async (files: FileList | File[]): Promise<ImportedFontAsset[]> => {
454
+ const pid = projectIdRef.current;
455
+ if (!pid) return [];
370
456
  const uploaded = await uploadProjectFiles(
371
457
  Array.from(files).filter((file) => FONT_EXT.test(file.name)),
372
458
  "assets/fonts",
373
459
  );
374
- const pid = projectIdRef.current;
375
460
  const imported = uploaded
376
461
  .filter((asset) => FONT_EXT.test(asset))
377
462
  .map((asset) => ({
378
463
  family: fontFamilyFromAssetPath(asset),
379
464
  path: asset,
380
- url: `/api/projects/${pid}/preview/${asset}`,
465
+ url: `/api/projects/${encodeURIComponent(pid)}/preview/${asset}`,
381
466
  }));
382
467
  importedFontAssetsRef.current = [
383
468
  ...imported,
@@ -412,6 +497,7 @@ export function useFileManager({
412
497
  readProjectFile,
413
498
  writeProjectFile,
414
499
  readOptionalProjectFile,
500
+ observeProjectFileVersion,
415
501
  updateEditingFileContent,
416
502
 
417
503
  // Click-to-source
@@ -7,6 +7,7 @@ import {
7
7
  sdkGsapDeleteAllForSelectorPersist,
8
8
  sdkAddWithKeyframesPersist,
9
9
  sdkReplaceWithKeyframesPersist,
10
+ cutoverCommittedOrThrow,
10
11
  type CutoverDeps,
11
12
  } from "../utils/sdkCutover";
12
13
  import {
@@ -52,7 +53,7 @@ export function useGsapAnimationOps({
52
53
  sdkDeps,
53
54
  { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
54
55
  );
55
- if (handled) return;
56
+ if (cutoverCommittedOrThrow(handled)) return;
56
57
  }
57
58
  commitMutationSafely(
58
59
  selection,
@@ -74,7 +75,7 @@ export function useGsapAnimationOps({
74
75
  sdkDeps,
75
76
  { label: "Delete GSAP animation" },
76
77
  );
77
- if (handled) return;
78
+ if (cutoverCommittedOrThrow(handled)) return;
78
79
  }
79
80
  commitMutationSafely(
80
81
  selection,
@@ -96,7 +97,7 @@ export function useGsapAnimationOps({
96
97
  sdkDeps,
97
98
  { label: "Delete all animations for element" },
98
99
  );
99
- if (handled) return;
100
+ if (cutoverCommittedOrThrow(handled)) return;
100
101
  }
101
102
  void commitMutation(
102
103
  selection,
@@ -162,7 +163,7 @@ export function useGsapAnimationOps({
162
163
  sdkDeps,
163
164
  { label: `Add GSAP ${method} animation` },
164
165
  );
165
- if (handled) return;
166
+ if (cutoverCommittedOrThrow(handled)) return;
166
167
  }
167
168
 
168
169
  await commitMutation(
@@ -213,7 +214,7 @@ export function useGsapAnimationOps({
213
214
  sdkDeps,
214
215
  { label },
215
216
  );
216
- if (handled) return;
217
+ if (cutoverCommittedOrThrow(handled)) return;
217
218
  }
218
219
  void commitMutation(
219
220
  selection,
@@ -256,7 +257,7 @@ export function useGsapAnimationOps({
256
257
  sdkDeps,
257
258
  { label },
258
259
  );
259
- if (handled) return;
260
+ if (cutoverCommittedOrThrow(handled)) return;
260
261
  }
261
262
  void commitMutation(
262
263
  selection,
@@ -8,6 +8,7 @@ import {
8
8
  sdkGsapRemoveKeyframePersist,
9
9
  sdkGsapRemoveAllKeyframesPersist,
10
10
  sdkGsapConvertToKeyframesPersist,
11
+ cutoverCommittedOrThrow,
11
12
  type CutoverDeps,
12
13
  } from "../utils/sdkCutover";
13
14
  import type { KeyframeCacheEntry } from "../player/store/playerStore";
@@ -136,7 +137,7 @@ export function useGsapKeyframeOps({
136
137
  coalesceKey: `gsap:${animationId}:kf:${percentage}`,
137
138
  },
138
139
  );
139
- if (handled) return;
140
+ if (cutoverCommittedOrThrow(handled)) return;
140
141
  }
141
142
  await commitMutation(selection, mutation, {
142
143
  label: `Add keyframe at ${percentage}%`,
@@ -169,7 +170,7 @@ export function useGsapKeyframeOps({
169
170
  sdkDeps,
170
171
  toSdkPersistOptions(`Add keyframe at ${percentage}%`, commitOverrides),
171
172
  );
172
- if (handled) return;
173
+ if (cutoverCommittedOrThrow(handled)) return;
173
174
  }
174
175
  return commitMutation(
175
176
  selection,
@@ -218,7 +219,7 @@ export function useGsapKeyframeOps({
218
219
  sdkDeps,
219
220
  toSdkPersistOptions(label, commitOverrides),
220
221
  );
221
- if (handled) return;
222
+ if (cutoverCommittedOrThrow(handled)) return;
222
223
  }
223
224
  const commitOptions = commitOverrides?.skipReload
224
225
  ? { label, ...commitOverrides }
@@ -300,7 +301,7 @@ export function useGsapKeyframeOps({
300
301
  sdkDeps,
301
302
  toSdkPersistOptions("Convert to keyframes", commitOverrides),
302
303
  );
303
- if (handled) return;
304
+ if (cutoverCommittedOrThrow(handled)) return;
304
305
  }
305
306
  return commitMutation(
306
307
  selection,
@@ -329,7 +330,7 @@ export function useGsapKeyframeOps({
329
330
  sdkDeps,
330
331
  { label: "Remove all keyframes" },
331
332
  );
332
- if (handled) return;
333
+ if (cutoverCommittedOrThrow(handled)) return;
333
334
  }
334
335
  commitMutationSafely(
335
336
  selection,
@@ -5,6 +5,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
5
  import {
6
6
  sdkGsapTweenPersist,
7
7
  sdkGsapRemovePropertyPersist,
8
+ cutoverCommittedOrThrow,
8
9
  type CutoverDeps,
9
10
  } from "../utils/sdkCutover";
10
11
  import { extractGsapScriptText } from "../utils/gsapSoftReload";
@@ -45,6 +46,12 @@ interface SdkPropertyDeps {
45
46
  sdkSession?: Composition | null;
46
47
  sdkDeps?: CutoverDeps | null;
47
48
  activeCompPath?: string | null;
49
+ onFlushError?: (
50
+ error: unknown,
51
+ selection: DomEditSelection,
52
+ mutation: Record<string, unknown>,
53
+ label: string,
54
+ ) => void;
48
55
  }
49
56
 
50
57
  export function useGsapPropertyDebounce(
@@ -68,38 +75,46 @@ export function useGsapPropertyDebounce(
68
75
  const sdkRef = useRef(sdk);
69
76
  sdkRef.current = sdk;
70
77
 
78
+ // fallow-ignore-next-line complexity
71
79
  const flushPendingPropertyEdit = useCallback(async () => {
72
80
  const pending = pendingPropertyEditRef.current;
73
81
  if (!pending) return;
74
82
  pendingPropertyEditRef.current = null;
75
83
  const { selection, animationId, property, value } = pending;
76
- const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
77
- if (sdkSession && sdkDeps) {
78
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
79
- const handled = await sdkGsapTweenPersist(
80
- targetPath,
81
- {
82
- kind: "set",
83
- animationId,
84
- properties: {
85
- properties: mergeTweenProperties(sdkSession, animationId, { [property]: value }, "to"),
84
+ const mutation = { type: "update-property", animationId, property, value };
85
+ const label = `Edit GSAP ${property}`;
86
+ try {
87
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
88
+ if (sdkSession && sdkDeps) {
89
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
90
+ const handled = await sdkGsapTweenPersist(
91
+ targetPath,
92
+ {
93
+ kind: "set",
94
+ animationId,
95
+ properties: {
96
+ properties: mergeTweenProperties(
97
+ sdkSession,
98
+ animationId,
99
+ { [property]: value },
100
+ "to",
101
+ ),
102
+ },
86
103
  },
87
- },
88
- sdkSession,
89
- sdkDeps,
90
- { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` },
91
- );
92
- if (handled) return;
93
- }
94
- commitMutationSafely(
95
- selection,
96
- { type: "update-property", animationId, property, value },
97
- {
98
- label: `Edit GSAP ${property}`,
104
+ sdkSession,
105
+ sdkDeps,
106
+ { label, coalesceKey: `gsap:${animationId}:${property}` },
107
+ );
108
+ if (cutoverCommittedOrThrow(handled)) return;
109
+ }
110
+ await commitMutationSafely(selection, mutation, {
111
+ label,
99
112
  coalesceKey: `gsap:${animationId}:${property}`,
100
113
  softReload: true,
101
- },
102
- );
114
+ });
115
+ } catch (error) {
116
+ sdkRef.current?.onFlushError?.(error, selection, mutation, label);
117
+ }
103
118
  }, [commitMutationSafely]);
104
119
 
105
120
  const updateGsapProperty = useCallback(
@@ -162,7 +177,7 @@ export function useGsapPropertyDebounce(
162
177
  sdkDeps,
163
178
  { label: `Add GSAP ${property}` },
164
179
  );
165
- if (handled) return;
180
+ if (cutoverCommittedOrThrow(handled)) return;
166
181
  }
167
182
  commitMutationSafely(
168
183
  selection,
@@ -187,7 +202,7 @@ export function useGsapPropertyDebounce(
187
202
  sdkDeps,
188
203
  { label: `Remove GSAP ${from ? `from-${property}` : property}` },
189
204
  );
190
- if (handled) return;
205
+ if (cutoverCommittedOrThrow(handled)) return;
191
206
  }
192
207
  if (from) {
193
208
  commitMutationSafely(
@@ -247,7 +262,7 @@ export function useGsapPropertyDebounce(
247
262
  coalesceKey: `gsap:${animationId}:from:${property}`,
248
263
  },
249
264
  );
250
- if (handled) return;
265
+ if (cutoverCommittedOrThrow(handled)) return;
251
266
  }
252
267
  commitMutationSafely(
253
268
  selection,
@@ -285,7 +300,7 @@ export function useGsapPropertyDebounce(
285
300
  sdkDeps,
286
301
  { label: `Add GSAP from-${property}` },
287
302
  );
288
- if (handled) return;
303
+ if (cutoverCommittedOrThrow(handled)) return;
289
304
  }
290
305
  commitMutationSafely(
291
306
  selection,
@@ -82,4 +82,39 @@ describe("useGsapPropertyDebounce flush stability (finding #7)", () => {
82
82
  root.unmount();
83
83
  });
84
84
  });
85
+
86
+ it("reports a rejected debounced flush instead of leaking an unhandled rejection", async () => {
87
+ const error = new Error("save failed");
88
+ const commitMutationSafely = vi.fn().mockRejectedValue(error);
89
+ const onFlushError = vi.fn();
90
+ let queueEdit: (() => void) | null = null;
91
+
92
+ function Harness() {
93
+ const ops = useGsapPropertyDebounce(commitMutationSafely, {
94
+ sdkSession: null,
95
+ sdkDeps: null,
96
+ activeCompPath: "index.html",
97
+ onFlushError,
98
+ });
99
+ queueEdit = () => ops.updateGsapProperty(selection, "tw-1", "x", 42);
100
+ return null;
101
+ }
102
+
103
+ const root = createRoot(container);
104
+ await act(async () => {
105
+ root.render(React.createElement(Harness));
106
+ });
107
+ act(() => queueEdit?.());
108
+ await act(async () => {
109
+ await vi.advanceTimersByTimeAsync(200);
110
+ });
111
+
112
+ expect(onFlushError).toHaveBeenCalledWith(
113
+ error,
114
+ selection,
115
+ { type: "update-property", animationId: "tw-1", property: "x", value: 42 },
116
+ "Edit GSAP x",
117
+ );
118
+ act(() => root.unmount());
119
+ });
85
120
  });