@hyperframes/studio 0.7.59 → 0.7.61

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 (78) hide show
  1. package/dist/assets/hyperframes-player-Xvx2hkrc.js +459 -0
  2. package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
  3. package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
  4. package/dist/assets/index-BhWig0mx.css +1 -0
  5. package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1441 -770
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +4 -9
  11. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  12. package/src/components/StudioHeader.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +47 -28
  14. package/src/components/editor/PromotableControl.tsx +4 -2
  15. package/src/components/editor/PropertyPanel.tsx +151 -149
  16. package/src/components/editor/PropertyPanelFlat.tsx +44 -42
  17. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  18. package/src/components/editor/manualEditingAvailability.ts +5 -3
  19. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
  20. package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
  21. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
  22. package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
  23. package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
  24. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  25. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
  26. package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
  27. package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
  28. package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
  29. package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
  30. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  31. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  32. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  33. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  34. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  35. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  36. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  37. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  38. package/src/components/storyboard/useFrameComments.ts +22 -6
  39. package/src/contexts/FileManagerContext.tsx +3 -0
  40. package/src/contexts/PanelLayoutContext.tsx +3 -0
  41. package/src/contexts/ViewModeContext.tsx +60 -7
  42. package/src/hooks/timelineEditingHelpers.ts +2 -2
  43. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  44. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  45. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  46. package/src/hooks/useDomEditCommits.ts +2 -2
  47. package/src/hooks/useDomEditSession.ts +1 -1
  48. package/src/hooks/useEditorSave.ts +1 -1
  49. package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
  50. package/src/hooks/useFileManager.ts +76 -12
  51. package/src/hooks/usePanelLayout.test.ts +118 -0
  52. package/src/hooks/usePanelLayout.ts +24 -1
  53. package/src/hooks/usePreviewPersistence.ts +4 -1
  54. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  55. package/src/hooks/useRazorSplit.test.tsx +88 -0
  56. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  57. package/src/hooks/useRazorSplit.ts +50 -16
  58. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  59. package/src/hooks/useSlideshowTabState.ts +61 -0
  60. package/src/hooks/useTimelineEditing.ts +2 -0
  61. package/src/hooks/useTimelineEditingTypes.ts +2 -1
  62. package/src/hooks/useTimelineGroupEditing.ts +1 -1
  63. package/src/player/lib/playbackTypes.ts +7 -0
  64. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  65. package/src/player/lib/timelineDOM.ts +8 -11
  66. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  67. package/src/utils/domEditSaveQueue.test.ts +19 -0
  68. package/src/utils/domEditSaveQueue.ts +2 -1
  69. package/src/utils/sdkCutover.test.ts +21 -5
  70. package/src/utils/sdkEditTransaction.ts +5 -4
  71. package/src/utils/studioFileHistory.ts +6 -4
  72. package/src/utils/studioFileVersion.test.ts +36 -0
  73. package/src/utils/studioFileVersion.ts +23 -0
  74. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  75. package/src/utils/studioSaveDiagnostics.ts +21 -0
  76. package/dist/assets/hyperframes-player-Bm07FLkl.js +0 -459
  77. package/dist/assets/index-Dj5p8U_A.css +0 -1
  78. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deriveStoryboardHandoffStep, deriveStoryboardReviewStage } from "./storyboardReviewStage";
