@hyperframes/studio 0.7.56 → 0.7.58

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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -8,6 +8,7 @@ import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
8
8
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
9
9
  import type { EditHistoryKind } from "../utils/editHistory";
10
10
  import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
11
+ import { findElementForSelection } from "../components/editor/domEditingElement";
11
12
  import { readFileContent } from "./timelineEditingHelpers";
12
13
 
13
14
  interface RecordEditInput {
@@ -34,6 +35,7 @@ interface UseClipboardOptions {
34
35
  function getElementOuterHtml(
35
36
  iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
36
37
  selection: DomEditSelection,
38
+ activeCompositionPath: string | null,
37
39
  ): string | null {
38
40
  let doc: Document | null = null;
39
41
  try {
@@ -43,15 +45,7 @@ function getElementOuterHtml(
43
45
  }
44
46
  if (!doc) return null;
45
47
 
46
- let el: Element | null = null;
47
- if (selection.id) {
48
- el = doc.getElementById(selection.id);
49
- }
50
- if (!el && selection.selector) {
51
- const matches = doc.querySelectorAll(selection.selector);
52
- el = matches[selection.selectorIndex ?? 0] ?? null;
53
- }
54
- return el && "outerHTML" in el ? (el as Element).outerHTML : null;
48
+ return findElementForSelection(doc, selection, activeCompositionPath)?.outerHTML ?? null;
55
49
  }
56
50
 
57
51
  export function useClipboard({
@@ -71,6 +65,9 @@ export function useClipboard({
71
65
  const projectIdRef = useRef(projectId);
72
66
  projectIdRef.current = projectId;
73
67
 
68
+ // The copy-mode branches predate this change; this diff only replaces its
69
+ // duplicated DOM lookup with the canonical composition-aware resolver.
70
+ // fallow-ignore-next-line complexity
74
71
  const handleCopy = useCallback((): boolean => {
75
72
  const { selectedElementId, elements } = usePlayerStore.getState();
76
73
 
@@ -84,13 +81,18 @@ export function useClipboard({
84
81
  try {
85
82
  const doc = previewIframeRef.current?.contentDocument;
86
83
  if (doc) {
87
- let el: Element | null = null;
88
- if (element.domId) el = doc.getElementById(element.domId);
89
- if (!el && element.selector) {
90
- const matches = doc.querySelectorAll(element.selector);
91
- el = matches[element.selectorIndex ?? 0] ?? null;
92
- }
93
- if (el && "outerHTML" in el) html = (el as Element).outerHTML;
84
+ html =
85
+ findElementForSelection(
86
+ doc,
87
+ {
88
+ hfId: element.hfId,
89
+ id: element.domId,
90
+ selector: element.selector,
91
+ selectorIndex: element.selectorIndex,
92
+ sourceFile: targetPath,
93
+ },
94
+ activeCompPath,
95
+ )?.outerHTML ?? null;
94
96
  }
95
97
  } catch {
96
98
  // cross-origin frame
@@ -110,7 +112,7 @@ export function useClipboard({
110
112
  // DOM element copy
111
113
  const domSelection = domEditSelectionRef.current;
112
114
  if (domSelection) {
113
- const html = getElementOuterHtml(previewIframeRef, domSelection);
115
+ const html = getElementOuterHtml(previewIframeRef, domSelection, activeCompPath);
114
116
  if (!html) {
115
117
  showToast("Unable to copy this element.", "info");
116
118
  return false;
@@ -1,6 +1,33 @@
1
1
  import { useState } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
+ import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
5
+
6
+ function readCompositionSizeMessage(data: unknown): CompositionDimensions | null {
7
+ if (!isStageSizeMessage(data)) return null;
8
+ const message = data;
9
+ if (!acceptStudioRuntimeMessage(message)) return null;
10
+ return readPositiveDimensions(message.width, message.height);
11
+ }
12
+
13
+ function isStageSizeMessage(value: unknown): value is Record<string, unknown> {
14
+ if (typeof value !== "object") return false;
15
+ if (value === null) return false;
16
+ const message = value as Record<string, unknown>;
17
+ return message.source === "hf-preview" && message.type === "stage-size";
18
+ }
19
+
20
+ function readPositiveNumber(value: unknown): number | null {
21
+ if (typeof value !== "number") return null;
22
+ return Number.isFinite(value) && value > 0 ? value : null;
23
+ }
24
+
25
+ function readPositiveDimensions(width: unknown, height: unknown): CompositionDimensions | null {
26
+ const parsedWidth = readPositiveNumber(width);
27
+ const parsedHeight = readPositiveNumber(height);
28
+ if (parsedWidth === null || parsedHeight === null) return null;
29
+ return { width: parsedWidth, height: parsedHeight };
30
+ }
4
31
 
5
32
  export function useCompositionDimensions() {
6
33
  const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
@@ -9,12 +36,12 @@ export function useCompositionDimensions() {
9
36
 
10
37
  useMountEffect(() => {
11
38
  const handleMessage = (e: MessageEvent) => {
12
- const data = e.data;
13
- if (data?.source !== "hf-preview" || data?.type !== "stage-size") return;
14
- const { width, height } = data as { width: number; height: number };
15
- if (!(width > 0) || !(height > 0)) return;
39
+ const dimensions = readCompositionSizeMessage(e.data);
40
+ if (!dimensions) return;
16
41
  setCompositionDimensions((prev) =>
17
- prev && prev.width === width && prev.height === height ? prev : { width, height },
42
+ prev && prev.width === dimensions.width && prev.height === dimensions.height
43
+ ? prev
44
+ : dimensions,
18
45
  );
19
46
  };
20
47
  window.addEventListener("message", handleMessage);
@@ -265,7 +265,7 @@ describe("useDomEditCommits z-index reorder persistence", () => {
265
265
  document.body.replaceChildren();
266
266
  });
267
267
 
268
- it("persists an N-element reorder with one batch POST, one undo entry, and one reload", async () => {
268
+ it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
269
269
  const original =
270
270
  '<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
271
271
  const after =
@@ -276,18 +276,20 @@ describe("useDomEditCommits z-index reorder persistence", () => {
276
276
  _init?: Parameters<typeof fetch>[1],
277
277
  ): Promise<Response> => {
278
278
  const url = requestUrl(input);
279
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
280
- if (url.includes("/file-mutations/patch-elements-batch/")) {
279
+ if (url.endsWith("/file-mutations/patch-element-batches")) {
281
280
  return jsonResponse({
282
- ok: true,
283
- changed: true,
284
- matched: [true, true, true],
285
- content: after,
281
+ durable: true,
282
+ files: [
283
+ {
284
+ sourceFile: "index.html",
285
+ changed: true,
286
+ matched: [true, true, true],
287
+ before: original,
288
+ after,
289
+ },
290
+ ],
286
291
  });
287
292
  }
288
- if (url.includes("/file-mutations/patch-element/")) {
289
- return jsonResponse({ ok: true, changed: true, matched: true, content: after });
290
- }
291
293
  throw new Error(`Unexpected fetch: ${url}`);
292
294
  },
293
295
  );
@@ -317,39 +319,262 @@ describe("useDomEditCommits z-index reorder persistence", () => {
317
319
  });
318
320
 
319
321
  const batchPosts = fetchMock.mock.calls.filter(([input]) =>
320
- requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
322
+ requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
321
323
  );
322
324
  const singlePosts = fetchMock.mock.calls.filter(([input]) =>
323
- requestUrl(input).includes("/file-mutations/patch-element/"),
325
+ requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
324
326
  );
325
327
  expect(batchPosts).toHaveLength(1);
326
328
  expect(singlePosts).toHaveLength(0);
327
329
  expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
328
- patches: expect.arrayContaining([
329
- expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
330
- expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
331
- expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
332
- ]),
330
+ batches: [
331
+ {
332
+ sourceFile: "index.html",
333
+ patches: expect.arrayContaining([
334
+ expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
335
+ expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
336
+ expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
337
+ ]),
338
+ },
339
+ ],
333
340
  });
334
341
  expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
335
342
  expect(rendered.recordEdit).toHaveBeenCalledWith({
336
343
  label: "Reorder layers",
337
344
  kind: "manual",
338
345
  coalesceKey: "z-reorder:test",
346
+ // Unbounded per-gesture fold window (keys are unique per gesture):
347
+ // the z entry and its mirror/lane counterpart fold across the server
348
+ // round-trip that separates them.
349
+ coalesceMs: Number.POSITIVE_INFINITY,
339
350
  files: { "index.html": { before: original, after } },
340
351
  });
352
+ // FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
353
+ // The live DOM + store already hold the final state and the server matched
354
+ // every style-only patch, so the reload is provably redundant.
355
+ expect(rendered.reloadPreview).not.toHaveBeenCalled();
356
+ } finally {
357
+ rendered.cleanup();
358
+ }
359
+ });
360
+
361
+ it("falls back to reloading when the server response omits matched[]", async () => {
362
+ // Without a matched[] confirmation the persist can't be proven in sync with
363
+ // the live DOM — the skip-reload path must not engage.
364
+ const original = '<div id="a" style="z-index: 1"></div>';
365
+ const after = '<div id="a" style="z-index: 2"></div>';
366
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
367
+ const url = requestUrl(input);
368
+ if (url.endsWith("/file-mutations/patch-element-batches")) {
369
+ return jsonResponse({
370
+ durable: true,
371
+ files: [{ sourceFile: "index.html", changed: true, before: original, after }],
372
+ });
373
+ }
374
+ throw new Error(`Unexpected fetch: ${url}`);
375
+ });
376
+ vi.stubGlobal("fetch", fetchMock);
377
+ const { iframe, element } = createPreviewElement();
378
+ element.id = "a";
379
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
380
+
381
+ try {
382
+ await act(async () => {
383
+ await rendered.hook.handleDomZIndexReorderCommit([
384
+ { element, zIndex: 2, id: "a", sourceFile: "index.html" },
385
+ ]);
386
+ });
387
+
388
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
389
+ expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
390
+ } finally {
391
+ rendered.cleanup();
392
+ }
393
+ });
394
+
395
+ it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
396
+ // The server reports per-patch matched[]: #b was not found in the source,
397
+ // so it atomically refuses the whole multi-file gesture. The reload
398
+ // reconverges the preview with disk while the lifecycle owner rolls back
399
+ // both optimistic writes.
400
+ const original = '<div id="a" style="z-index: 1"></div>';
401
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
402
+ const url = requestUrl(input);
403
+ if (url.endsWith("/file-mutations/patch-element-batches")) {
404
+ return jsonResponse({
405
+ durable: false,
406
+ files: [
407
+ {
408
+ sourceFile: "index.html",
409
+ changed: false,
410
+ matched: [true, false],
411
+ before: original,
412
+ after: original,
413
+ },
414
+ ],
415
+ });
416
+ }
417
+ throw new Error(`Unexpected fetch: ${url}`);
418
+ });
419
+ vi.stubGlobal("fetch", fetchMock);
420
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
421
+ const { iframe, element } = createPreviewElement(
422
+ '<div data-hf-id="hf-card"></div><div id="b"></div>',
423
+ );
424
+ element.id = "a";
425
+ const second = iframe.contentDocument!.getElementById("b")!;
426
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
427
+
428
+ try {
429
+ await act(async () => {
430
+ await rendered.hook.handleDomZIndexReorderCommit([
431
+ { element, zIndex: 2, id: "a", sourceFile: "index.html" },
432
+ { element: second, zIndex: 1, id: "b", sourceFile: "index.html" },
433
+ ]);
434
+ });
435
+
436
+ // No throw: incomplete durability rolls back both optimistic writes.
437
+ expect(element.style.zIndex).toBe("");
438
+ expect(second.style.zIndex).toBe("");
439
+ expect(rendered.recordEdit).not.toHaveBeenCalled();
440
+ expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
441
+ expect(warnSpy).toHaveBeenCalledWith(
442
+ expect.stringContaining("could not match 1 patch target(s) in index.html"),
443
+ "b",
444
+ );
445
+ expect(trackStudioEvent).toHaveBeenCalledWith(
446
+ "save_failure",
447
+ expect.objectContaining({
448
+ mutation_type: "z-reorder-unmatched",
449
+ file_path: "index.html",
450
+ error_message: expect.stringContaining("b"),
451
+ }),
452
+ );
453
+ } finally {
454
+ warnSpy.mockRestore();
455
+ rendered.cleanup();
456
+ }
457
+ });
458
+
459
+ it("uses one atomic request and rolls back every file when one target is unmatched", async () => {
460
+ const indexOriginal = '<div id="a" style="z-index: 4"></div>';
461
+ const sceneOriginal = '<div id="b" style="z-index: 5"></div>';
462
+ const fetchMock = vi.fn(
463
+ async (
464
+ input: Parameters<typeof fetch>[0],
465
+ init?: Parameters<typeof fetch>[1],
466
+ ): Promise<Response> => {
467
+ const url = requestUrl(input);
468
+ if (url.endsWith("/file-mutations/patch-element-batches")) {
469
+ expect(JSON.parse(String(init?.body))).toEqual({
470
+ batches: [
471
+ expect.objectContaining({ sourceFile: "index.html" }),
472
+ expect.objectContaining({ sourceFile: "scene.html" }),
473
+ ],
474
+ });
475
+ return jsonResponse({
476
+ durable: false,
477
+ files: [
478
+ {
479
+ sourceFile: "index.html",
480
+ changed: false,
481
+ matched: [true],
482
+ before: indexOriginal,
483
+ after: indexOriginal,
484
+ },
485
+ {
486
+ sourceFile: "scene.html",
487
+ changed: false,
488
+ matched: [false],
489
+ before: sceneOriginal,
490
+ after: sceneOriginal,
491
+ },
492
+ ],
493
+ });
494
+ }
495
+ throw new Error(`Unexpected fetch: ${url}`);
496
+ },
497
+ );
498
+ vi.stubGlobal("fetch", fetchMock);
499
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
500
+ const { iframe, element } = createPreviewElement(
501
+ '<div data-hf-id="hf-card" style="z-index: 4"></div><div id="b" style="z-index: 5"></div>',
502
+ );
503
+ element.id = "a";
504
+ const second = iframe.contentDocument!.getElementById("b")!;
505
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
506
+
507
+ try {
508
+ let result:
509
+ | Awaited<ReturnType<typeof rendered.hook.handleDomZIndexReorderCommit>>
510
+ | undefined;
511
+ await act(async () => {
512
+ result = await rendered.hook.handleDomZIndexReorderCommit([
513
+ { element, zIndex: 8, id: "a", sourceFile: "index.html" },
514
+ { element: second, zIndex: 9, id: "b", sourceFile: "scene.html" },
515
+ ]);
516
+ });
517
+
518
+ expect(result).toEqual({ durable: false, allMatched: false, changed: false });
519
+ expect(element.style.zIndex).toBe("4");
520
+ expect(second.style.zIndex).toBe("5");
521
+ expect(rendered.recordEdit).not.toHaveBeenCalled();
522
+ expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
523
+ expect(fetchMock).toHaveBeenCalledTimes(1);
524
+ } finally {
525
+ warnSpy.mockRestore();
526
+ rendered.cleanup();
527
+ }
528
+ });
529
+
530
+ it("reloads and reports non-durable when an unmatched batch changes no bytes", async () => {
531
+ const original = '<div id="a" style="z-index: 1"></div>';
532
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
533
+ const url = requestUrl(input);
534
+ if (url.endsWith("/file-mutations/patch-element-batches")) {
535
+ return jsonResponse({
536
+ durable: false,
537
+ files: [
538
+ {
539
+ sourceFile: "index.html",
540
+ changed: false,
541
+ matched: [false],
542
+ before: original,
543
+ after: original,
544
+ },
545
+ ],
546
+ });
547
+ }
548
+ throw new Error(`Unexpected fetch: ${url}`);
549
+ });
550
+ vi.stubGlobal("fetch", fetchMock);
551
+ const { iframe, element } = createPreviewElement();
552
+ element.id = "a";
553
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
554
+
555
+ try {
556
+ let result:
557
+ | Awaited<ReturnType<typeof rendered.hook.handleDomZIndexReorderCommit>>
558
+ | undefined;
559
+ await act(async () => {
560
+ result = await rendered.hook.handleDomZIndexReorderCommit([
561
+ { element, zIndex: 2, id: "missing", sourceFile: "index.html" },
562
+ ]);
563
+ });
564
+
565
+ expect(result).toEqual({ durable: false, allMatched: false, changed: false });
566
+ expect(element.style.zIndex).toBe("");
567
+ expect(rendered.recordEdit).not.toHaveBeenCalled();
341
568
  expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
342
569
  } finally {
343
570
  rendered.cleanup();
344
571
  }
345
572
  });
346
573
 
347
- it("rolls back live state after a failed batch POST without a disk write-back", async () => {
348
- const original = '<div id="a" style="z-index: 7"></div><div id="b"></div>';
574
+ it("rolls back and reloads after a rejected batch POST", async () => {
349
575
  const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
350
576
  const url = requestUrl(input);
351
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
352
- if (url.includes("/file-mutations/patch-elements-batch/")) {
577
+ if (url.endsWith("/file-mutations/patch-element-batches")) {
353
578
  return jsonResponse({ error: "batch rejected" }, 500);
354
579
  }
355
580
  throw new Error(`Unexpected fetch: ${url}`);
@@ -394,10 +619,154 @@ describe("useDomEditCommits z-index reorder persistence", () => {
394
619
  ]);
395
620
  expect(writeProjectFile).not.toHaveBeenCalled();
396
621
  expect(rendered.recordEdit).not.toHaveBeenCalled();
397
- expect(rendered.reloadPreview).not.toHaveBeenCalled();
622
+ expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
623
+ expect(
624
+ fetchMock.mock.calls.filter(([input]) =>
625
+ requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
626
+ ),
627
+ ).toHaveLength(1);
628
+ } finally {
629
+ rendered.cleanup();
630
+ }
631
+ });
632
+
633
+ it("reloads after the server commits but the aggregate response is lost", async () => {
634
+ const original = '<div data-hf-id="hf-card" id="a" style="z-index: 7"></div>';
635
+ const after = '<div data-hf-id="hf-card" id="a" style="z-index: 2"></div>';
636
+ let diskContent = original;
637
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
638
+ const url = requestUrl(input);
639
+ if (url.endsWith("/file-mutations/patch-element-batches")) {
640
+ diskContent = after;
641
+ throw new TypeError("connection reset after commit");
642
+ }
643
+ throw new Error(`Unexpected fetch: ${url}`);
644
+ });
645
+ vi.stubGlobal("fetch", fetchMock);
646
+ const { iframe, element } = createPreviewElement(original);
647
+ usePlayerStore.getState().setElements([
648
+ {
649
+ id: "a",
650
+ tag: "div",
651
+ start: 0,
652
+ duration: 1,
653
+ track: 0,
654
+ zIndex: 7,
655
+ hasExplicitZIndex: true,
656
+ },
657
+ ]);
658
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
659
+
660
+ try {
661
+ let rejection: unknown;
662
+ await act(async () => {
663
+ try {
664
+ await rendered.hook.handleDomZIndexReorderCommit([
665
+ { element, zIndex: 2, id: "a", sourceFile: "index.html", key: "a" },
666
+ ]);
667
+ } catch (error) {
668
+ rejection = error;
669
+ }
670
+ });
671
+
672
+ expect(rejection).toBeInstanceOf(Error);
673
+ expect((rejection as Error).message).toContain("connection reset after commit");
674
+ expect(diskContent).toBe(after);
675
+ expect(element.style.zIndex).toBe("7");
676
+ expect(usePlayerStore.getState().elements[0]).toMatchObject({
677
+ zIndex: 7,
678
+ hasExplicitZIndex: true,
679
+ });
680
+ expect(rendered.recordEdit).not.toHaveBeenCalled();
681
+ expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
682
+ expect(
683
+ fetchMock.mock.calls.filter(([input]) =>
684
+ requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
685
+ ),
686
+ ).toHaveLength(1);
687
+ } finally {
688
+ rendered.cleanup();
689
+ }
690
+ });
691
+
692
+ it.each([
693
+ [
694
+ "changed=false with different snapshots",
695
+ {
696
+ durable: true,
697
+ files: [
698
+ {
699
+ sourceFile: "index.html",
700
+ changed: false,
701
+ matched: [true],
702
+ before: '<div id="a" style="z-index: 1"></div>',
703
+ after: '<div id="a" style="z-index: 2"></div>',
704
+ },
705
+ ],
706
+ },
707
+ ],
708
+ [
709
+ "changed=true with identical snapshots",
710
+ {
711
+ durable: true,
712
+ files: [
713
+ {
714
+ sourceFile: "index.html",
715
+ changed: true,
716
+ matched: [true],
717
+ before: '<div id="a" style="z-index: 1"></div>',
718
+ after: '<div id="a" style="z-index: 1"></div>',
719
+ },
720
+ ],
721
+ },
722
+ ],
723
+ [
724
+ "durable=false with a changed file",
725
+ {
726
+ durable: false,
727
+ files: [
728
+ {
729
+ sourceFile: "index.html",
730
+ changed: true,
731
+ matched: [true],
732
+ before: '<div id="a" style="z-index: 1"></div>',
733
+ after: '<div id="a" style="z-index: 2"></div>',
734
+ },
735
+ ],
736
+ },
737
+ ],
738
+ ])("rejects and reloads for malformed 200 response: %s", async (_label, responseBody) => {
739
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
740
+ const url = requestUrl(input);
741
+ if (url.endsWith("/file-mutations/patch-element-batches")) {
742
+ return jsonResponse(responseBody);
743
+ }
744
+ throw new Error(`Unexpected fetch: ${url}`);
745
+ });
746
+ vi.stubGlobal("fetch", fetchMock);
747
+ const { iframe, element } = createPreviewElement();
748
+ element.id = "a";
749
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
750
+
751
+ try {
752
+ let rejection: unknown;
753
+ await act(async () => {
754
+ try {
755
+ await rendered.hook.handleDomZIndexReorderCommit([
756
+ { element, zIndex: 2, id: "a", sourceFile: "index.html", key: "a" },
757
+ ]);
758
+ } catch (error) {
759
+ rejection = error;
760
+ }
761
+ });
762
+
763
+ expect(rejection).toBeInstanceOf(Error);
764
+ expect((rejection as Error).message).toBe("Invalid atomic element patch response");
765
+ expect(rendered.recordEdit).not.toHaveBeenCalled();
766
+ expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
398
767
  expect(
399
768
  fetchMock.mock.calls.filter(([input]) =>
400
- requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
769
+ requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
401
770
  ),
402
771
  ).toHaveLength(1);
403
772
  } finally {