@hyperframes/studio 0.7.58 → 0.7.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -145,15 +145,19 @@ export function useDomEditTextCommits({
145
145
  const domTextCommitVersionRef = useRef(0);
146
146
  const domStyleCommitVersionRef = useRef(new Map<string, number>());
147
147
 
148
- const { handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit } =
149
- useDomEditAttributeCommits({
150
- activeCompPath,
151
- previewIframeRef,
152
- showToast,
153
- domEditSelection,
154
- refreshDomEditSelectionFromPreview,
155
- persistDomEditOperations,
156
- });
148
+ const {
149
+ handleDomAttributeCommit,
150
+ handleDomAttributeLiveCommit,
151
+ handleDomHtmlAttributeCommit,
152
+ handleDomAttributesCommit,
153
+ } = useDomEditAttributeCommits({
154
+ activeCompPath,
155
+ previewIframeRef,
156
+ showToast,
157
+ domEditSelection,
158
+ refreshDomEditSelectionFromPreview,
159
+ persistDomEditOperations,
160
+ });
157
161
 
158
162
  const handleDomStyleCommit = useCallback(
159
163
  async (property: string, value: string) => {
@@ -471,6 +475,7 @@ export function useDomEditTextCommits({
471
475
  handleDomAttributeCommit,
472
476
  handleDomAttributeLiveCommit,
473
477
  handleDomHtmlAttributeCommit,
478
+ handleDomAttributesCommit,
474
479
  handleDomTextCommit,
475
480
  commitDomTextFields,
476
481
  handleDomTextFieldStyleCommit,
@@ -55,24 +55,24 @@ export interface UseDomEditWiringParams {
55
55
  sel: DomEditSelection,
56
56
  animId: string,
57
57
  updates: { duration?: number; ease?: string; position?: number },
58
- ) => void;
59
- deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
60
- deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => void;
58
+ ) => Promise<void>;
59
+ deleteGsapAnimation: (sel: DomEditSelection, animId: string) => Promise<void>;
60
+ deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => Promise<void>;
61
61
  addGsapAnimation: (
62
62
  sel: DomEditSelection,
63
63
  method: "to" | "from" | "set" | "fromTo",
64
64
  time: number,
65
65
  ) => Promise<void>;
66
- addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
67
- removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
66
+ addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
67
+ removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
68
68
  updateGsapFromProperty: (
69
69
  sel: DomEditSelection,
70
70
  animId: string,
71
71
  prop: string,
72
72
  value: number | string,
73
- ) => void;
74
- addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
75
- removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
73
+ ) => Promise<void>;
74
+ addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
75
+ removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
76
76
  addKeyframe: (
77
77
  sel: DomEditSelection,
78
78
  animId: string,
@@ -105,7 +105,7 @@ export interface UseDomEditWiringParams {
105
105
  animId: string,
106
106
  resolvedFromValues?: Record<string, number | string>,
107
107
  ) => Promise<void>;
108
- removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
108
+ removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
109
109
  handleDomManualEditsReset: (sel: DomEditSelection) => void;
110
110
  }
111
111
 
@@ -245,6 +245,7 @@ export function useDomEditWiring({
245
245
  removeAllKeyframes,
246
246
  handleDomManualEditsReset,
247
247
  selectedGsapAnimations,
248
+ showToast,
248
249
  });
249
250
 
250
251
  // ── Preview sync side-effects ──
@@ -20,10 +20,15 @@ import {
20
20
  type LayerRevealCommitOwnership,
21
21
  } from "../components/editor/useLayerRevealOverride";
22
22
  import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
23
+ import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
23
24
 
24
25
  interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
25
- /** Route delete through SDK when session resolves the hf-id; returns true if handled. */
26
- onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
26
+ /** Route delete through SDK when session resolves the hf-id. */
27
+ onTrySdkDelete?: (
28
+ hfId: string,
29
+ originalContent: string,
30
+ targetPath: string,
31
+ ) => Promise<CutoverResult>;
27
32
  /** Resolver-shadow tripwire for the reordered targets (telemetry-only, decoupled from cutover). */
28
33
  onReorderShadow?: (targets: string[]) => void;
29
34
  /** Resync the SDK session after a server-fallback delete. */
@@ -97,7 +102,7 @@ export function useElementLifecycleOps({
97
102
 
98
103
  if (onTrySdkDelete && selection.hfId) {
99
104
  const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath);
100
- if (handled) {
105
+ if (cutoverCommittedOrThrow(handled)) {
101
106
  clearDomSelection();
102
107
  usePlayerStore.getState().setSelectedElementId(null);
103
108
  showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
@@ -0,0 +1,98 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ vi.mock("./useFileTree", () => ({
7
+ useFileTree: () => ({
8
+ projectDir: "",
9
+ fileTree: [],
10
+ setFileTree: vi.fn(),
11
+ fileTreeLoaded: true,
12
+ refreshFileTree: vi.fn(async () => {}),
13
+ compositions: [],
14
+ assets: [],
15
+ fontAssets: [],
16
+ }),
17
+ }));
18
+
19
+ vi.mock("./useEditorSave", () => ({
20
+ useEditorSave: () => ({
21
+ saveRafRef: { current: null },
22
+ handleContentChange: vi.fn(),
23
+ }),
24
+ }));
25
+
26
+ import { useFileManager } from "./useFileManager";
27
+
28
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
29
+
30
+ describe("useFileManager project ownership", () => {
31
+ afterEach(() => {
32
+ vi.unstubAllGlobals();
33
+ });
34
+
35
+ it("keeps delayed callbacks bound to the project that created them", async () => {
36
+ let resolveProjectARead: ((value: Response) => void) | undefined;
37
+ const projectARead = new Promise<Response>((resolve) => {
38
+ resolveProjectARead = resolve;
39
+ });
40
+ const fetchMock = vi.fn((url: string, init?: RequestInit) => {
41
+ if (url.includes("project-a") && !init?.method) return projectARead;
42
+ if (!init?.method) {
43
+ return Promise.resolve({
44
+ ok: true,
45
+ json: async () => ({ content: "PROJECT_B" }),
46
+ } as Response);
47
+ }
48
+ return Promise.resolve({ ok: true } as Response);
49
+ });
50
+ vi.stubGlobal("fetch", fetchMock);
51
+
52
+ const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
53
+ function Probe({ projectId }: { projectId: string }) {
54
+ captured.manager = useFileManager({
55
+ projectId,
56
+ showToast: vi.fn(),
57
+ recordEdit: vi.fn(async () => {}),
58
+ domEditSaveTimestampRef: { current: 0 },
59
+ setRefreshKey: vi.fn(),
60
+ });
61
+ return null;
62
+ }
63
+
64
+ const root = createRoot(document.createElement("div"));
65
+ await act(async () => root.render(<Probe projectId="project-a/../other?x=1" />));
66
+ const managerA = captured.manager;
67
+ if (!managerA) throw new Error("project A manager did not render");
68
+ const delayedRead = managerA.readProjectFile("index.html");
69
+
70
+ await act(async () => root.render(<Probe projectId="project-b#fragment" />));
71
+ const managerB = captured.manager;
72
+ if (!managerB) throw new Error("project B manager did not render");
73
+ expect(managerB.writeProjectFile).not.toBe(managerA.writeProjectFile);
74
+
75
+ resolveProjectARead?.({
76
+ ok: true,
77
+ json: async () => ({ content: "PROJECT_A" }),
78
+ } as Response);
79
+ await expect(delayedRead).resolves.toBe("PROJECT_A");
80
+ await managerA.writeProjectFile("index.html", "A_AFTER");
81
+ await expect(managerB.readProjectFile("index.html")).resolves.toBe("PROJECT_B");
82
+ await expect(managerB.readOptionalProjectFile("index.html")).resolves.toBe("PROJECT_B");
83
+
84
+ expect(fetchMock).toHaveBeenCalledWith(
85
+ "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
86
+ );
87
+ expect(fetchMock).toHaveBeenCalledWith(
88
+ "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
89
+ expect.objectContaining({ method: "PUT", body: "A_AFTER" }),
90
+ );
91
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html");
92
+ expect(fetchMock).toHaveBeenCalledWith(
93
+ "/api/projects/project-b%23fragment/files/index.html?optional=1",
94
+ );
95
+
96
+ await act(async () => root.unmount());
97
+ });
98
+ });
@@ -66,38 +66,48 @@ export function useFileManager({
66
66
 
67
67
  // ── Core file I/O ──
68
68
 
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
- }, []);
69
+ const readProjectFile = useCallback(
70
+ async (path: string): Promise<string> => {
71
+ if (!projectId) throw new Error("No active project");
72
+ const response = await fetch(
73
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
74
+ );
75
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
76
+ const data = (await response.json()) as { content?: string };
77
+ if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
78
+ return data.content;
79
+ },
80
+ [projectId],
81
+ );
78
82
 
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
- });
83
+ const writeProjectFile = useCallback(
84
+ async (path: string, content: string): Promise<void> => {
85
+ if (!projectId) throw new Error("No active project");
86
+ const writeProjectId = projectId;
87
+ await retryStudioSave(async () => {
88
+ let response: Response;
89
+ try {
90
+ response = await fetch(
91
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
92
+ {
93
+ method: "PUT",
94
+ headers: { "Content-Type": "text/plain" },
95
+ body: content,
96
+ },
97
+ );
98
+ } catch (error) {
99
+ throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
100
+ cause: error,
101
+ });
102
+ }
103
+ if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
104
+ });
105
+ if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
106
+ setEditingFile({ path, content });
94
107
  }
95
- if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
96
- });
97
- if (editingPathRef.current === path) {
98
- setEditingFile({ path, content });
99
- }
100
- }, []);
108
+ },
109
+ [projectId],
110
+ );
101
111
 
