@hyperframes/studio 0.7.60 → 0.7.62

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 (110) hide show
  1. package/dist/assets/hyperframes-player-CWpmBjzv.js +459 -0
  2. package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4392 -3314
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -15
  12. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  13. package/src/components/EditorShell.tsx +12 -0
  14. package/src/components/StudioHeader.tsx +2 -3
  15. package/src/components/StudioLeftSidebar.tsx +3 -0
  16. package/src/components/StudioRightPanel.tsx +34 -26
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  18. package/src/components/editor/PromotableControl.tsx +4 -2
  19. package/src/components/editor/PropertyPanel.tsx +151 -149
  20. package/src/components/editor/PropertyPanelFlat.tsx +50 -42
  21. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  22. package/src/components/editor/domEditing.test.ts +34 -0
  23. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  24. package/src/components/editor/manualEditingAvailability.ts +5 -3
  25. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  26. package/src/components/editor/propertyPanelColor.tsx +150 -28
  27. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
  29. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
  30. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  31. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
  32. package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
  33. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  34. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
  35. package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
  36. package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
  37. package/src/components/editor/propertyPanelSections.tsx +2 -2
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  40. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  41. package/src/components/nle/TimelinePane.tsx +6 -0
  42. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  43. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  44. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  45. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  46. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  47. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  48. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  49. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  50. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  51. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  52. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  53. package/src/components/storyboard/useFrameComments.ts +22 -6
  54. package/src/contexts/StudioContext.tsx +4 -0
  55. package/src/contexts/ViewModeContext.tsx +60 -7
  56. package/src/hooks/timelineEditingHelpers.ts +10 -4
  57. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  58. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  59. package/src/hooks/usePreviewPersistence.ts +7 -1
  60. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  61. package/src/hooks/useRazorSplit.test.tsx +20 -9
  62. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  63. package/src/hooks/useRazorSplit.ts +78 -350
  64. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  65. package/src/hooks/useSlideshowTabState.ts +61 -0
  66. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  67. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  68. package/src/hooks/useTimelineEditing.ts +19 -17
  69. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  70. package/src/player/components/Timeline.tsx +12 -1
  71. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  72. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  73. package/src/player/components/timelineCallbacks.ts +4 -0
  74. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  75. package/src/player/components/timelineClipDragCommit.ts +77 -105
  76. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  77. package/src/player/components/timelineClipDragPreview.ts +2 -1
  78. package/src/player/components/timelineCollision.test.ts +18 -0
  79. package/src/player/components/timelineCollision.ts +12 -9
  80. package/src/player/components/timelineDragDrop.ts +28 -7
  81. package/src/player/components/timelineEditCapabilities.ts +4 -1
  82. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  83. package/src/player/components/timelineGroupEditing.ts +4 -3
  84. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  85. package/src/player/components/timelineLayout.ts +3 -1
  86. package/src/player/components/timelineZMirror.ts +1 -1
  87. package/src/player/components/useTimelineClipDrag.ts +5 -0
  88. package/src/player/components/useTimelineEditPinning.ts +12 -0
  89. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  90. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  91. package/src/player/lib/playbackTypes.ts +9 -0
  92. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  93. package/src/player/lib/timelineDOM.test.ts +58 -0
  94. package/src/player/lib/timelineDOM.ts +26 -12
  95. package/src/player/lib/timelineElementHelpers.ts +21 -11
  96. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  97. package/src/player/store/playerStore.ts +2 -1
  98. package/src/utils/razorSplitTransaction.test.ts +175 -0
  99. package/src/utils/razorSplitTransaction.ts +200 -0
  100. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  101. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  102. package/src/utils/timelineCompositionDrop.ts +16 -0
  103. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  104. package/src/utils/timelineCompositionInsert.ts +90 -0
  105. package/src/utils/timelineElementSplit.test.ts +14 -2
  106. package/src/utils/timelineElementSplit.ts +5 -1
  107. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  108. package/dist/assets/index-DXbu6IPT.css +0 -1
  109. package/dist/assets/index-cH6NfVV_.js +0 -426
  110. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -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);
@@ -166,6 +166,14 @@ export function patchIframeDomTiming(
166
166
  // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
167
167
  }
168
168
  }
169
+
170
+ export function playbackStartAttributeForElement(
171
+ element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
172
+ ): "data-media-start" | "data-playback-start" {
173
+ return element.playbackStartAttr === "playback-start" || element.kind === "composition"
174
+ ? "data-playback-start"
175
+ : "data-media-start";
176
+ }
169
177
  // fallow-ignore-next-line complexity
