@hyperframes/studio 0.8.16 → 0.8.18

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 (55) hide show
  1. package/dist/assets/{hyperframes-player-iAIHATIw.js → hyperframes-player-DzRNJZAz.js} +1 -1
  2. package/dist/assets/{index-Cf-mbMRL.js → index-B4qse6wy.js} +1 -1
  3. package/dist/assets/index-BX3KHhGX.js +71 -0
  4. package/dist/assets/{index-D8o3ZIo2.js → index-F-PUkOVc.js} +128 -128
  5. package/dist/assets/{index-YmetcS6L.js → index-SGl0bb71.js} +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +1640 -1282
  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/nle/useCompositionStack.test.tsx +69 -0
  30. package/src/components/nle/useCompositionStack.ts +8 -2
  31. package/src/hooks/domEditCommitRunner.ts +47 -0
  32. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
  33. package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
  34. package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
  35. package/src/hooks/useDomEditTextCommits.ts +17 -9
  36. package/src/hooks/useDomEditWiring.ts +1 -1
  37. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  38. package/src/hooks/useDomGeometryCommits.ts +10 -2
  39. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
  40. package/src/hooks/useElementLifecycleOps.ts +11 -5
  41. package/src/hooks/useGsapSelectionHandlers.ts +4 -2
  42. package/src/utils/studioUiPreferences.ts +11 -0
  43. package/src/webmcp/StudioAgentTools.tsx +46 -0
  44. package/src/webmcp/handles.test.ts +130 -0
  45. package/src/webmcp/handles.ts +129 -0
  46. package/src/webmcp/polyfill.test.ts +98 -0
  47. package/src/webmcp/polyfill.ts +60 -0
  48. package/src/webmcp/registrar.test.ts +150 -0
  49. package/src/webmcp/registrar.ts +115 -0
  50. package/src/webmcp/toolResult.ts +67 -0
  51. package/src/webmcp/tools/lookTools.test.ts +225 -0
  52. package/src/webmcp/tools/lookTools.ts +201 -0
  53. package/src/webmcp/types.ts +90 -0
  54. package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
  55. package/src/webmcp/useStudioAgentTools.ts +119 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.16",
3
+ "version": "0.8.18",
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.16",
51
- "@hyperframes/parsers": "0.8.16",
52
- "@hyperframes/player": "0.8.16",
53
- "@hyperframes/studio-server": "0.8.16",
54
- "@hyperframes/sdk": "0.8.16"
51
+ "@hyperframes/core": "0.8.18",
52
+ "@hyperframes/parsers": "0.8.18",
53
+ "@hyperframes/player": "0.8.18",
54
+ "@hyperframes/sdk": "0.8.18",
55
+ "@hyperframes/studio-server": "0.8.18"
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.16"
72
+ "@hyperframes/producer": "0.8.18"
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({
@@ -42,3 +42,72 @@ describe("useCompositionStack — project scoping", () => {
42
42
  });
43
43
  }
44
44
  });
45
+
46
+ describe("useCompositionStack — activating a composition by path", () => {
47
+ afterEach(() => {
48
+ document.body.innerHTML = "";
49
+ });
50
+
51
+ function mountStack(activeCompositionPath: string | null) {
52
+ const host = document.createElement("div");
53
+ document.body.append(host);
54
+ const root = createRoot(host);
55
+ const seen: { stack: ReturnType<typeof useCompositionStack>["compositionStack"] } = {
56
+ stack: [],
57
+ };
58
+
59
+ function Harness(props: { activeCompositionPath: string | null }) {
60
+ seen.stack = useCompositionStack({ projectId: "p", ...props }).compositionStack;
61
+ return null;
62
+ }
63
+
64
+ return { root, seen, Harness, activeCompositionPath };
65
+ }
66
+
67
+ // The root stays on the master level; everything else pushes a second level.
68
+ for (const path of [
69
+ "compositions/scene-a.html",
70
+ "parts/part-1.html",
71
+ "chapter-2.html",
72
+ "a/b/c/deep.html",
73
+ ]) {
74
+ it(`pushes a level for ${path}`, async () => {
75
+ const { root, seen, Harness } = mountStack(path);
76
+
77
+ await act(async () => {
78
+ root.render(<Harness activeCompositionPath={path} />);
79
+ });
80
+
81
+ expect(seen.stack).toHaveLength(2);
82
+ expect(seen.stack[1]?.id).toBe(path);
83
+ expect(seen.stack[1]?.previewUrl).toBe(`/api/projects/p/preview/comp/${path}`);
84
+
85
+ act(() => root.unmount());
86
+ });
87
+ }
88
+
89
+ it("labels a non-compositions/ path without mangling it", async () => {
90
+ const { root, seen, Harness } = mountStack("parts/part-1.html");
91
+
92
+ await act(async () => {
93
+ root.render(<Harness activeCompositionPath="parts/part-1.html" />);
94
+ });
95
+
96
+ expect(seen.stack[1]?.label).toBe("parts/part-1");
97
+
98
+ act(() => root.unmount());
99
+ });
100
+
101
+ it("keeps the master alone for the root composition", async () => {
102
+ const { root, seen, Harness } = mountStack("index.html");
103
+
104
+ await act(async () => {
105
+ root.render(<Harness activeCompositionPath="index.html" />);
106
+ });
107
+
108
+ expect(seen.stack).toHaveLength(1);
109
+ expect(seen.stack[0]?.id).toBe("master");
110
+
111
+ act(() => root.unmount());
112
+ });
113
+ });
@@ -108,7 +108,13 @@ export function useCompositionStack({
108
108
  if (activeCompositionPath === "index.html") {
109
109
  usePlayerStore.getState().setElements([]);
110
110
  updateCompositionStack([master]);
111
- } else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
111
+ } else if (activeCompositionPath) {
112
+ // Any composition file that isn't the root, wherever it lives. Gating
113
+ // this on a `compositions/` prefix meant a project laying its comps out
114
+ // anywhere else (`parts/part-1.html`, generated multi-part builds) hit
115
+ // no branch at all: the stack kept the master mounted while the Comps
116
+ // panel highlighted the row, so the canvas and timeline stayed on
117
+ // index.html and edits landed in the root file.
112
118
  const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
113
119
  const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(activeCompositionPath)}`;
114
120
  usePlayerStore.getState().setElements([]);
@@ -116,7 +122,7 @@ export function useCompositionStack({
116
122
  if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
117
123
  return [master, { id: activeCompositionPath, label, previewUrl }];
118
124
  });
119
- } else if (!activeCompositionPath) {
125
+ } else {
120
126
  usePlayerStore.getState().setElements([]);
121
127
  updateCompositionStack([master]);
122
128
  }
@@ -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
+ }