@hyperframes/studio 0.8.15 → 0.8.17

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 (62) hide show
  1. package/dist/assets/hyperframes-player-DejBDgXB.js +459 -0
  2. package/dist/assets/index-BX3KHhGX.js +71 -0
  3. package/dist/assets/{index-DKoZIpw1.js → index-CU6o8PuW.js} +180 -180
  4. package/dist/assets/{index-CVtYYTnA.js → index-CklNVmi3.js} +1 -1
  5. package/dist/assets/{index-CtDg2lB7.js → index-uu4Zd3BU.js} +1 -1
  6. package/dist/index.d.ts +9 -13
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +1660 -1284
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/components/EditorShell.tsx +4 -0
  12. package/src/components/editor/DomEditCropHandles.test.tsx +1 -1
  13. package/src/components/editor/DomEditCropHandles.tsx +1 -1
  14. package/src/components/editor/DomEditOverlay.tsx +1 -1
  15. package/src/components/editor/DomEditSelectionChrome.tsx +1 -1
  16. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +38 -4
  17. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  18. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +3 -3
  19. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +1 -1
  20. package/src/components/editor/propertyPanelFlatMediaSection.tsx +1 -1
  21. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +8 -8
  23. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  24. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  25. package/src/components/editor/propertyPanelStyleSections.tsx +1 -1
  26. package/src/components/editor/propertyPanelTypes.ts +1 -1
  27. package/src/components/editor/useDomEditOverlayGestures.ts +8 -2
  28. package/src/components/editor/useInspectorGestureTransaction.ts +3 -3
  29. package/src/components/sidebar/AudioRow.tsx +13 -2
  30. package/src/hooks/domEditCommitRunner.ts +47 -0
  31. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
  32. package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
  33. package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
  34. package/src/hooks/useDomEditTextCommits.ts +17 -9
  35. package/src/hooks/useDomEditWiring.ts +1 -1
  36. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  37. package/src/hooks/useDomGeometryCommits.ts +10 -2
  38. package/src/hooks/useDomSelection.test.ts +48 -0
  39. package/src/hooks/useDomSelection.ts +26 -82
  40. package/src/hooks/useDomSelectionTypes.ts +82 -0
  41. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
  42. package/src/hooks/useElementLifecycleOps.ts +11 -5
  43. package/src/hooks/useGsapSelectionHandlers.ts +4 -2
  44. package/src/hooks/usePanelLayout.test.ts +38 -0
  45. package/src/hooks/usePanelLayout.ts +21 -6
  46. package/src/player/lib/playbackTypes.ts +3 -12
  47. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  48. package/src/utils/studioUiPreferences.ts +11 -0
  49. package/src/webmcp/StudioAgentTools.tsx +46 -0
  50. package/src/webmcp/handles.test.ts +130 -0
  51. package/src/webmcp/handles.ts +129 -0
  52. package/src/webmcp/polyfill.test.ts +98 -0
  53. package/src/webmcp/polyfill.ts +60 -0
  54. package/src/webmcp/registrar.test.ts +150 -0
  55. package/src/webmcp/registrar.ts +115 -0
  56. package/src/webmcp/toolResult.ts +67 -0
  57. package/src/webmcp/tools/lookTools.test.ts +225 -0
  58. package/src/webmcp/tools/lookTools.ts +201 -0
  59. package/src/webmcp/types.ts +90 -0
  60. package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
  61. package/src/webmcp/useStudioAgentTools.ts +119 -0
  62. package/dist/assets/hyperframes-player-BlIp1Y-b.js +0 -459
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.15",
3
+ "version": "0.8.17",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,6 +40,7 @@
40
40
  "@codemirror/state": "^6.6.0",
41
41
  "@codemirror/theme-one-dark": "^6.1.2",
42
42
  "@codemirror/view": "6.40.0",
43
+ "@mcp-b/global": "^5.0.1",
43
44
  "@phosphor-icons/react": "^2.1.10",
44
45
  "@tanstack/react-virtual": "^3.14.6",
45
46
  "bpm-detective": "^2.0.5",
@@ -47,11 +48,11 @@
47
48
  "gsap": "^3.13.0",
