@hyperframes/studio 0.8.23 → 0.8.25

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 (90) hide show
  1. package/dist/assets/{hyperframes-player-oZ6g4tHJ.js → hyperframes-player-CTP_00ix.js} +1 -1
  2. package/dist/assets/{index-UyvIa_cE.js → index-Bjb4YqS_.js} +1 -1
  3. package/dist/assets/{index-Tiu74FPg.js → index-DSk_ALFr.js} +218 -218
  4. package/dist/assets/{index-DW-H7Y2b.js → index-Di-8Jxqc.js} +1 -1
  5. package/dist/assets/index-DnRfAiK2.css +1 -0
  6. package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
  7. package/dist/chunk-A7S5SGNA.js.map +1 -0
  8. package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6206 -5011
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -7
  14. package/src/components/editor/TopologyLens.test.tsx +294 -0
  15. package/src/components/editor/TopologyLens.tsx +206 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  17. package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
  18. package/src/components/editor/domEditingLayers.test.ts +9 -0
  19. package/src/components/editor/domEditingLayers.ts +8 -8
  20. package/src/components/editor/topologyLensGeometry.test.ts +44 -0
  21. package/src/components/editor/topologyLensGeometry.ts +119 -0
  22. package/src/components/editor/topologyLensState.test.ts +116 -0
  23. package/src/components/editor/topologyLensState.ts +72 -0
  24. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
  25. package/src/components/nle/PreviewOverlays.tsx +24 -18
  26. package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
  27. package/src/components/sidebar/CompositionsTab.tsx +10 -3
  28. package/src/components/ui/HyperframesLoader.tsx +3 -48
  29. package/src/components/ui/HyperframesMark.tsx +53 -0
  30. package/src/contexts/DomEditContext.tsx +12 -0
  31. package/src/hooks/domEditCommitRunner.test.ts +59 -0
  32. package/src/hooks/domEditCommitRunner.ts +55 -20
  33. package/src/hooks/domEditCommitTypes.ts +8 -1
  34. package/src/hooks/domEditTextCommitPlan.ts +47 -0
  35. package/src/hooks/useDomEditAttributeCommits.ts +4 -1
  36. package/src/hooks/useDomEditCommits.test.tsx +207 -8
  37. package/src/hooks/useDomEditCommits.ts +54 -14
  38. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  40. package/src/hooks/useDomEditSession.ts +6 -2
  41. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  42. package/src/hooks/useDomEditTextCommits.ts +64 -86
  43. package/src/hooks/useDomEditWiring.ts +1 -1
  44. package/src/hooks/useDomSelection.ts +2 -0
  45. package/src/hooks/useDomSelectionTypes.ts +2 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  48. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  50. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  51. package/src/hooks/useGsapAnimationOps.ts +4 -4
  52. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  53. package/src/hooks/useGsapAwareEditing.ts +31 -10
  54. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  55. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  56. package/src/hooks/useRenderClipContent.test.ts +27 -0
  57. package/src/hooks/useRenderClipContent.ts +5 -0
  58. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  59. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  60. package/src/player/components/CompositionThumbnail.tsx +6 -0
  61. package/src/player/store/playerStore.test.ts +24 -0
  62. package/src/player/store/thumbnailSlice.ts +6 -0
  63. package/src/styles/studio.css +179 -0
  64. package/src/utils/domEditSaveQueue.test.ts +5 -3
  65. package/src/utils/domEditSaveQueue.ts +6 -1
  66. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  67. package/src/webmcp/StudioAgentTools.tsx +89 -21
  68. package/src/webmcp/handles.test.ts +172 -15
  69. package/src/webmcp/handles.ts +152 -35
  70. package/src/webmcp/tools/animationTools.test.ts +241 -60
  71. package/src/webmcp/tools/animationTools.ts +191 -104
  72. package/src/webmcp/tools/contentTools.test.ts +103 -41
  73. package/src/webmcp/tools/contentTools.ts +149 -105
  74. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  75. package/src/webmcp/tools/inspectTools.ts +59 -19
  76. package/src/webmcp/tools/lookTools.test.ts +185 -38
  77. package/src/webmcp/tools/lookTools.ts +134 -21
  78. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  79. package/src/webmcp/tools/selectionTools.ts +34 -19
  80. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  81. package/src/webmcp/tools/transformTools.test.ts +140 -54
  82. package/src/webmcp/tools/transformTools.ts +215 -56
  83. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  84. package/src/webmcp/useStudioAgentTools.ts +27 -15
  85. package/src/webmcp/webmcpTestUtils.ts +37 -0
  86. package/src/webmcp/writeCoordinator.test.ts +375 -0
  87. package/src/webmcp/writeCoordinator.ts +473 -0
  88. package/dist/assets/index-yGhfxxoL.css +0 -1
  89. package/dist/chunk-6BT6DTB4.js.map +0 -1
  90. /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