102
112
  const updateEditingFileContent = useCallback((path: string, content: string) => {
103
113
  if (editingPathRef.current === path) {
@@ -105,16 +115,18 @@ export function useFileManager({
105
115
  }
106
116
  }, []);
107
117
 
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
- }, []);
118
+ const readOptionalProjectFile = useCallback(
119
+ async (path: string): Promise<string> => {
120
+ if (!projectId) throw new Error("No active project");
121
+ const response = await fetch(
122
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
123
+ );
124
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
125
+ const data = (await response.json()) as { content?: string };
126
+ return typeof data.content === "string" ? data.content : "";
127
+ },
128
+ [projectId],
129
+ );
118
130
 
119
131
  // ── Editor save (debounced content change) ──
120
132
 
@@ -146,7 +158,7 @@ export function useFileManager({
146
158
  setEditingFile({ path, content: null });
147
159
  return;
148
160
  }
149
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
161
+ fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
150
162
  .then((r) => {
151
163
  if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
152
164
  return r.json();
@@ -179,7 +191,7 @@ export function useFileManager({
179
191
  const requestId = ++revealRequestIdRef.current;
180
192
  const controller = new AbortController();
181
193
  revealAbortRef.current = controller;
182
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(sourceFile)}`, {
194
+ fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`, {
183
195
  signal: controller.signal,
184
196
  })
185
197
  .then((r) => r.json())
@@ -211,7 +223,7 @@ export function useFileManager({
211
223
 
212
224
  const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
213
225
  try {
214
- const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
226
+ const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
215
227
  method: "POST",
216
228
  body: formData,
217
229
  });
@@ -251,11 +263,14 @@ export function useFileManager({
251
263
  content =
252
264
  '<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
253
265
  }
254
- const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
255
- method: "POST",
256
- headers: { "Content-Type": "text/plain" },
257
- body: content,
258
- });
266
+ const res = await fetch(
267
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
268
+ {
269
+ method: "POST",
270
+ headers: { "Content-Type": "text/plain" },
271
+ body: content,
272
+ },
273
+ );
259
274
  if (res.ok) {
260
275
  await refreshFileTree();
261
276
  handleFileSelect(path);
@@ -273,7 +288,7 @@ export function useFileManager({
273
288
  const pid = projectIdRef.current;
274
289
  if (!pid) return;
275
290
  const res = await fetch(
276
- `/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`,
291
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path + "/.gitkeep")}`,
277
292
  {
278
293
  method: "POST",
279
294
  headers: { "Content-Type": "text/plain" },
@@ -295,9 +310,12 @@ export function useFileManager({
295
310
  async (path: string) => {
296
311
  const pid = projectIdRef.current;
297
312
  if (!pid) return;
298
- const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
299
- method: "DELETE",
300
- });
313
+ const res = await fetch(
314
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
315
+ {
316
+ method: "DELETE",
317
+ },
318
+ );
301
319
  if (res.ok) {
302
320
  if (editingPathRef.current === path) setEditingFile(null);
303
321
  await refreshFileTree();
@@ -314,11 +332,14 @@ export function useFileManager({
314
332
  async (oldPath: string, newPath: string) => {
315
333
  const pid = projectIdRef.current;
316
334
  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
- });
335
+ const res = await fetch(
336
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(oldPath)}`,
337
+ {
338
+ method: "PATCH",
339
+ headers: { "Content-Type": "application/json" },
340
+ body: JSON.stringify({ newPath }),
341
+ },
342
+ );
322
343
  if (res.ok) {
323
344
  if (editingPathRef.current === oldPath) {
324
345
  handleFileSelect(newPath);
@@ -338,7 +359,7 @@ export function useFileManager({
338
359
  async (path: string) => {
339
360
  const pid = projectIdRef.current;
340
361
  if (!pid) return;
341
- const res = await fetch(`/api/projects/${pid}/duplicate-file`, {
362
+ const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
342
363
  method: "POST",
343
364
  headers: { "Content-Type": "application/json" },
344
365
  body: JSON.stringify({ path }),
@@ -367,17 +388,18 @@ export function useFileManager({
367
388
 
368
389
  const handleImportFonts = useCallback(
369
390
  async (files: FileList | File[]): Promise<ImportedFontAsset[]> => {
391
+ const pid = projectIdRef.current;
392
+ if (!pid) return [];
370
393
  const uploaded = await uploadProjectFiles(
371
394
  Array.from(files).filter((file) => FONT_EXT.test(file.name)),
372
395
  "assets/fonts",
373
396
  );
374
- const pid = projectIdRef.current;
375
397
  const imported = uploaded
376
398
  .filter((asset) => FONT_EXT.test(asset))
377
399
  .map((asset) => ({
378
400
  family: fontFamilyFromAssetPath(asset),
379
401
  path: asset,
380
- url: `/api/projects/${pid}/preview/${asset}`,
402
+ url: `/api/projects/${encodeURIComponent(pid)}/preview/${asset}`,
381
403
  }));
382
404
  importedFontAssetsRef.current = [
383
405
  ...imported,
@@ -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,