3
+
4
+ describe("deriveStoryboardReviewStage", () => {
5
+ it.each([
6
+ [[], "empty"],
7
+ [["outline", "outline"], "plan-review"],
8
+ [["outline", "built"], "sketch-in-progress"],
9
+ [["outline", "animated"], "sketch-in-progress"],
10
+ [["built", "built"], "sketch-review"],
11
+ [["built", "animated"], "animation-in-progress"],
12
+ [["animated", "animated"], "final-review"],
13
+ ] as const)("maps %j to %s", (statuses, expected) => {
14
+ expect(deriveStoryboardReviewStage(statuses.map((status) => ({ status }))).stage).toBe(
15
+ expected,
16
+ );
17
+ });
18
+
19
+ it("returns counts for the visible progress summary", () => {
20
+ expect(
21
+ deriveStoryboardReviewStage([
22
+ { status: "outline" },
23
+ { status: "built" },
24
+ { status: "animated" },
25
+ { status: "animated" },
26
+ ]).counts,
27
+ ).toEqual({ outline: 1, built: 1, animated: 2 });
28
+ });
29
+ });
30
+
31
+ describe("storyboard review handoff", () => {
32
+ it.each([
33
+ [0, 0, 1],
34
+ [2, 0, 2],
35
+ [0, 3, 3],
36
+ [1, 3, 2],
37
+ ] as const)("maps %i drafts and %i pending comments to step %i", (drafts, pending, step) => {
38
+ expect(deriveStoryboardHandoffStep(drafts, pending)).toBe(step);
39
+ });
40
+ });
@@ -0,0 +1,45 @@
1
+ import type { FrameStatus } from "@hyperframes/core/storyboard";
2
+
3
+ export type StoryboardReviewStage =
4
+ | "empty"
5
+ | "plan-review"
6
+ | "sketch-in-progress"
7
+ | "sketch-review"
8
+ | "animation-in-progress"
9
+ | "final-review";
10
+
11
+ export interface StoryboardReviewSummary {
12
+ stage: StoryboardReviewStage;
13
+ counts: Record<FrameStatus, number>;
14
+ frameCount: number;
15
+ }
16
+
17
+ export type StoryboardHandoffStep = 1 | 2 | 3;
18
+
19
+ /** Current user action in the Studio → agent feedback handoff. */
20
+ export function deriveStoryboardHandoffStep(
21
+ draftCount: number,
22
+ pendingCount: number,
23
+ ): StoryboardHandoffStep {
24
+ if (draftCount > 0) return 2;
25
+ if (pendingCount > 0) return 3;
26
+ return 1;
27
+ }
28
+
29
+ /** Derive the board-level review moment from agent-owned frame statuses. */
30
+ export function deriveStoryboardReviewStage(
31
+ frames: ReadonlyArray<{ status: FrameStatus }>,
32
+ ): StoryboardReviewSummary {
33
+ const counts: Record<FrameStatus, number> = { outline: 0, built: 0, animated: 0 };
34
+ for (const frame of frames) counts[frame.status] += 1;
35
+
36
+ let stage: StoryboardReviewStage;
37
+ if (frames.length === 0) stage = "empty";
38
+ else if (counts.outline === frames.length) stage = "plan-review";
39
+ else if (counts.outline > 0) stage = "sketch-in-progress";
40
+ else if (counts.built === frames.length) stage = "sketch-review";
41
+ else if (counts.built > 0) stage = "animation-in-progress";
42
+ else stage = "final-review";
43
+
44
+ return { stage, counts, frameCount: frames.length };
45
+ }
@@ -17,8 +17,10 @@ export interface FrameCommentsValue {
17
17
  /** How many frames currently carry a non-empty draft. */
18
18
  draftCount: number;
19
19
  submitState: CommentsSubmitState;
20
+ /** Most recent submit failure, shown next to the submit action. */
21
+ submitError: string | null;
20
22
  /** Write the batch to `.hyperframes/frame-comments.json` and clear the drafts. */
21
- submit: () => Promise<void>;
23
+ submit: () => Promise<boolean>;
22
24
  /**
23
25
  * Comments already submitted but not yet consumed by the agent (the file
24
26
  * still exists on disk). Refreshed on mount, after submit, and on window
@@ -34,6 +36,7 @@ export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsVa
34
36
  const { writeProjectFile, readOptionalProjectFile } = useFileManagerContext();
35
37
  const [drafts, setDrafts] = useState<Record<number, string>>({});
36
38
  const [submitState, setSubmitState] = useState<CommentsSubmitState>("idle");
39
+ const [submitError, setSubmitError] = useState<string | null>(null);
37
40
  const [pending, setPending] = useState<FrameCommentEntry[] | null>(null);
38
41
 
39
42
  const refreshPending = useCallback(async () => {
@@ -61,22 +64,35 @@ export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsVa
61
64
  [drafts],
62
65
  );
63
66
 
67
+ // One guarded async transaction owns loading, success, failure, and cleanup state.
68
+ // fallow-ignore-next-line complexity
64
69
  const submit = useCallback(async () => {
65
- if (draftCount === 0 || submitState === "saving") return;
70
+ if (draftCount === 0 || submitState === "saving") return false;
66
71
  setSubmitState("saving");
72
+ setSubmitError(null);
67
73
  try {
68
74
  const previous = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
69
75
  const file = buildCommentsFile(frames, drafts, previous, new Date().toISOString());
70
76
  await writeProjectFile(FRAME_COMMENTS_PATH, `${JSON.stringify(file, null, 2)}\n`);
71
77
  setDrafts({});
72
78
  setPending(file.comments);
73
- } catch {
74
- // writeProjectFile surfaces save failures through the studio save banner;
75
- // just re-arm the button so the user can retry.
79
+ return true;
80
+ } catch (err: unknown) {
81
+ setSubmitError(err instanceof Error ? err.message : "Failed to submit comments");
82
+ return false;
76
83
  } finally {
77
84
  setSubmitState("idle");
78
85
  }
79
86
  }, [draftCount, submitState, frames, drafts, readOptionalProjectFile, writeProjectFile]);
80
87
 
81
- return { drafts, setDraft, draftCount, submitState, submit, pending, refreshPending };
88
+ return {
89
+ drafts,
90
+ setDraft,
91
+ draftCount,
92
+ submitState,
93
+ submitError,
94
+ submit,
95
+ pending,
96
+ refreshPending,
97
+ };
82
98
  }
@@ -30,6 +30,7 @@ export function FileManagerProvider({
30
30
  readProjectFile,
31
31
  writeProjectFile,
32
32
  readOptionalProjectFile,
33
+ observeProjectFileVersion,
33
34
  updateEditingFileContent,
34
35
  revealSourceOffset,
35
36
  openSourceForSelection,
@@ -69,6 +70,7 @@ export function FileManagerProvider({
69
70
  readProjectFile,
70
71
  writeProjectFile,
71
72
  readOptionalProjectFile,
73
+ observeProjectFileVersion,
72
74
  updateEditingFileContent,
73
75
  revealSourceOffset,
74
76
  openSourceForSelection,
@@ -102,6 +104,7 @@ export function FileManagerProvider({
102
104
  readProjectFile,
103
105
  writeProjectFile,
104
106
  readOptionalProjectFile,
107
+ observeProjectFileVersion,
105
108
  updateEditingFileContent,
106
109
  revealSourceOffset,
107
110
  openSourceForSelection,
@@ -25,6 +25,7 @@ export function PanelLayoutProvider({
25
25
  setRightPanelTab,
26
26
  rightInspectorPanes,
27
27
  toggleRightInspectorPane,
28
+ setExclusiveRightInspectorPane,
28
29
  toggleLeftSidebar,
29
30
  handlePanelResizeStart,
30
31
  handlePanelResizeMove,
@@ -49,6 +50,7 @@ export function PanelLayoutProvider({
49
50
  setRightPanelTab,
50
51
  rightInspectorPanes,
51
52
  toggleRightInspectorPane,
53
+ setExclusiveRightInspectorPane,
52
54
  toggleLeftSidebar,
53
55
  handlePanelResizeStart,
54
56
  handlePanelResizeMove,
@@ -67,6 +69,7 @@ export function PanelLayoutProvider({
67
69
  setRightPanelTab,
68
70
  rightInspectorPanes,
69
71
  toggleRightInspectorPane,
72
+ setExclusiveRightInspectorPane,
70
73
  toggleLeftSidebar,
71
74
  handlePanelResizeStart,
72
75
  handlePanelResizeMove,
@@ -4,6 +4,7 @@ import {
4
4
  useContext,
5
5
  useEffect,
6
6
  useMemo,
7
+ useRef,
7
8
  useState,
8
9
  type ReactNode,
9
10
  } from "react";
@@ -17,6 +18,7 @@ import {
17
18
  * user straight into the storyboard by navigating the tab to `?view=storyboard`.
18
19
  */
19
20
  export type StudioViewMode = "timeline" | "storyboard";
21
+ export type ViewModeGuard = (nextMode: StudioViewMode) => boolean;
20
22
 
21
23
  const VIEW_QUERY_PARAM = "view";
22
24
 
@@ -38,9 +40,24 @@ function writeViewModeToUrl(mode: StudioViewMode): void {
38
40
  window.history.replaceState(window.history.state, "", url);
39
41
  }
40
42
 
43
+ interface HistoryLocation {
44
+ href: string;
45
+ state: unknown;
46
+ }
47
+
48
+ function readHistoryLocation(): HistoryLocation | null {
49
+ if (typeof window === "undefined") return null;
50
+ return {
51
+ href: window.location.href,
52
+ state: window.history.state,
53
+ };
54
+ }
55
+
41
56
  export interface ViewModeValue {
42
57
  viewMode: StudioViewMode;
43
- setViewMode: (mode: StudioViewMode) => void;
58
+ /** Returns false when an active editor vetoes the transition. */
59
+ setViewMode: (mode: StudioViewMode) => boolean;
60
+ registerViewModeGuard: (guard: ViewModeGuard) => () => void;
44
61
  }
45
62
 
46
63
  /**
@@ -49,6 +66,15 @@ export interface ViewModeValue {
49
66
  */
50
67
  export function useViewModeState(): ViewModeValue {
51
68
  const [viewMode, setMode] = useState<StudioViewMode>(() => readViewModeFromUrl());
69
+ const guardsRef = useRef(new Set<ViewModeGuard>());
70
+ const acceptedLocationRef = useRef<HistoryLocation | null>(readHistoryLocation());
71
+
72
+ const canSetViewMode = useCallback((mode: StudioViewMode) => {
73
+ for (const guard of guardsRef.current) {
74
+ if (!guard(mode)) return false;
75
+ }
76
+ return true;
77
+ }, []);
52
78
 
53
79
  // Reflect genuine browser back/forward between history entries with a different
54
80
  // `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
@@ -57,17 +83,44 @@ export function useViewModeState(): ViewModeValue {
57
83
  // the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
58
84
  // reflected here, by design.
59
85
  useEffect(() => {
60
- const onPopState = () => setMode(readViewModeFromUrl());
86
+ const onPopState = () => {
87
+ const mode = readViewModeFromUrl();
88
+ if (mode !== viewMode && !canSetViewMode(mode)) {
89
+ const acceptedLocation = acceptedLocationRef.current;
90
+ if (acceptedLocation) {
91
+ window.history.pushState(acceptedLocation.state, "", acceptedLocation.href);
92
+ }
93
+ return;
94
+ }
95
+ setMode(mode);
96
+ acceptedLocationRef.current = readHistoryLocation();
97
+ };
61
98
  window.addEventListener("popstate", onPopState);
62
99
  return () => window.removeEventListener("popstate", onPopState);
63
- }, []);
100
+ }, [canSetViewMode, viewMode]);
101
+
102
+ const setViewMode = useCallback(
103
+ (mode: StudioViewMode) => {
104
+ if (!canSetViewMode(mode)) return false;
105
+ setMode(mode);
106
+ writeViewModeToUrl(mode);
107
+ acceptedLocationRef.current = readHistoryLocation();
108
+ return true;
109
+ },
110
+ [canSetViewMode],
111
+ );
64
112
 
65
- const setViewMode = useCallback((mode: StudioViewMode) => {
66
- setMode(mode);
67
- writeViewModeToUrl(mode);
113
+ const registerViewModeGuard = useCallback((guard: ViewModeGuard) => {
114
+ guardsRef.current.add(guard);
115
+ return () => {
116
+ guardsRef.current.delete(guard);
117
+ };
68
118
  }, []);
69
119
 
70
- return useMemo(() => ({ viewMode, setViewMode }), [viewMode, setViewMode]);
120
+ return useMemo(
121
+ () => ({ viewMode, setViewMode, registerViewModeGuard }),
122
+ [viewMode, setViewMode, registerViewModeGuard],
123
+ );
71
124
  }
72
125
 
73
126
  const ViewModeContext = createContext<ViewModeValue | null>(null);
@@ -262,7 +262,7 @@ export interface PersistTimelineEditInput {
262
262
  activeCompPath: string | null;
263
263
  label: string;
264
264
  buildPatches: (original: string, target: PatchTarget) => string;
265
- writeProjectFile: (path: string, content: string) => Promise<void>;
265
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
266
266
  recordEdit: (input: RecordEditInput) => Promise<void>;
267
267
  domEditSaveTimestampRef: React.MutableRefObject<number>;
268
268
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
@@ -308,7 +308,7 @@ export interface PersistTimelineBatchEditInput {
308
308
  activeCompPath: string | null;
309
309
  label: string;
310
310
  changes: PersistTimelineBatchChange[];
311
- writeProjectFile: (path: string, content: string) => Promise<void>;
311
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
312
312
  recordEdit: (input: RecordEditInput) => Promise<void>;
313
313
  domEditSaveTimestampRef: React.MutableRefObject<number>;
314
314
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
@@ -0,0 +1,44 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { usePlayerStore } from "../player";
7
+ import { useAddAssetAtPlayhead } from "./useAddAssetAtPlayhead";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ describe("useAddAssetAtPlayhead", () => {
16
+ it("drops the asset on track 0 at the current playhead time", () => {
17
+ usePlayerStore.getState().setCurrentTime(4.5);
18
+ const handleTimelineAssetDrop = vi.fn();
19
+ let addAsset: (assetPath: string) => unknown = () => undefined;
20
+
21
+ function Harness() {
22
+ addAsset = useAddAssetAtPlayhead(handleTimelineAssetDrop);
23
+ return null;
24
+ }
25
+
26
+ const host = document.createElement("div");
27
+ document.body.append(host);
28
+ const root = createRoot(host);
29
+ act(() => {
30
+ root.render(React.createElement(Harness));
31
+ });
32
+
33
+ act(() => {
34
+ addAsset("assets/clip.mp4");
35
+ });
36
+
37
+ expect(handleTimelineAssetDrop).toHaveBeenCalledWith("assets/clip.mp4", {
38
+ start: 4.5,
39
+ track: 0,
40
+ });
41
+
42
+ act(() => root.unmount());
43
+ });
44
+ });
@@ -0,0 +1,21 @@
1
+ import { useCallback } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import { usePlayerStore } from "../player";
4
+
5
+ /** Drops an asset onto track 0 at the current playhead time. */
6
+ export function useAddAssetAtPlayhead(
7
+ handleTimelineAssetDrop: (
8
+ assetPath: string,
9
+ placement: Pick<TimelineElement, "start" | "track">,
10
+ durationOverride?: number,
11
+ ) => unknown,
12
+ ) {
13
+ return useCallback(
14
+ (assetPath: string) =>
15
+ handleTimelineAssetDrop(assetPath, {
16
+ start: usePlayerStore.getState().currentTime,
17
+ track: 0,
18
+ }),
19
+ [handleTimelineAssetDrop],
20
+ );
21
+ }
@@ -34,7 +34,7 @@ interface RenderedDomEditCommits {
34
34
 
35
35
  interface RenderDomEditCommitsOptions {
36
36
  importedFontAssets?: ImportedFontAsset[];
37
- writeProjectFile?: (path: string, content: string) => Promise<void>;
37
+ writeProjectFile?: (path: string, content: string, expectedContent?: string) => Promise<void>;
38
38
  }
39
39
 
40
40
  type FetchHandler = (
@@ -1050,6 +1050,46 @@ describe("useDomEditCommits style persist handling", () => {
1050
1050
  }
1051
1051
  });
1052
1052
 
1053
+ it("uses the patched server content as the custom-font write precondition", async () => {
1054
+ const patchedContent =
1055
+ '<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>';
1056
+ stubPatchFetch(
1057
+ { ok: true, changed: true, matched: true, content: patchedContent },
1058
+ patchedContent,
1059
+ );
1060
+ const { iframe, element } = createPreviewElement();
1061
+ const selection = createSelection(element, {
1062
+ textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1063
+ });
1064
+ const writeProjectFile = vi.fn(async () => {});
1065
+ const rendered = renderDomEditCommits(selection, iframe, { writeProjectFile });
1066
+
1067
+ try {
1068
+ await act(async () => {
1069
+ await rendered.hook.commitDomTextFields(
1070
+ selection,
1071
+ [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1072
+ {
1073
+ importedFont: {
1074
+ family: "Imported",
1075
+ path: "fonts/Imported.woff2",
1076
+ url: "/api/projects/p1/preview/fonts/Imported.woff2",
1077
+ },
1078
+ },
1079
+ );
1080
+ });
1081
+
1082
+ expect(writeProjectFile).toHaveBeenCalledWith(
1083
+ "index.html",
1084
+ expect.stringContaining("@font-face"),
1085
+ patchedContent,
1086
+ );
1087
+ expect(rendered.showToast).not.toHaveBeenCalled();
1088
+ } finally {
1089
+ rendered.cleanup();
1090
+ }
1091
+ });
1092
+
1053
1093
  it("keeps a rejected patch request (HTTP error) to one toast", async () => {
1054
1094
  const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
1055
1095
  const url = requestUrl(input);
@@ -46,7 +46,7 @@ export interface UseDomEditCommitsParams {
46
46
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
47
47
  showToast: (message: string, tone?: "error" | "info") => void;
48
48
  queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
49
- writeProjectFile: (path: string, content: string) => Promise<void>;
49
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
50
50
  domEditSaveTimestampRef: React.MutableRefObject<number>;
51
51
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
52
52
  fileTree: string[];
@@ -245,7 +245,7 @@ export function useDomEditCommits({
245
245
  const preparedContent = options.prepareContent(patchedContent, targetPath);
246
246
  if (preparedContent !== patchedContent) {
247
247
  try {
248
- await writeProjectFile(targetPath, preparedContent);
248
+ await writeProjectFile(targetPath, preparedContent, patchedContent);
249
249
  finalContent = preparedContent;
250
250
  } catch (error) {
251
251
  // The patch above already landed on disk — only the prepareContent
@@ -45,7 +45,7 @@ export interface UseDomEditSessionParams {
45
45
  refreshPreviewDocumentVersion: () => void;
46
46
  queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
47
47
  readProjectFile: (path: string) => Promise<string>;
48
- writeProjectFile: (path: string, content: string) => Promise<void>;
48
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
49
49
  updateEditingFileContent: (path: string, content: string) => void;
50
50
  domEditSaveTimestampRef: React.MutableRefObject<number>;
51
51
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
@@ -14,7 +14,7 @@ interface UseEditorSaveOptions {
14
14
  editingPathRef: React.RefObject<string | undefined>;
15
15
  projectIdRef: React.RefObject<string | null>;
16
16
  readProjectFile: (path: string) => Promise<string>;
17
- writeProjectFile: (path: string, content: string) => Promise<void>;
17
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
18
18
  recordEdit: (input: RecordEditInput) => Promise<void>;
19
19
  domEditSaveTimestampRef: React.MutableRefObject<number>;
20
20
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
@@ -38,14 +38,17 @@ describe("useFileManager project ownership", () => {
38
38
  resolveProjectARead = resolve;
39
39
  });
40
40
  const fetchMock = vi.fn((url: string, init?: RequestInit) => {
41
+ if (url.endsWith("/files/missing.html") && !init?.method) {
42
+ return Promise.resolve({ ok: false, status: 404 } as Response);
43
+ }
41
44
  if (url.includes("project-a") && !init?.method) return projectARead;
42
45
  if (!init?.method) {
43
46
  return Promise.resolve({
44
47
  ok: true,
45
- json: async () => ({ content: "PROJECT_B" }),
48
+ json: async () => ({ content: "PROJECT_B", version: "b-v1" }),
46
49
  } as Response);
47
50
  }
48
- return Promise.resolve({ ok: true } as Response);
51
+ return Promise.resolve({ ok: true, json: async () => ({ version: "a-v2" }) } as Response);
49
52
  });
50
53
  vi.stubGlobal("fetch", fetchMock);
51
54
 
@@ -74,10 +77,11 @@ describe("useFileManager project ownership", () => {
74
77
 
75
78
  resolveProjectARead?.({
76
79
  ok: true,
77
- json: async () => ({ content: "PROJECT_A" }),
80
+ json: async () => ({ content: "PROJECT_A", version: "a-v1" }),
78
81
  } as Response);
79
82
  await expect(delayedRead).resolves.toBe("PROJECT_A");
80
83
  await managerA.writeProjectFile("index.html", "A_AFTER");
84
+ await managerA.writeProjectFile("missing.html", "A_NEW");
81
85
  await expect(managerB.readProjectFile("index.html")).resolves.toBe("PROJECT_B");
82
86
  await expect(managerB.readOptionalProjectFile("index.html")).resolves.toBe("PROJECT_B");
83
87
 
@@ -88,6 +92,13 @@ describe("useFileManager project ownership", () => {
88
92
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
89
93
  expect.objectContaining({ method: "PUT", body: "A_AFTER" }),
90
94
  );
95
+ expect(fetchMock).toHaveBeenCalledWith(
96
+ "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
97
+ );
98
+ expect(fetchMock).toHaveBeenCalledWith(
99
+ "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
100
+ expect.objectContaining({ method: "PUT", body: "A_NEW" }),
101
+ );
91
102
  expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html");
92
103
  expect(fetchMock).toHaveBeenCalledWith(
93
104
  "/api/projects/project-b%23fragment/files/index.html?optional=1",