@@ -73,7 +73,7 @@ describe("buildCompositionThumbnailUrl", () => {
73
73
  origin: "http://localhost:3000",
74
74
  }),
75
75
  ).toBe(
76
- "http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&selector=.card&selectorIndex=2",
76
+ "http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&revision=0&selector=.card&selectorIndex=2",
77
77
  );
78
78
  });
79
79
 
@@ -91,6 +91,16 @@ describe("buildCompositionThumbnailUrl", () => {
91
91
  "output=storyboard",
92
92
  );
93
93
  });
94
+
95
+ it("includes the persisted content revision in the cache identity", () => {
96
+ const url = buildCompositionThumbnailUrl({
97
+ previewUrl: "/api/projects/demo/preview",
98
+ origin: "http://localhost:3000",
99
+ contentRevision: 7,
100
+ });
101
+
102
+ expect(new URL(url).searchParams.get("revision")).toBe("7");
103
+ });
94
104
  });
95
105
 
96
106
  describe("CompositionThumbnail", () => {
@@ -148,4 +158,59 @@ describe("CompositionThumbnail", () => {
148
158
  expect(probe.src).toBe("");
149
159
  expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:composition-thumbnail");
150
160
  });
161
+
162
+ it("releases the old request and ignores its late result when persisted content changes", async () => {
163
+ const signals: AbortSignal[] = [];
164
+ const resolveFetches: Array<(response: Response) => void> = [];
165
+ globalThis.fetch = vi.fn((_url, init) => {
166
+ signals.push(init?.signal as AbortSignal);
167
+ return new Promise<Response>((resolve) => resolveFetches.push(resolve));
168
+ });
169
+ root = createRoot(host);
170
+
171
+ await act(async () => {
172
+ root!.render(
173
+ React.createElement(CompositionThumbnail, {
174
+ previewUrl: "/api/projects/demo/preview",
175
+ label: "",
176
+ labelColor: "#fff",
177
+ projectId: "demo",
178
+ contentRevision: 0,
179
+ }),
180
+ );
181
+ await new Promise((resolve) => setTimeout(resolve, 0));
182
+ });
183
+ await act(async () => {
184
+ root!.render(
185
+ React.createElement(CompositionThumbnail, {
186
+ previewUrl: "/api/projects/demo/preview",
187
+ label: "",
188
+ labelColor: "#fff",
189
+ projectId: "demo",
190
+ contentRevision: 1,
191
+ }),
192
+ );
193
+ await new Promise((resolve) => setTimeout(resolve, 0));
194
+ });
195
+
196
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
197
+ expect(signals[0]?.aborted).toBe(true);
198
+ expect(signals[1]?.aborted).toBe(false);
199
+ expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[1]?.[0]).toContain(
200
+ "revision=1",
201
+ );
202
+
203
+ await act(async () => {
204
+ resolveFetches[0]?.(new Response(new Blob(["stale"]), { status: 200 }));
205
+ await new Promise((resolve) => setTimeout(resolve, 0));
206
+ });
207
+ expect(MockImage.instances).toHaveLength(0);
208
+
209
+ await act(async () => {
210
+ resolveFetches[1]?.(new Response(new Blob(["fresh"]), { status: 200 }));
211
+ await new Promise((resolve) => setTimeout(resolve, 0));
212
+ });
213
+ expect(MockImage.instances).toHaveLength(1);
214
+ expect(MockImage.instances[0]?.src).toBe("blob:composition-thumbnail");
215
+ });
151
216
  });