48
49
  "marked": "^14.1.4",
49
50
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.8.15",
51
- "@hyperframes/player": "0.8.15",
52
- "@hyperframes/parsers": "0.8.15",
53
- "@hyperframes/studio-server": "0.8.15",
54
- "@hyperframes/sdk": "0.8.15"
51
+ "@hyperframes/player": "0.8.17",
52
+ "@hyperframes/parsers": "0.8.17",
53
+ "@hyperframes/studio-server": "0.8.17",
54
+ "@hyperframes/core": "0.8.17",
55
+ "@hyperframes/sdk": "0.8.17"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@types/react": "19",
@@ -68,7 +69,7 @@
68
69
  "vite": "^6.4.2",
69
70
  "vitest": "^3.2.4",
70
71
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.8.15"
72
+ "@hyperframes/producer": "0.8.17"
72
73
  },
73
74
  "peerDependencies": {
74
75
  "react": "19",
@@ -15,6 +15,7 @@ import { usePlayerStore, type TimelineElement } from "../player";
15
15
  import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
16
16
  import type { GestureRecordingState } from "./editor/GestureRecordControl";
17
17
  import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
18
+ import { StudioAgentTools } from "../webmcp/StudioAgentTools";
18
19
 
19
20
  type RenderClipContent = (
20
21
  element: TimelineElement,
@@ -262,6 +263,9 @@ function EditorShellBody({
262
263
  onKeyDown={handleKeyDown}
263
264
  tabIndex={-1}
264
265
  >
266
+ {/* Renders nothing; exposes Studio's state to an agentic browser. Mounted
267
+ here rather than in App because it needs the DomEdit contexts. */}
268
+ <StudioAgentTools />
265
269
  {/* Top row: [left | preview | right] — outer padding + the 8px resize
266
270
  seams give the panels CapCut-style separation on the dark canvas. */}
267
271
  <div className="flex flex-row flex-1 min-h-0 px-px pt-px">
@@ -35,7 +35,7 @@ function makeEl(id: string, clip: string): HTMLElement {
35
35
 
36
36
  function render(
37
37
  el: HTMLElement,
38
- onStyleCommit: (property: string, value: string) => Promise<void> | void = () => undefined,
38
+ onStyleCommit: (property: string, value: string) => Promise<unknown> | void = () => undefined,
39
39
  ): { root: Root; rerender: (next: HTMLElement) => void } {
40
40
  const host = document.createElement("div");
41
41
  document.body.append(host);
@@ -28,7 +28,7 @@ interface CropGestureState {
28
28
  interface DomEditCropHandlesProps {
29
29
  selection: DomEditSelection;
30
30
  overlayRect: OverlayRect;
31
- onStyleCommit?: (property: string, value: string) => Promise<void> | void;
31
+ onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
32
32
  }
33
33
 
34
34
  // Hit-strip size (px) for an edge crop handle: THICKNESS extends outward from
@@ -83,7 +83,7 @@ interface DomEditOverlayProps {
83
83
  restore?: () => void,
84
84
  ) => Promise<void> | void;
85
85
  onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
86
- onStyleCommit?: (property: string, value: string) => Promise<void> | void;
86
+ onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
87
87
  gridVisible?: boolean;
88
88
  gridSpacing?: number;
89
89
  recordingState?: GestureRecordingState;
@@ -123,7 +123,7 @@ interface DomEditSelectionChromeProps {
123
123
  groupSelectionCount: number;
124
124
  blockedMoveRef: RefObject<BlockedMoveState | null>;
125
125
  gestures: GestureHandlers;
126
- onStyleCommit?: (property: string, value: string) => Promise<void> | void;
126
+ onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
127
127
  onBoxMouseDown: (e: React.MouseEvent) => void;
128
128
  onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
129
129
  /** The canvas' text-editing session: what opens one, and whether one is open. */
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
4
5
  import type { DomEditSelection } from "./domEditing";
5
6
  import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
6
7
 
@@ -75,7 +76,9 @@ interface CommitCall {
75
76
  offset: { x: number; y: number } | undefined;
76
77
  }
77
78
 
78
- function buildHarness() {
79
+ function buildHarness(
80
+ onBoxSizeCommit?: UseDomEditOverlayGesturesOptions["onBoxSizeCommitRef"]["current"],
81
+ ) {
79
82
  const element = document.createElement("div");
80
83
  document.body.append(element);
81
84
 
@@ -136,9 +139,12 @@ function buildHarness() {
136
139
  onManualDragStartRef: ref(() => {}),
137
140
  onPathOffsetCommitRef: ref(() => {}),
138
141
  onGroupPathOffsetCommitRef: ref(() => {}),
139
- onBoxSizeCommitRef: ref((_s, size, offset) => {
140
- commits.push({ size, offset });
141
- }),
142
+ onBoxSizeCommitRef: ref(
143
+ onBoxSizeCommit ??
144
+ ((_s, size, offset) => {
145
+ commits.push({ size, offset });
146
+ }),
147
+ ),
142
148
  onRotationCommitRef: ref(() => {}),
143
149
  onCanvasPointerMoveRef: ref(() => Promise.resolve(null)),
144
150
  onCanvasMouseDown: () => {},
@@ -172,6 +178,15 @@ function evt(clientX: number, clientY: number) {
172
178
  } as unknown as React.PointerEvent<HTMLDivElement>;
173
179
  }
174
180
 
181
+ async function finishResize(handlers: ReturnType<typeof createDomEditOverlayGestureHandlers>) {
182
+ handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
183
+ resizeHandle: "se",
184
+ });
185
+ handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
186
+ handlers.onPointerUp(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
187
+ await Promise.resolve();
188
+ }
189
+
175
190
  afterEach(() => {
176
191
  document.body.innerHTML = "";
177
192
  });
@@ -211,4 +226,23 @@ describe("anchored corner resize — the release commit feeds the center-pin off
211
226
  expect(offset.x).toBeCloseTo(-(size.width - ORIGIN.width) / 2, 0);
212
227
  expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
213
228
  });
229
+
230
+ it("does not log a paused save queue as an ordinary resize failure", async () => {
231
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
232
+ const { handlers } = buildHarness(() => Promise.reject(new DomEditSaveQueueOpenError()));
233
+
234
+ await finishResize(handlers);
235
+
236
+ expect(consoleError).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it("still logs an ordinary resize failure", async () => {
240
+ const failure = new Error("save failed");
241
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
242
+ const { handlers } = buildHarness(() => Promise.reject(failure));
243
+
244
+ await finishResize(handlers);
245
+
246
+ expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
247
+ });
214
248
  });
@@ -21,7 +21,7 @@ export function CommitField({
21
21
  liveCommit?: boolean;
22
22
  align?: "left" | "right";
23
23
  onPreview?: (nextValue: string) => void;
24
- onCommit: (nextValue: string) => void | Promise<void>;
24
+ onCommit: (nextValue: string) => void | Promise<unknown>;
25
25
  }) {
26
26
  const [draft, setDraft] = useState(value);
27
27
  const valueRef = useRef(value);
@@ -181,7 +181,7 @@ export function LayoutZIndexRow({
181
181
  onSetStyle,
182
182
  }: {
183
183
  styles: Record<string, string>;
184
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
184
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
185
185
  }) {
186
186
  const zIndex = String(parseInt(styles["z-index"] || "auto", 10) || 0);
187
187
  return (
@@ -200,7 +200,7 @@ export function LayoutFlexBlock({
200
200
  disabled,
201
201
  }: {
202
202
  styles: Record<string, string>;
203
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
203
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
204
204
  disabled: boolean;
205
205
  }) {
206
206
  const isFlex = styles.display === "flex" || styles.display === "inline-flex";
@@ -337,7 +337,7 @@ interface FlatLayoutSectionProps
337
337
  > {
338
338
  element: DomEditSelection;
339
339
  styles: Record<string, string>;
340
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
340
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
341
341
  disabled: boolean;
342
342
  }
343
343
 
@@ -27,7 +27,7 @@ export function FlatMaskInsetRows({
27
27
  clipPathValue: string;
28
28
  radiusValue: number;
29
29
  disabled: boolean;
30
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
30
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
31
31
  }) {
32
32
  const clipPathPreset = inferClipPathPreset(clipPathValue);
33
33
  const parsedClipInsets = parseInsetClipPathSides(clipPathValue);
@@ -38,7 +38,7 @@ export function FlatMediaSection({
38
38
  projectDir: string | null;
39
39
  element: DomEditSelection;
40
40
  styles: Record<string, string>;
41
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
41
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
42
42
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
43
43
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
44
44
  /** A volume lane in the timeline drives the level; the slider cannot. */
@@ -35,7 +35,7 @@ export function FlatRow({
35
35
  /** Renders a trailing 10px caret-down, for select-backed rows. */
36
36
  dropdown?: boolean;
37
37
  onPreview?: (nextValue: string) => void;
38
- onCommit: (nextValue: string) => void | Promise<void>;
38
+ onCommit: (nextValue: string) => void | Promise<unknown>;
39
39
  onReset?: () => void;
40
40
  }) {
41
41
  const track = useTrackDesignInput();
@@ -50,7 +50,7 @@ function FlatFillFields({
50
50
  element: DomEditSelection;
51
51
  styles: Record<string, string>;
52
52
  assets: string[];
53
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
53
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
54
54
  onPreviewStyle?: (prop: string, value: string) => void;
55
55
  onImportAssets?: (files: FileList) => Promise<string[]>;
56
56
  }) {
@@ -156,7 +156,7 @@ function FlatStrokeRow({
156
156
  }: {
157
157
  styles: Record<string, string>;
158
158
  disabled: boolean;
159
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
159
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
160
160
  }) {
161
161
  const borderWidthValue =
162
162
  parsePxMetricValue(styles["border-width"] ?? "") ??
@@ -236,7 +236,7 @@ function FlatRadiusRow({
236
236
  styles: Record<string, string>;
237
237
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
238
238
  disabled: boolean;
239
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
239
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
240
240
  }) {
241
241
  const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
242
242
  const radiusTL =
@@ -286,7 +286,7 @@ function FlatShadowBlendRows({
286
286
  }: {
287
287
  styles: Record<string, string>;
288
288
  disabled: boolean;
289
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
289
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
290
290
  }) {
291
291
  const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]);
292
292
  const blendValue = styles["mix-blend-mode"] || "normal";
@@ -332,7 +332,7 @@ function FlatBlurSliders({
332
332
  }: {
333
333
  styles: Record<string, string>;
334
334
  disabled: boolean;
335
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
335
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
336
336
  }) {
337
337
  const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur");
338
338
  const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
@@ -378,7 +378,7 @@ function FlatOverflowMaskRows({
378
378
  }: {
379
379
  styles: Record<string, string>;
380
380
  disabled: boolean;
381
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
381
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
382
382
  }) {
383
383
  const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
384
384
  const clipPathValue = styles["clip-path"] || "none";
@@ -432,7 +432,7 @@ function FlatOpacitySlider({
432
432
  }: {
433
433
  styles: Record<string, string>;
434
434
  disabled: boolean;
435
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
435
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
436
436
  }) {
437
437
  const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
438
438
 
@@ -464,7 +464,7 @@ export function FlatStyleSection({
464
464
  element: DomEditSelection;
465
465
  styles: Record<string, string>;
466
466
  assets: string[];
467
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
467
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
468
468
  onPreviewStyle?: (prop: string, value: string) => void;
469
469
  onImportAssets?: (files: FileList) => Promise<string[]>;
470
470
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
@@ -35,7 +35,7 @@ export function MediaSection({
35
35
  projectDir: string | null;
36
36
  element: DomEditSelection;
37
37
  styles: Record<string, string>;
38
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
38
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
39
39
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
40
40
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
41
41
  onRemoveBackground?: (
@@ -29,7 +29,7 @@ export function MetricField({
29
29
  scrub?: boolean;
30
30
  suffix?: string;
31
31
  tooltip?: string;
32
- onCommit: (nextValue: string) => void | Promise<void>;
32
+ onCommit: (nextValue: string) => void | Promise<unknown>;
33
33
  }) {
34
34
  const track = useTrackDesignInput();
35
35
  const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
@@ -53,7 +53,7 @@ export function StyleSections({
53
53
  element: DomEditSelection;
54
54
  styles: Record<string, string>;
55
55
  assets: string[];
56
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
56
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
57
57
  onImportAssets?: (files: FileList) => Promise<string[]>;
58
58
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
59
59
  // When true, the Flex `Section` is suppressed. The flat inspector renders
@@ -44,7 +44,7 @@ export interface PropertyPanelProps {
44
44
  copiedAgentPrompt: boolean;
45
45
  onClearSelection: () => void;
46
46
  onUngroup?: () => void;
47
- onSetStyle: (prop: string, value: string) => void | Promise<void>;
47
+ onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
48
48
  onPreviewStyle?: (prop: string, value: string) => void;
49
49
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
50
50
  /** Commits several data-* attributes on the SAME element in ONE atomic
@@ -58,6 +58,12 @@ import {
58
58
  import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDebug";
59
59
  import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
60
60
  import { createGroupDragMover } from "./groupDragMove";
61
+ import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
62
+
63
+ function logGestureCommitFailure(message: string, error: unknown): void {
64
+ if (error instanceof DomEditSaveQueueOpenError) return;
65
+ console.error(message, error);
66
+ }
61
67
 
62
68
  export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
63
69
  const setDraftOverlayRect = (next: OverlayRect) => {
@@ -409,7 +415,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
409
415
  }
410
416
  void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
411
417
  .catch((error) => {
412
- console.error("rotate commit failed", error);
418
+ logGestureCommitFailure("rotate commit failed", error);
413
419
  if (
414
420
  g.manualEditDragToken &&
415
421
  isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
@@ -493,7 +499,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
493
499
  opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
494
500
  )
495
501
  .catch((error) => {
496
- console.error("resize commit failed", error);
502
+ logGestureCommitFailure("resize commit failed", error);
497
503
  })
498
504
  .finally(() => {
499
505
  if (member) endManualOffsetDragMembers([member]);
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
- function isPromiseCommit(result: void | Promise<void>): result is Promise<void> {
3
+ function isPromiseCommit(result: void | Promise<unknown>): result is Promise<unknown> {
4
4
  return Boolean(result && typeof result.then === "function");
5
5
  }
6
6
 
@@ -12,7 +12,7 @@ export function useInspectorGestureTransaction<T>({
12
12
  }: {
13
13
  sourceValue: T;
14
14
  onPreview: (value: T) => void;
15
- onCommit: (value: T) => void | Promise<void>;
15
+ onCommit: (value: T) => void | Promise<unknown>;
16
16
  }) {
17
17
  const sourceRef = useRef(sourceValue);
18
18
  const activeRef = useRef<{ before: T; latest: T } | null>(null);
@@ -122,7 +122,7 @@ export function useInspectorGestureDraft<T>({
122
122
  }: {
123
123
  sourceValue: T;
124
124
  onPreview: (value: T) => void;
125
- onCommit: (value: T) => void | Promise<void>;
125
+ onCommit: (value: T) => void | Promise<unknown>;
126
126
  }) {
127
127
  const [draft, setDraft] = useState(sourceValue);
128
128
  const transaction = useInspectorGestureTransaction({
@@ -1,4 +1,5 @@
1
1
  import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { classifyWebAudioMediaRoute } from "@hyperframes/core/runtime/web-audio-route";
2
3
  import { ContextMenu } from "./AssetContextMenu";
3
4
  import { basename, getAudioSubtype, type CopyFeedback } from "./assetHelpers";
4
5
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
@@ -167,14 +168,24 @@ export function AudioRow({
167
168
  setPlaying(false);
168
169
  cancelAnimationFrame(animRef.current);
169
170
  };
171
+ // `src` must be set BEFORE classifying: the check reads `currentSrc`/
172
+ // `src`, and a same-origin `serveUrl` mustn't be judged from a blank
173
+ // element.
174
+ el.src = serveUrl;
170
175
  audioRef.current = el;
171
176
  const analyser = analyserRef.current;
172
- if (analyser) {
177
+ // Same hazard `webAudioRoute.ts` documents for the timeline runtime
178
+ // (#3458): `createMediaElementSource` on a cross-origin element without
179
+ // a `crossorigin` opt-in permanently reroutes it to a node that outputs
180
+ // SILENCE per the Web Audio spec, without throwing. Classify first so
181
+ // this preview player can't reintroduce that bug — skipping the Web
182
+ // Audio graph here only costs the frequency-bar visualizer; native
183
+ // `<audio>` playback below stays audible either way.
184
+ if (analyser && classifyWebAudioMediaRoute(el).kind === "web-audio") {
173
185
  sourceRef.current = actxRef.current.createMediaElementSource(el);
174
186
  sourceRef.current.connect(analyser);
175
187
  analyser.connect(actxRef.current.destination);
176
188
  }
177
- el.src = serveUrl;
178
189
  }
179
190
 
180
191
  if (actxRef.current.state === "suspended") await actxRef.current.resume();
@@ -55,3 +55,50 @@ export async function runDomEditCommit(config: DomEditCommitRunnerConfig): Promi
55
55
  if (!config.shouldResync()) return;
56
56
  await config.resync();
57
57
  }
58
+
59
+ /**
60
+ * Why a DOM edit commit did not change the file.
61
+ *
62
+ * `runDomEditCommit` resolves on persist failure by design (see its contract
63
+ * above), so a caller cannot learn whether the write landed by awaiting it — a
64
+ * failed persist and a successful one are indistinguishable. Capture and apply
65
+ * bugs still reject. The human path does not need to ask about handled persist
66
+ * failures, because `onError` already put a toast on screen. A programmatic
67
+ * caller has no screen, so it has to be told.
68
+ */
69
+ export type DomEditCommitDeclineReason =
70
+ | "no-project"
71
+ | "no-selection"
72
+ | "geometry-property"
73
+ | "styles-not-editable"
74
+ | "not-text-editable"
75
+ | "preview-stale"
76
+ | "persist-failed";
77
+
78
+ export type DomEditCommitOutcome = { ok: true } | { ok: false; reason: DomEditCommitDeclineReason };
79
+
80
+ export function domEditCommitDeclined(reason: DomEditCommitDeclineReason): DomEditCommitOutcome {
81
+ return { ok: false, reason };
82
+ }
83
+
84
+ /**
85
+ * `runDomEditCommit`, reporting whether the write actually landed.
86
+ *
87
+ * Owns `onSettled` to do it, and forwards to a caller-supplied one rather than
88
+ * dropping it. `runDomEditCommit` calls `onSettled` exactly once on both the
89
+ * success and the failure path, so the flag is always set by the time it
90
+ * resolves.
91
+ */
92
+ export async function runReportedDomEditCommit(
93
+ config: DomEditCommitRunnerConfig,
94
+ ): Promise<DomEditCommitOutcome> {
95
+ let landed = false;
96
+ await runDomEditCommit({
97
+ ...config,
98
+ onSettled: (ok) => {
99
+ landed = ok;
100
+ config.onSettled?.(ok);
101
+ },
102
+ });
103
+ return landed ? { ok: true } : domEditCommitDeclined("persist-failed");
104
+ }
@@ -0,0 +1,116 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
6
+ import { mountReactHarness } from "./domSelectionTestHarness";
7
+ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
8
+
9
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
+
11
+ let cleanup: (() => void) | null = null;
12
+
13
+ function selectionStub(): DomEditSelection {
14
+ const element = document.createElement("div");
15
+ element.id = "card";
16
+ return {
17
+ id: "card",
18
+ element,
19
+ label: "Card",
20
+ tagName: "div",
21
+ sourceFile: "index.html",
22
+ compositionPath: "index.html",
23
+ isCompositionHost: false,
24
+ isInsideLockedComposition: false,
25
+ boundingBox: { x: 0, y: 0, width: 100, height: 100 },
26
+ textContent: null,
27
+ dataAttributes: {},
28
+ inlineStyles: {},
29
+ computedStyles: {},
30
+ textFields: [],
31
+ capabilities: {
32
+ canSelect: true,
33
+ canEditStyles: true,
34
+ canCrop: true,
35
+ canMove: true,
36
+ canResize: true,
37
+ canApplyManualOffset: true,
38
+ canApplyManualSize: true,
39
+ canApplyManualRotation: true,
40
+ },
41
+ };
42
+ }
43
+
44
+ function renderCommit(params: Parameters<typeof useDomEditPositionPatchCommit>[0]) {
45
+ const captured: { commit: ReturnType<typeof useDomEditPositionPatchCommit> | null } = {
46
+ commit: null,
47
+ };
48
+ function Probe() {
49
+ captured.commit = useDomEditPositionPatchCommit(params);
50
+ return null;
51
+ }
52
+ const root = mountReactHarness(<Probe />);
53
+ cleanup = () => act(() => root.unmount());
54
+ if (!captured.commit) throw new Error("hook did not initialize");
55
+ return captured.commit;
56
+ }
57
+
58
+ function paramsWith(queueDomEditSave: (save: () => Promise<void>) => Promise<void>) {
59
+ const showToast = vi.fn();
60
+ return {
61
+ showToast,
62
+ params: {
63
+ activeCompPath: "index.html",
64
+ persistDomEditOperations: vi.fn().mockResolvedValue(undefined),
65
+ queueDomEditSave,
66
+ showToast,
67
+ },
68
+ };
69
+ }
70
+
71
+ const options = { label: "Move layer", coalesceKey: "path-offset:card" };
72
+
73
+ afterEach(() => {
74
+ cleanup?.();
75
+ cleanup = null;
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ describe("useDomEditPositionPatchCommit", () => {
80
+ it("rejects when the save queue is paused, so the caller can revert its optimistic change", async () => {
81
+ const { showToast, params } = paramsWith(() => Promise.reject(new DomEditSaveQueueOpenError()));
82
+ const commit = renderCommit(params);
83
+
84
+ await act(async () => {
85
+ await expect(commit(selectionStub(), [], options)).rejects.toBeInstanceOf(
86
+ DomEditSaveQueueOpenError,
87
+ );
88
+ });
89
+
90
+ // No toast: the paused-save banner already tells the human, and one toast per
91
+ // blocked edit is what the original swallow existed to prevent.
92
+ expect(showToast).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("toasts and rejects on an ordinary save failure", async () => {
96
+ const { showToast, params } = paramsWith(() => Promise.reject(new Error("server said no")));
97
+ const commit = renderCommit(params);
98
+
99
+ await act(async () => {
100
+ await expect(commit(selectionStub(), [], options)).rejects.toThrow("server said no");
101
+ });
102
+
103
+ expect(showToast).toHaveBeenCalledWith("server said no");
104
+ });
105
+
106
+ it("resolves when the write lands", async () => {
107
+ const { showToast, params } = paramsWith((save) => save());
108
+ const commit = renderCommit(params);
109
+
110
+ await act(async () => {
111
+ await expect(commit(selectionStub(), [], options)).resolves.toBeUndefined();
112
+ });
113
+
114
+ expect(showToast).not.toHaveBeenCalled();
115
+ });
116
+ });
@@ -35,7 +35,12 @@ export function useDomEditPositionPatchCommit({
35
35
  skipRefresh: options.skipRefresh ?? true,
36
36
  });
37
37
  }).catch((error) => {
38
- if (error instanceof DomEditSaveQueueOpenError) return;
38
+ // A paused save queue is not worth a toast: the paused-save banner is
39
+ // already on screen, and one toast per blocked edit is what this branch
40
+ // exists to prevent. It still has to REJECT, though. Swallowing it
41
+ // resolved the commit, which skipped the caller's revert, so the element
42
+ // stayed where the drag put it while nothing reached the file.
43
+ if (error instanceof DomEditSaveQueueOpenError) throw error;
39
44
  showToast(error instanceof Error ? error.message : "Failed to save position");
40
45
  trackStudioSaveFailure({
41
46
  source: "dom_edit",