170
178
  function resolveResizePlaybackStart(
171
179
  original: string,
@@ -174,8 +182,7 @@ function resolveResizePlaybackStart(
174
182
  updates: Pick<TimelineElement, "start" | "playbackStart">,
175
183
  ): { attrName: string; value: number } | null {
176
184
  if (updates.playbackStart != null) {
177
- const attrName =
178
- element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
185
+ const attrName = playbackStartAttributeForElement(element).slice("data-".length);
179
186
  return { attrName, value: updates.playbackStart };
180
187
  }
181
188
  const trimDelta = updates.start - element.start;
@@ -185,8 +192,7 @@ function resolveResizePlaybackStart(
185
192
  readAttributeByTarget(original, target, "media-start");
186
193
  const current = raw != null ? parseFloat(raw) : undefined;
187
194
  if (current == null || !Number.isFinite(current)) return null;
188
- const attrName =
189
- element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
195
+ const attrName = playbackStartAttributeForElement(element).slice("data-".length);
190
196
  return {
191
197
  attrName,
192
198
  value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
@@ -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
+ }
@@ -208,13 +208,19 @@ export function usePreviewPersistence({
208
208
  // attributes onto the live DOM and re-runs the timeline at the SAME playhead,
209
209
  // falling back to reloadPreview for anything structural (split/delete undo),
210
210
  // multi-file, sub-comp, or a permanent soft-reload failure.
211
- applyUndoRestoreToPreview(
211
+ const strategy = applyUndoRestoreToPreview(
212
212
  previewIframeRef.current,
213
213
  activeCompPathRef.current,
214
214
  restore.files,
215
215
  usePlayerStore.getState().currentTime,
216
216
  reloadPreview,
217
217
  );
218
+ if (strategy === "full") {
219
+ const player = usePlayerStore.getState();
220
+ player.setElements([]);
221
+ player.setSelectedElementId(null);
222
+ player.setTimelineReady(false);
223
+ }
218
224
  },
219
225
  [previewIframeRef, activeCompPathRef, reloadPreview],
220
226
  );
@@ -67,43 +67,36 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
67
67
  onChange: () => {},
68
68
  });
69
69
 
70
- // Faithful stand-in for the studio-server file-mutation endpoints: the server
71
- // writes the split to disk itself, then returns the patched content.
70
+ // Faithful stand-in for the atomic server cut: one forward file write and one
71
+ // response carrying the canonical history snapshots.
72
72
  const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
73
73
  const u = String(url);
74
- if (u.includes("/gsap-mutations/")) {
75
- if (opts.gsap) {
76
- // Mirror the server: rewrites the GSAP script for the new id, writes to
77
- // disk, returns the final content.
78
- disk["index.html"] = SPLIT_GSAP;
79
- const version = `"test-gsap-${SPLIT_GSAP.length}"`;
80
- return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP, version }), {
81
- status: 200,
82
- headers: { "Content-Type": "application/json", ETag: version },
83
- });
84
- }
85
- // The fixture has no GSAP script — mirror the server's 400 response.
86
- return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
87
- status: 400,
88
- headers: { "Content-Type": "application/json" },
89
- });
90
- }
91
- if (u.includes("/file-mutations/split-element/")) {
92
- disk["index.html"] = SPLIT;
93
- const version = `"test-split-${SPLIT.length}"`;
74
+ if (u.includes("/file-mutations/split-batch")) {
75
+ const before = disk["index.html"];
76
+ disk["index.html"] = finalContent;
77
+ const version = `"test-cut-${finalContent.length}"`;
94
78
  return new Response(
95
79
  JSON.stringify({
96
80
  ok: true,
97
- changed: true,
98
- content: SPLIT,
99
- newId: "clip1-split",
100
- version,
81
+ outcome: "committed",
82
+ files: [
83
+ {
84
+ path: "index.html",
85
+ before,
86
+ after: finalContent,
87
+ version,
88
+ writeToken: "test-cut",
89
+ splitCount: 1,
90
+ skippedSelectors: [],
91
+ },
92
+ ],
101
93
  }),
102
- { status: 200, headers: { "Content-Type": "application/json", ETag: version } },
94
+ { status: 200, headers: { "Content-Type": "application/json" } },
103
95
  );
104
96
  }
105
97
  if (u.includes("/files/")) {
106
- return new Response(JSON.stringify({ content: disk["index.html"] }), {
98
+ const content = disk["index.html"];
99
+ return new Response(JSON.stringify({ content, version: `"test-${content.length}"` }), {
107
100
  status: 200,
108
101
  headers: { "Content-Type": "application/json" },
109
102
  });
@@ -17,19 +17,30 @@ function jsonResponse(body: unknown, status = 200): Response {
17
17
  describe("useRazorSplit mutation versions", () => {
18
18
  afterEach(() => vi.restoreAllMocks());
19
19
 
20
- it("observes each out-of-band mutation version before the OCC writer runs", async () => {
20
+ it("observes the batch version without a redundant client forward write", async () => {
21
21
  const original = '<div id="clip" data-start="0" data-duration="4">Clip</div>';
22
22
  const htmlSplit =
23
23
  '<div id="clip" data-start="0" data-duration="2">Clip</div><div id="clip-split" data-start="2" data-duration="2">Clip</div>';
24
24
  const final = `${htmlSplit}<script>window.__timelines = {}</script>`;
25
25
  vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
26
26
  const url = String(input);
27
- if (url.includes("/files/")) return jsonResponse({ content: original });
28
- if (url.includes("/file-mutations/split-element/")) {
29
- return jsonResponse({ ok: true, changed: true, content: htmlSplit, version: '"v-html"' });
30
- }
31
- if (url.includes("/gsap-mutations/")) {
32
- return jsonResponse({ ok: true, changed: true, after: final, version: '"v-gsap"' });
27
+ if (url.includes("/files/")) return jsonResponse({ content: original, version: '"v0"' });
28
+ if (url.includes("/file-mutations/split-batch")) {
29
+ return jsonResponse({
30
+ ok: true,
31
+ outcome: "committed",
32
+ files: [
33
+ {
34
+ path: "index.html",
35
+ before: original,
36
+ after: final,
37
+ version: '"v-cut"',
38
+ writeToken: "cut-1",
39
+ splitCount: 1,
40
+ skippedSelectors: [],
41
+ },
42
+ ],
43
+ });
33
44
  }
34
45
  throw new Error(`Unexpected request: ${url}`);
35
46
  });
@@ -78,8 +89,8 @@ describe("useRazorSplit mutation versions", () => {
78
89
  );
79
90
  });
80
91
 
81
- expect(order).toEqual(['observe:index.html:"v-html"', 'observe:index.html:"v-gsap"', "write"]);
82
- expect(writeProjectFile).toHaveBeenCalledWith("index.html", final, original);
92
+ expect(order).toEqual(['observe:index.html:"v-cut"']);
93
+ expect(writeProjectFile).not.toHaveBeenCalled();
83
94
  expect(recordEdit).toHaveBeenCalledTimes(1);
84
95
 
85
96
  act(() => root.unmount());
@@ -8,15 +8,19 @@ export interface SplitBody {
8
8
  elementDuration: number;
9
9
  }
10
10
 
11
+ interface BatchFileBody {
12
+ path: string;
13
+ targets: SplitBody[];
14
+ }
15
+
11
16
  function decodePathFromUrl(url: string, marker: string): string {
12
17
  const encoded = url.slice(url.indexOf(marker) + marker.length);
13
18
  return decodeURIComponent(encoded);
14
19
  }
15
20
 
16
21
  /**
17
- * Fetch mock shared by both harnesses: GSAP mutations 400 (no script in fixtures),
18
- * split-element writes a `<!--split-->` marker so `changed` is true, and file reads
19
- * echo the in-memory `disk`. `onSplit` (when set) records each split request's body.
22
+ * Fetch mock shared by both harnesses: the atomic split batch writes each file
23
+ * once and returns its canonical snapshots; file reads echo the in-memory disk.
20
24
  */
21
25
  export function createSplitFetchMock(
22
26
  disk: Record<string, string>,
@@ -24,31 +28,39 @@ export function createSplitFetchMock(
24
28
  ) {
25
29
  return vi.fn(async (url: string, init?: RequestInit) => {
26
30
  const u = String(url);
27
- if (u.includes("/gsap-mutations/")) {
28
- // No GSAP script in the fixtures mirror the server's 400 response.
29
- return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
30
- status: 400,
31
- headers: { "Content-Type": "application/json" },
31
+ if (u.includes("/file-mutations/split-batch")) {
32
+ const body = JSON.parse(String(init?.body)) as { files: BatchFileBody[] };
33
+ const files = body.files.map((file) => {
34
+ const before = disk[file.path];
35
+ for (const target of file.targets) onSplit?.(file.path, target);
36
+ const after = `${before}${"<!--split-->".repeat(file.targets.length)}`;
37
+ const version = `"test-${file.path}-${after.length}"`;
38
+ return {
39
+ path: file.path,
40
+ before,
41
+ after,
42
+ version,
43
+ writeToken: "test-cut",
44
+ splitCount: file.targets.length,
45
+ skippedSelectors: [],
46
+ };
32
47
  });
33
- }
34
- if (u.includes("/file-mutations/split-element/")) {
35
- const path = decodePathFromUrl(u, "/file-mutations/split-element/");
36
- onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
37
- // Return content that differs from the original so `changed` is true.
38
- const after = `${disk[path]}<!--split-->`;
39
- const version = `"test-${path}-${after.length}"`;
40
- disk[path] = after; // server writes the split to disk
41
- return new Response(JSON.stringify({ ok: true, changed: true, content: after, version }), {
48
+ for (const file of files) disk[file.path] = file.after;
49
+ return new Response(JSON.stringify({ ok: true, outcome: "committed", files }), {
42
50
  status: 200,
43
- headers: { "Content-Type": "application/json", ETag: version },
51
+ headers: { "Content-Type": "application/json" },
44
52
  });
45
53
  }
46
54
  if (u.includes("/files/")) {
47
55
  const path = decodePathFromUrl(u, "/files/").replace(/\?.*$/, "");
48
- return new Response(JSON.stringify({ content: disk[path] ?? "" }), {
49
- status: 200,
50
- headers: { "Content-Type": "application/json" },
51
- });
56
+ const content = disk[path] ?? "";
57
+ return new Response(
58
+ JSON.stringify({ content, version: `"test-${path}-${content.length}"` }),
59
+ {
60
+ status: 200,
61
+ headers: { "Content-Type": "application/json" },
62
+ },
63
+ );
52
64
  }
53
65
  void init;
54
66
  throw new Error(`unexpected fetch: ${u}`);