@@ -17,6 +17,7 @@ interface CompositionThumbnailProps {
17
17
  height?: number;
18
18
  projectId?: string;
19
19
  sessionEpoch?: number;
20
+ contentRevision?: number;
20
21
  priority?: ThumbnailPriority;
21
22
  rich?: boolean;
22
23
  }
@@ -32,6 +33,7 @@ export function buildCompositionThumbnailUrl({
32
33
  selectorIndex,
33
34
  origin,
34
35
  output,
36
+ contentRevision = 0,
35
37
  }: {
36
38
  previewUrl: string;
37
39
  seekTime?: number;
@@ -46,6 +48,7 @@ export function buildCompositionThumbnailUrl({
46
48
  * high-density review size; `"source"` uses the composition's own dimensions.
47
49
  */
48
50
  output?: "source" | "storyboard";
51
+ contentRevision?: number;
49
52
  }): string {
50
53
  const thumbnailBase = previewUrl
51
54
  .replace("/preview/comp/", "/thumbnail/")
@@ -53,6 +56,7 @@ export function buildCompositionThumbnailUrl({
53
56
  const thumbnailUrl = new URL(thumbnailBase, origin);
54
57
  thumbnailUrl.searchParams.set("t", (seekTime + duration / 2).toFixed(2));
55
58
  thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
59
+ thumbnailUrl.searchParams.set("revision", String(contentRevision));
56
60
  if (output) thumbnailUrl.searchParams.set("output", output);
57
61
  if (selector) {
58
62
  thumbnailUrl.searchParams.set("selector", selector);
@@ -96,6 +100,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
96
100
  duration = 5,
97
101
  projectId = previewUrl,
98
102
  sessionEpoch = 0,
103
+ contentRevision = 0,
99
104
  priority = "visible",
100
105
  }: CompositionThumbnailProps) {
101
106
  const [containerWidth, setContainerWidth] = useState(0);
@@ -107,6 +112,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
107
112
  selector,
108
113
  selectorIndex,
109
114
  origin: window.location.origin,
115
+ contentRevision,
110
116
  });
111
117
  const request = useMemo(
112
118
  () => ({
@@ -30,6 +30,30 @@ describe("usePlayerStore", () => {
30
30
  });
31
31
  });
32
32
 
33
+ describe("thumbnail content revision", () => {
34
+ it("advances once per accepted persisted file event", () => {
35
+ const before = usePlayerStore.getState().thumbnailContentRevision;
36
+
37
+ usePlayerStore.getState().bumpThumbnailContentRevision();
38
+
39
+ expect(usePlayerStore.getState().thumbnailContentRevision).toBe(before + 1);
40
+ });
41
+
42
+ it("remains monotonic across soft resets while project identity stays with the session epoch", () => {
43
+ const store = usePlayerStore.getState();
44
+ store.beginTimelineSession("project-a");
45
+ store.bumpThumbnailContentRevision();
46
+ const revision = usePlayerStore.getState().thumbnailContentRevision;
47
+ const firstEpoch = usePlayerStore.getState().timelineSessionEpoch;
48
+
49
+ store.reset();
50
+ expect(usePlayerStore.getState().thumbnailContentRevision).toBe(revision);
51
+ store.beginTimelineSession("project-b");
52
+ expect(usePlayerStore.getState().thumbnailContentRevision).toBe(revision);
53
+ expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch + 1);
54
+ });
55
+ });
56
+
33
57
  describe("expandedClipIds", () => {
34
58
  it("toggles clip membership", () => {
35
59
  const store = usePlayerStore.getState();
@@ -4,15 +4,21 @@ import { defaultThumbnailMode, type ThumbnailMode } from "../lib/thumbnailPolicy
4
4
 
5
5
  export interface ThumbnailSlice {
6
6
  thumbnailMode: ThumbnailMode;
7
+ /** Monotonic identity for persisted project content shown by mounted thumbnails. */
8
+ thumbnailContentRevision: number;
7
9
  setThumbnailMode: (mode: ThumbnailMode) => void;
10
+ bumpThumbnailContentRevision: () => void;
8
11
  }
9
12
 
10
13
  export function createThumbnailSlice(set: StoreApi<ThumbnailSlice>["setState"]): ThumbnailSlice {
11
14
  return {
12
15
  thumbnailMode: defaultThumbnailMode(readStudioUiPreferences().thumbnailMode),
16
+ thumbnailContentRevision: 0,
13
17
  setThumbnailMode: (mode) => {
14
18
  writeStudioUiPreferences({ thumbnailMode: mode });
15
19
  set({ thumbnailMode: mode });
16
20
  },
21
+ bumpThumbnailContentRevision: () =>
22
+ set((state) => ({ thumbnailContentRevision: state.thumbnailContentRevision + 1 })),
17
23
  };
18
24
  }
@@ -618,6 +618,185 @@ body {
618
618
  }
619
619
  }
620
620
 
621
+ /* Receipt-driven WebMCP Topology Lens. Every animation is one-shot Studio
622
+ chrome; none of these nodes enter the composition iframe or rendered output. */
623
+ .hf-topology-contour {
624
+ border: 1px solid rgba(60, 230, 172, 0.62);
625
+ box-shadow:
626
+ 0 0 0 1px rgba(6, 227, 250, 0.08),
627
+ inset 0 0 18px rgba(60, 230, 172, 0.035);
628
+ }
629
+
630
+ .hf-topology-field::before,
631
+ .hf-topology-contour::before,
632
+ .hf-topology-target::before {
633
+ content: attr(data-topology-node);
634
+ position: absolute;
635
+ top: 2px;
636
+ left: 3px;
637
+ z-index: 2;
638
+ padding: 1px 3px;
639
+ border-radius: 2px;
640
+ background: rgba(5, 15, 12, 0.86);
641
+ color: rgb(90, 247, 193);
642
+ font:
643
+ 600 7px/1.3 ui-monospace,
644
+ SFMono-Regular,
645
+ Menlo,
646
+ Monaco,
647
+ Consolas,
648
+ monospace;
649
+ letter-spacing: 0.04em;
650
+ text-transform: lowercase;
651
+ }
652
+
653
+ .hf-topology-field {
654
+ z-index: 0;
655
+ border: 1px solid rgba(60, 230, 172, 0.28);
656
+ background: linear-gradient(
657
+ 90deg,
658
+ rgba(60, 230, 172, 0.025),
659
+ rgba(60, 230, 172, 0.1) 68%,
660
+ rgba(6, 227, 250, 0.06)
661
+ );
662
+ box-shadow: inset 0 0 28px rgba(60, 230, 172, 0.05);
663
+ }
664
+
665
+ .hf-topology-target {
666
+ border: 1px solid rgba(60, 230, 172, 0.82);
667
+ box-shadow:
668
+ 0 0 0 1px rgba(60, 230, 172, 0.14),
669
+ 0 0 18px rgba(6, 227, 250, 0.09);
670
+ }
671
+
672
+ .hf-topology-scan {
673
+ background: linear-gradient(
674
+ 90deg,
675
+ transparent,
676
+ rgba(60, 230, 172, 0.16) 55%,
677
+ rgba(230, 255, 247, 0.9)
678
+ );
679
+ clip-path: polygon(0 0, 100% 0, 72% 100%, 0 100%);
680
+ }
681
+
682
+ .hf-topology-seal {
683
+ filter: drop-shadow(0 0 6px rgba(60, 230, 172, 0.36));
684
+ }
685
+
686
+ @media (prefers-reduced-motion: no-preference) {
687
+ .hf-topology-contour {
688
+ animation: hf-topology-reveal 176ms cubic-bezier(0.77, 0, 0.175, 1) both;
689
+ }
690
+
691
+ .hf-topology-field[data-topology-phase="acquiring"] {
692
+ animation: hf-topology-reveal 240ms cubic-bezier(0.77, 0, 0.175, 1) both;
693
+ }
694
+
695
+ .hf-topology-scan {
696
+ animation: hf-topology-scan 224ms cubic-bezier(0.77, 0, 0.175, 1) both;
697
+ }
698
+
699
+ .hf-topology-target[data-topology-phase="acquiring"] {
700
+ animation: hf-topology-localize 224ms cubic-bezier(0.23, 1, 0.32, 1) 72ms both;
701
+ }
702
+
703
+ .hf-topology-target[data-topology-phase="localizing"] {
704
+ animation: hf-topology-localize 176ms cubic-bezier(0.23, 1, 0.32, 1) both;
705
+ }
706
+
707
+ .hf-topology-target[data-topology-phase="sealing"] {
708
+ animation: hf-topology-localize 144ms cubic-bezier(0.23, 1, 0.32, 1) both;
709
+ }
710
+
711
+ .hf-topology-seal {
712
+ animation: hf-topology-seal 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
713
+ }
714
+
715
+ .hf-topology-seal path:first-of-type {
716
+ animation: hf-topology-seal-left 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
717
+ }
718
+
719
+ .hf-topology-seal path:nth-of-type(2) {
720
+ animation: hf-topology-seal-right 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
721
+ }
722
+ }
723
+
724
+ @keyframes hf-topology-reveal {
725
+ from {
726
+ opacity: 0;
727
+ clip-path: inset(0 100% 0 0);
728
+ }
729
+ to {
730
+ opacity: 1;
731
+ clip-path: inset(0);
732
+ }
733
+ }
734
+
735
+ @keyframes hf-topology-scan {
736
+ from {
737
+ opacity: 0;
738
+ transform: translate3d(-100%, 0, 0);
739
+ }
740
+ 16% {
741
+ opacity: 1;
742
+ }
743
+ to {
744
+ opacity: 0;
745
+ transform: translate3d(400%, 0, 0);
746
+ }
747
+ }
748
+
749
+ @keyframes hf-topology-localize {
750
+ from {
751
+ opacity: 0;
752
+ transform: scaleX(0.76);
753
+ }
754
+ to {
755
+ opacity: 1;
756
+ transform: scaleX(1);
757
+ }
758
+ }
759
+
760
+ @keyframes hf-topology-seal {
761
+ from {
762
+ opacity: 0;
763
+ transform: scale(0.88);
764
+ }
765
+ to {
766
+ opacity: 1;
767
+ transform: scale(1);
768
+ }
769
+ }
770
+
771
+ @keyframes hf-topology-seal-left {
772
+ from {
773
+ transform: translate3d(-9px, 0, 0);
774
+ }
775
+ to {
776
+ transform: translate3d(0, 0, 0);
777
+ }
778
+ }
779
+
780
+ @keyframes hf-topology-seal-right {
781
+ from {
782
+ transform: translate3d(9px, 0, 0);
783
+ }
784
+ to {
785
+ transform: translate3d(0, 0, 0);
786
+ }
787
+ }
788
+
789
+ @media (prefers-reduced-motion: reduce) {
790
+ .hf-topology-contour,
791
+ .hf-topology-field,
792
+ .hf-topology-target,
793
+ .hf-topology-seal,
794
+ .hf-topology-seal path {
795
+ animation: none;
796
+ transform: none;
797
+ }
798
+ }
799
+
621
800
  @keyframes hf-fx-preset-wave {
622
801
  0%,
623
802
  100% {
@@ -51,7 +51,7 @@ describe("dom edit save queue", () => {
51
51
  queue.destroy();
52
52
  });
53
53
 
54
- it("keeps an open breaker paused even when already queued work succeeds", async () => {
54
+ it("stops already queued work when the preceding save opens the breaker", async () => {
55
55
  const onOpen = vi.fn();
56
56
  const onReset = vi.fn();
57
57
  const queue = createDomEditSaveQueue({
@@ -72,13 +72,15 @@ describe("dom edit save queue", () => {
72
72
  resolveFirstStarted?.();
73
73
  }),
74
74
  );
75
- const second = queue.enqueue(async () => {});
75
+ const secondSave = vi.fn(async () => undefined);
76
+ const second = queue.enqueue(secondSave);
76
77
 
77
78
  await firstStarted;
78
79
  expect(rejectFirst).toBeTypeOf("function");
79
80
  rejectFirst?.(new StudioSaveHttpError("Server down", 503));
80
81
  await expect(first).rejects.toThrow("Server down");
81
- await expect(second).resolves.toBeUndefined();
82
+ await expect(second).rejects.toThrow("Auto-save is paused");
83
+ expect(secondSave).not.toHaveBeenCalled();
82
84
 
83
85
  expect(onOpen).toHaveBeenCalledOnce();
84
86
  expect(onReset).not.toHaveBeenCalled();
@@ -81,7 +81,12 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
81
81
  return {
82
82
  enqueue(save) {
83
83
  if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
84
- const queued = tail.catch(() => undefined).then(() => run(save));
84
+ const queued = tail
85
+ .catch(() => undefined)
86
+ .then(() => {
87
+ if (breakerOpen) throw new DomEditSaveQueueOpenError();
88
+ return run(save);
89
+ });
85
90
  tail = queued.then(
86
91
  () => undefined,
87
92
  () => undefined,
@@ -0,0 +1,70 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import { vi } from "vitest";
5
+ import { readLiveSelectionBox, resizeSelectionFromAgent } from "./StudioAgentTools";
6
+
7
+ function rect(width: number, height: number): DOMRect {
8
+ return { x: 10, y: 20, width, height } as DOMRect;
9
+ }
10
+
11
+ describe("readLiveSelectionBox", () => {
12
+ it("measures the replacement preview node after a commit reload", () => {
13
+ const oldElement = document.createElement("h1");
14
+ oldElement.id = "headline";
15
+ oldElement.getBoundingClientRect = () => rect(300, 50);
16
+ const selection = {
17
+ id: "headline",
18
+ sourceFile: "index.html",
19
+ element: oldElement,
20
+ } as DomEditSelection;
21
+
22
+ const liveDocument = document.implementation.createHTMLDocument();
23
+ const replacement = liveDocument.createElement("h1");
24
+ replacement.id = "headline";
25
+ replacement.getBoundingClientRect = () => rect(280, 60);
26
+ liveDocument.body.append(replacement);
27
+
28
+ expect(readLiveSelectionBox(liveDocument, selection, "index.html")).toEqual({
29
+ x: 10,
30
+ y: 20,
31
+ width: 280,
32
+ height: 60,
33
+ });
34
+ });
35
+
36
+ it("does not hide a stale target behind its detached old node", () => {
37
+ const oldElement = document.createElement("h1");
38
+ oldElement.id = "headline";
39
+ const selection = {
40
+ id: "headline",
41
+ sourceFile: "index.html",
42
+ element: oldElement,
43
+ } as DomEditSelection;
44
+ const liveDocument = document.implementation.createHTMLDocument();
45
+
46
+ expect(() => readLiveSelectionBox(liveDocument, selection, "index.html")).toThrow(
47
+ "target is missing",
48
+ );
49
+ });
50
+ });
51
+
52
+ describe("resizeSelectionFromAgent", () => {
53
+ it("applies the same live draft a pointer gesture shows before persistence", async () => {
54
+ const element = document.createElement("div");
55
+ element.style.width = "300px";
56
+ element.style.height = "50px";
57
+ const selection = { element } as DomEditSelection;
58
+ const commit = vi.fn(async (_selection, _next, _offset, restore: () => void) => {
59
+ expect(element.style.width).toBe("280px");
60
+ expect(element.style.height).toBe("60px");
61
+ restore();
62
+ });
63
+
64
+ await resizeSelectionFromAgent(selection, { width: 280, height: 60 }, commit);
65
+
66
+ expect(commit).toHaveBeenCalledOnce();
67
+ expect(element.style.width).toBe("300px");
68
+ expect(element.style.height).toBe("50px");
69
+ });
70
+ });
@@ -1,9 +1,49 @@
1
- import { useCallback, useMemo } from "react";
1
+ import { useCallback, useEffect, useMemo } from "react";
2
2
  import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
3
3
  import { useStudioShellContext } from "../contexts/StudioContext";
4
4
  import { usePlayerStore } from "../player";
5
5
  import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
6
- import type { StudioLookSnapshot } from "./tools/lookTools";
6
+ import { collectStudioLookScene, type StudioLookSnapshot } from "./tools/lookTools";
7
+ import { studioEditLifecycle } from "./writeCoordinator";
8
+ import { findElementForSelection } from "../components/editor/domEditingElement";
9
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
+ import {
11
+ applyStudioBoxSizeDraft,
12
+ captureStudioBoxSize,
13
+ restoreStudioBoxSize,
14
+ } from "../components/editor/manualEdits";
15
+
16
+ export function readLiveSelectionBox(
17
+ doc: Document | null | undefined,
18
+ selection: DomEditSelection,
19
+ activeCompositionPath: string | null,
20
+ ) {
21
+ const liveElement = doc
22
+ ? findElementForSelection(doc, selection, activeCompositionPath)
23
+ : selection.element;
24
+ if (!liveElement) throw new Error("the target is missing from the current Studio preview");
25
+ const rect = liveElement.getBoundingClientRect();
26
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
27
+ }
28
+
29
+ type ResizeCommit = (
30
+ selection: DomEditSelection,
31
+ next: { width: number; height: number },
32
+ offset?: { x: number; y: number },
33
+ restore?: () => void,
34
+ ) => Promise<void>;
35
+
36
+ export function resizeSelectionFromAgent(
37
+ selection: DomEditSelection,
38
+ next: { width: number; height: number },
39
+ commit: ResizeCommit,
40
+ ): Promise<void> {
41
+ const previous = captureStudioBoxSize(selection.element);
42
+ applyStudioBoxSizeDraft(selection.element, next);
43
+ return commit(selection, next, undefined, () => {
44
+ restoreStudioBoxSize(selection.element, previous);
45
+ });
46
+ }
7
47
 
8
48
  /**
9
49
  * Mounts Studio's WebMCP tool surface. Renders nothing.
@@ -20,6 +60,7 @@ export function StudioAgentTools() {
20
60
  const { projectId, activeCompPath, editHistory, writeBlockedReason } = useStudioShellContext();
21
61
  const {
22
62
  domEditSelection,
63
+ activeGroupElement,
23
64
  selectedGsapAnimations,
24
65
  gsapMultipleTimelines,
25
66
  gsapUnsupportedTimelinePattern,
@@ -28,8 +69,8 @@ export function StudioAgentTools() {
28
69
  previewIframeRef,
29
70
  buildDomSelectionFromTarget,
30
71
  applyDomSelection,
31
- handleDomTextCommit,
32
- handleDomStyleCommit,
72
+ handleDomTextCommitForSelection,
73
+ handleDomStyleCommitForSelection,
33
74
  handleDomPathOffsetCommit,
34
75
  handleDomBoxSizeCommit,
35
76
  handleDomRotationCommit,
@@ -37,8 +78,14 @@ export function StudioAgentTools() {
37
78
  handleGsapUpdateMeta,
38
79
  handleGsapAddKeyframeBatch,
39
80
  handleGsapDeleteAnimation,
81
+ getGsapAnimationsForSelection,
40
82
  } = useDomEditActionsContext();
41
83
 
84
+ useEffect(() => {
85
+ studioEditLifecycle.activateProject(projectId);
86
+ return () => studioEditLifecycle.reset();
87
+ }, [projectId]);
88
+
42
89
  const getSnapshot = useCallback((): StudioLookSnapshot => {
43
90
  const player = usePlayerStore.getState();
44
91
  return {
@@ -48,6 +95,11 @@ export function StudioAgentTools() {
48
95
  duration: player.duration,
49
96
  isPlaying: player.isPlaying,
50
97
  elements: player.elements,
98
+ scene: collectStudioLookScene(
99
+ previewIframeRef.current?.contentDocument ?? null,
100
+ activeCompPath,
101
+ activeGroupElement,
102
+ ),
51
103
  selection: domEditSelection,
52
104
  selectionAnimationCount: selectedGsapAnimations.length,
53
105
  history: {
@@ -57,13 +109,21 @@ export function StudioAgentTools() {
57
109
  redoLabel: editHistory.redoLabel ?? null,
58
110
  },
59
111
  };
60
- }, [projectId, activeCompPath, domEditSelection, selectedGsapAnimations, editHistory]);
112
+ }, [
113
+ projectId,
114
+ activeCompPath,
115
+ domEditSelection,
116
+ activeGroupElement,
117
+ selectedGsapAnimations,
118
+ editHistory,
119
+ previewIframeRef,
120
+ ]);
61
121
 
62
122
  const deps = useMemo<StudioAgentToolsDeps>(
63
123
  () => ({
64
124
  getSnapshot,
65
125
  getPreviewDocument: () => previewIframeRef.current?.contentDocument ?? null,
66
- buildSelection: (element) => buildDomSelectionFromTarget(element),
126
+ buildSelection: (element) => buildDomSelectionFromTarget(element, { exactTarget: true }),
67
127
  applySelection: (selection) => applyDomSelection(selection, { revealPanel: true }),
68
128
  requestSeek: (time) => usePlayerStore.getState().requestSeek(time),
69
129
  readPlayhead: () => {
@@ -90,22 +150,29 @@ export function StudioAgentTools() {
90
150
  wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
91
151
  getCurrentSelection: () => domEditSelection,
92
152
  getWriteBlockedReason: () => writeBlockedReason,
93
- setText: (value, fieldKey) => handleDomTextCommit(value, fieldKey),
94
- setStyle: (property, value) => handleDomStyleCommit(property, value),
153
+ setText: (selection, value, fieldKey) =>
154
+ handleDomTextCommitForSelection(selection, value, fieldKey),
155
+ setStyle: (selection, property, value) =>
156
+ handleDomStyleCommitForSelection(selection, property, value),
95
157
  // Measured, not authored: the tool compares this before and after to
96
- // tell a real change from a handler that did nothing and resolved.
97
- readBox: (selection) => {
98
- const rect = selection.element.getBoundingClientRect();
99
- return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
100
- },
158
+ // tell a real change from a handler that did nothing and resolved. A
159
+ // successful commit may replace the preview document, so re-resolve the
160
+ // source-safe selection instead of measuring its detached old node.
161
+ readBox: (selection) =>
162
+ readLiveSelectionBox(previewIframeRef.current?.contentDocument, selection, activeCompPath),
101
163
  moveTo: (selection, next) => handleDomPathOffsetCommit(selection, next),
102
- resizeTo: (selection, next) => handleDomBoxSizeCommit(selection, next),
164
+ resizeTo: (selection, next) =>
165
+ resizeSelectionFromAgent(selection, next, handleDomBoxSizeCommit),
103
166
  rotateTo: (selection, next) => handleDomRotationCommit(selection, next),
104
- addAnimation: (method) => handleGsapAddAnimation(method),
105
- updateAnimation: (animationId, updates) => handleGsapUpdateMeta(animationId, updates),
106
- addKeyframe: (animationId, percent, properties) =>
107
- handleGsapAddKeyframeBatch(animationId, percent, properties),
108
- deleteAnimation: (animationId) => handleGsapDeleteAnimation(animationId),
167
+ addAnimation: (selection, method) => handleGsapAddAnimation(method, selection),
168
+ updateAnimation: (selection, animationId, updates) =>
169
+ handleGsapUpdateMeta(animationId, updates, selection),
170
+ addKeyframe: (selection, animationId, percent, properties) =>
171
+ handleGsapAddKeyframeBatch(animationId, percent, properties, undefined, selection),
172
+ deleteAnimation: (selection, animationId) =>
173
+ handleGsapDeleteAnimation(animationId, selection),
174
+ getAnimationsForSelection: async (selection) =>
175
+ await getGsapAnimationsForSelection(selection),
109
176
  getGsapDiagnostics: () => ({
110
177
  animations: selectedGsapAnimations,
111
178
  multipleTimelines: gsapMultipleTimelines,
@@ -120,8 +187,8 @@ export function StudioAgentTools() {
120
187
  projectId,
121
188
  activeCompPath,
122
189
  writeBlockedReason,
123
- handleDomTextCommit,
124
- handleDomStyleCommit,
190
+ handleDomTextCommitForSelection,
191
+ handleDomStyleCommitForSelection,
125
192
  handleDomPathOffsetCommit,
126
193
  handleDomBoxSizeCommit,
127
194
  handleDomRotationCommit,
@@ -129,6 +196,7 @@ export function StudioAgentTools() {
129
196
  handleGsapUpdateMeta,
130
197
  handleGsapAddKeyframeBatch,
131
198
  handleGsapDeleteAnimation,
199
+ getGsapAnimationsForSelection,
132
200
  domEditSelection,
133
201
  selectedGsapAnimations,
134
202
  gsapMultipleTimelines,