@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.23",
3
+ "version": "0.8.25",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.23",
52
- "@hyperframes/parsers": "0.8.23",
53
- "@hyperframes/player": "0.8.23",
54
- "@hyperframes/sdk": "0.8.23",
55
- "@hyperframes/studio-server": "0.8.23"
51
+ "@hyperframes/parsers": "0.8.25",
52
+ "@hyperframes/core": "0.8.25",
53
+ "@hyperframes/player": "0.8.25",
54
+ "@hyperframes/studio-server": "0.8.25",
55
+ "@hyperframes/sdk": "0.8.25"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^3.2.4",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.23"
72
+ "@hyperframes/producer": "0.8.25"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
@@ -81,6 +81,7 @@
81
81
  "build": "vite build && tsup",
82
82
  "typecheck": "tsc --noEmit",
83
83
  "test": "vitest run",
84
+ "test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
84
85
  "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
85
86
  "test:watch": "vitest",
86
87
  "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
@@ -0,0 +1,294 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { studioEditLifecycle, type StudioWriteResult } from "../../webmcp/writeCoordinator";
7
+ import { TopologyLens } from "./TopologyLens";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const geometryMock = vi.hoisted(() => ({ measure: vi.fn() }));
12
+ vi.mock("./topologyLensGeometry", () => ({
13
+ measureTopologyLensGeometry: geometryMock.measure,
14
+ }));
15
+
16
+ const target = { handle: "dom:target", sourceFile: "index.html" };
17
+ let root: Root | null = null;
18
+ let host: HTMLDivElement | null = null;
19
+ let iframe: HTMLIFrameElement | null = null;
20
+
21
+ function matchMedia(reducedMotion: boolean): typeof window.matchMedia {
22
+ return vi.fn().mockReturnValue({
23
+ matches: reducedMotion,
24
+ media: "(prefers-reduced-motion: reduce)",
25
+ onchange: null,
26
+ addEventListener: vi.fn(),
27
+ removeEventListener: vi.fn(),
28
+ addListener: vi.fn(),
29
+ removeListener: vi.fn(),
30
+ dispatchEvent: vi.fn(),
31
+ });
32
+ }
33
+
34
+ function mount(reducedMotion = false, strictMode = false): void {
35
+ window.matchMedia = matchMedia(reducedMotion);
36
+ host = document.createElement("div");
37
+ iframe = document.createElement("iframe");
38
+ document.body.append(host, iframe);
39
+ root = createRoot(host);
40
+ act(() => {
41
+ const lens = (
42
+ <TopologyLens iframeRef={{ current: iframe }} activeCompositionPath="index.html" />
43
+ );
44
+ root?.render(strictMode ? <React.StrictMode>{lens}</React.StrictMode> : lens);
45
+ });
46
+ }
47
+
48
+ function begin(): string {
49
+ let callId = "";
50
+ act(() => {
51
+ callId = studioEditLifecycle.begin("project-a", target, "set-text");
52
+ });
53
+ return callId;
54
+ }
55
+
56
+ function finish(
57
+ callId: string,
58
+ stage: "dispatched" | "saved" | "verified" | "failed",
59
+ changed = true,
60
+ ): void {
61
+ const result: StudioWriteResult<object> =
62
+ stage === "failed"
63
+ ? {
64
+ ok: false,
65
+ kind: "failed",
66
+ reason: "save failed",
67
+ stage,
68
+ target,
69
+ operation: "set-text",
70
+ }
71
+ : {
72
+ ok: true,
73
+ stage,
74
+ target,
75
+ operation: "set-text",
76
+ changed,
77
+ evidence:
78
+ stage === "dispatched"
79
+ ? { kind: "dispatch", followUp: "studio_inspect" }
80
+ : { kind: "content-version", sourceFile: "index.html", version: "v1" },
81
+ };
82
+ act(() => studioEditLifecycle.finish(callId, result));
83
+ }
84
+
85
+ beforeEach(() => {
86
+ vi.useFakeTimers();
87
+ geometryMock.measure.mockReset().mockReturnValue({
88
+ field: {
89
+ label: "section",
90
+ rect: { left: 5, top: 10, width: 320, height: 180, editScaleX: 1, editScaleY: 1 },
91
+ },
92
+ target: {
93
+ label: "h1",
94
+ rect: { left: 10, top: 20, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
95
+ },
96
+ contours: [
97
+ {
98
+ label: "p",
99
+ rect: { left: 20, top: 30, width: 80, height: 20, editScaleX: 1, editScaleY: 1 },
100
+ },
101
+ {
102
+ label: "aside",
103
+ rect: { left: 110, top: 30, width: 60, height: 50, editScaleX: 1, editScaleY: 1 },
104
+ },
105
+ ],
106
+ });
107
+ });
108
+
109
+ afterEach(() => {
110
+ if (root) act(() => root?.unmount());
111
+ act(() => vi.runOnlyPendingTimers());
112
+ root = null;
113
+ host = null;
114
+ iframe = null;
115
+ studioEditLifecycle.reset();
116
+ document.body.replaceChildren();
117
+ vi.useRealTimers();
118
+ });
119
+
120
+ describe("TopologyLens", () => {
121
+ it("reveals real contours for a new target and seals only a durable receipt", () => {
122
+ mount();
123
+ const callId = begin();
124
+
125
+ expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull();
126
+ expect(
127
+ host?.querySelector('[data-topology-field="true"][data-topology-node="section"]'),
128
+ ).not.toBeNull();
129
+ expect(host?.querySelector('[data-topology-target][data-topology-node="h1"]')).not.toBeNull();
130
+ expect(host?.querySelectorAll('[data-topology-contour="true"]')).toHaveLength(2);
131
+ expect(
132
+ [...(host?.querySelectorAll<HTMLElement>("[data-topology-contour]") ?? [])].map(
133
+ (element) => element.dataset.topologyNode,
134
+ ),
135
+ ).toEqual(["p", "aside"]);
136
+ expect(host?.querySelector('[data-topology-scan="true"]')).not.toBeNull();
137
+
138
+ finish(callId, "saved");
139
+ expect(host?.querySelector('[data-topology-lens="sealing"]')).not.toBeNull();
140
+ expect(
141
+ host
142
+ ?.querySelector('[data-topology-field][data-topology-phase="sealing"]')
143
+ ?.querySelector('[data-topology-seal="saved"]'),
144
+ ).not.toBeNull();
145
+ expect(host?.querySelector("[data-topology-target] [data-topology-seal]")).toBeNull();
146
+ expect(geometryMock.measure).toHaveBeenCalledTimes(2);
147
+
148
+ act(() => vi.advanceTimersByTime(240));
149
+ expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
150
+ expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" });
151
+ });
152
+
153
+ it("remeasures the post-write target before drawing the persistence seal", () => {
154
+ geometryMock.measure
155
+ .mockReturnValueOnce({
156
+ field: {
157
+ label: "section",
158
+ rect: { left: 5, top: 10, width: 300, height: 180, editScaleX: 1, editScaleY: 1 },
159
+ },
160
+ target: {
161
+ label: "h1",
162
+ rect: { left: 10, top: 20, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
163
+ },
164
+ contours: [],
165
+ })
166
+ .mockReturnValueOnce({
167
+ field: {
168
+ label: "section",
169
+ rect: { left: 50, top: 20, width: 360, height: 220, editScaleX: 1, editScaleY: 1 },
170
+ },
171
+ target: {
172
+ label: "h1",
173
+ rect: { left: 70, top: 40, width: 240, height: 120, editScaleX: 1, editScaleY: 1 },
174
+ },
175
+ contours: [],
176
+ });
177
+ mount();
178
+ const callId = begin();
179
+ expect(host?.querySelector<HTMLElement>("[data-topology-target]")?.style.left).toBe("10px");
180
+
181
+ finish(callId, "verified");
182
+
183
+ const sealedTarget = host?.querySelector<HTMLElement>("[data-topology-target]");
184
+ expect(sealedTarget?.style.left).toBe("70px");
185
+ expect(sealedTarget?.style.width).toBe("240px");
186
+ expect(host?.querySelector('[data-topology-seal="verified"]')).not.toBeNull();
187
+ });
188
+
189
+ it("skips acquisition when the coordinator identifies a repeated target", () => {
190
+ mount();
191
+ const firstCall = begin();
192
+ finish(firstCall, "saved");
193
+ act(() => vi.advanceTimersByTime(240));
194
+
195
+ begin();
196
+
197
+ expect(host?.querySelector('[data-topology-lens="localizing"]')).not.toBeNull();
198
+ expect(host?.querySelector('[data-topology-contour="true"]')).toBeNull();
199
+ });
200
+
201
+ it.each(["dispatched", "failed"] as const)("retracts %s without a seal", (stage) => {
202
+ mount();
203
+ const callId = begin();
204
+
205
+ finish(callId, stage);
206
+
207
+ expect(host?.querySelector('[data-topology-lens="localizing"]')).not.toBeNull();
208
+ expect(host?.querySelector("[data-topology-seal]")).toBeNull();
209
+ act(() => vi.advanceTimersByTime(180));
210
+ expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
211
+ expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" });
212
+ });
213
+
214
+ it.each(["saved", "verified"] as const)("retracts a %s no-op without a seal", (stage) => {
215
+ mount();
216
+ const callId = begin();
217
+
218
+ finish(callId, stage, false);
219
+
220
+ expect(host?.querySelector('[data-topology-lens="localizing"]')).not.toBeNull();
221
+ expect(host?.querySelector('[data-topology-terminal="no-change"]')).not.toBeNull();
222
+ expect(host?.querySelector("[data-topology-seal]")).toBeNull();
223
+ act(() => vi.advanceTimersByTime(180));
224
+ expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
225
+ });
226
+
227
+ it("does not replay a terminal seal after the overlay remounts", () => {
228
+ mount();
229
+ const callId = begin();
230
+ finish(callId, "saved");
231
+ act(() => vi.advanceTimersByTime(240));
232
+ expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" });
233
+
234
+ act(() => root?.unmount());
235
+ root = null;
236
+ host?.remove();
237
+ iframe?.remove();
238
+ mount();
239
+
240
+ expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
241
+ expect(host?.querySelector("[data-topology-seal]")).toBeNull();
242
+ });
243
+
244
+ it("keeps an active invocation through StrictMode effect replay", () => {
245
+ const callId = begin();
246
+
247
+ mount(false, true);
248
+ act(() => vi.advanceTimersByTime(0));
249
+
250
+ expect(studioEditLifecycle.getSnapshot()).toMatchObject({
251
+ callId,
252
+ phase: "dispatching",
253
+ });
254
+ expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull();
255
+ });
256
+
257
+ it("clears on iframe reload and project switch", () => {
258
+ mount();
259
+ begin();
260
+
261
+ act(() => iframe?.dispatchEvent(new Event("load")));
262
+ expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
263
+
264
+ begin();
265
+ act(() => studioEditLifecycle.activateProject("project-b"));
266
+ expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
267
+ });
268
+
269
+ it("removes spatial travel in reduced motion while preserving target state", () => {
270
+ mount(true);
271
+ begin();
272
+
273
+ expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull();
274
+ expect(host?.querySelector('[data-topology-contour="true"]')).not.toBeNull();
275
+ expect(host?.querySelector('[data-topology-scan="true"]')).toBeNull();
276
+ });
277
+
278
+ it("cleans its timer and iframe listener on unmount", () => {
279
+ mount();
280
+ const removeListener = vi.spyOn(iframe!, "removeEventListener");
281
+ begin();
282
+ expect(vi.getTimerCount()).toBe(1);
283
+
284
+ act(() => root?.unmount());
285
+ root = null;
286
+
287
+ expect(vi.getTimerCount()).toBe(1);
288
+ expect(removeListener).toHaveBeenCalledWith("load", expect.any(Function));
289
+ expect(studioEditLifecycle.getSnapshot()).toMatchObject({ phase: "dispatching" });
290
+ act(() => vi.advanceTimersByTime(0));
291
+ expect(vi.getTimerCount()).toBe(0);
292
+ expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" });
293
+ });
294
+ });
@@ -0,0 +1,206 @@
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useReducer,
5
+ useRef,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type CSSProperties,
9
+ type RefObject,
10
+ } from "react";
11
+ import { studioEditLifecycle } from "../../webmcp/writeCoordinator";
12
+ import { HyperframesMark } from "../ui/HyperframesMark";
13
+ import { measureTopologyLensGeometry, type TopologyLensGeometry } from "./topologyLensGeometry";
14
+ import { reduceTopologyLens, type TopologyLensState } from "./topologyLensState";
15
+
16
+ const ACQUISITION_MS = 240;
17
+ const TERMINAL_RETRACT_MS = 180;
18
+ const SEAL_MS = 240;
19
+
20
+ interface TopologyLensProps {
21
+ iframeRef: RefObject<HTMLIFrameElement | null>;
22
+ activeCompositionPath: string | null;
23
+ }
24
+
25
+ interface MeasuredLens {
26
+ callId: string;
27
+ geometry: TopologyLensGeometry;
28
+ }
29
+
30
+ function rectStyle(rect: TopologyLensGeometry["target"]["rect"]): CSSProperties {
31
+ return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
32
+ }
33
+
34
+ function getReducedMotion(): boolean {
35
+ return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
36
+ }
37
+
38
+ function subscribeReducedMotion(listener: () => void): () => void {
39
+ const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
40
+ if (!query) return () => undefined;
41
+ query.addEventListener("change", listener);
42
+ return () => query.removeEventListener("change", listener);
43
+ }
44
+
45
+ function useTopologyLensState(): TopologyLensState {
46
+ const lifecycle = useSyncExternalStore(
47
+ studioEditLifecycle.subscribe,
48
+ studioEditLifecycle.getSnapshot,
49
+ studioEditLifecycle.getSnapshot,
50
+ );
51
+ const [state, dispatch] = useReducer(
52
+ reduceTopologyLens,
53
+ lifecycle,
54
+ (initialLifecycle): TopologyLensState =>
55
+ reduceTopologyLens({ phase: "hidden" }, { type: "lifecycle", value: initialLifecycle }),
56
+ );
57
+
58
+ useEffect(() => dispatch({ type: "lifecycle", value: lifecycle }), [lifecycle]);
59
+ useEffect(() => {
60
+ if (state.phase === "hidden") return;
61
+ const delay =
62
+ state.phase === "acquiring"
63
+ ? ACQUISITION_MS
64
+ : state.phase === "sealing"
65
+ ? SEAL_MS
66
+ : state.terminal
67
+ ? TERMINAL_RETRACT_MS
68
+ : null;
69
+ if (delay === null) return;
70
+ const timeout = window.setTimeout(() => {
71
+ if (state.phase === "acquiring") {
72
+ dispatch({ type: "acquisition-elapsed", callId: state.callId });
73
+ return;
74
+ }
75
+ studioEditLifecycle.dismiss(state.callId);
76
+ }, delay);
77
+ return () => window.clearTimeout(timeout);
78
+ }, [state]);
79
+
80
+ return state;
81
+ }
82
+
83
+ /** Studio-parent chrome driven by the same invocation and receipt as WebMCP. */
84
+ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensProps) {
85
+ const overlayRef = useRef<HTMLDivElement | null>(null);
86
+ const state = useTopologyLensState();
87
+ const reducedMotion = useSyncExternalStore(subscribeReducedMotion, getReducedMotion, () => false);
88
+ const [measured, setMeasured] = useState<MeasuredLens | null>(null);
89
+ const callId = state.phase === "hidden" ? null : state.callId;
90
+ const handle = state.phase === "hidden" ? null : state.target.handle;
91
+ const phase = state.phase;
92
+ const ownedCallIdRef = useRef(callId);
93
+ const pendingUnmountDismissRef = useRef<number | null>(null);
94
+ ownedCallIdRef.current = callId;
95
+
96
+ useLayoutEffect(() => {
97
+ if (!callId || !handle) {
98
+ setMeasured(null);
99
+ return;
100
+ }
101
+ const overlay = overlayRef.current;
102
+ const iframe = iframeRef.current;
103
+ if (!overlay || !iframe) {
104
+ setMeasured(null);
105
+ return;
106
+ }
107
+ const geometry = measureTopologyLensGeometry({
108
+ overlay,
109
+ iframe,
110
+ activeCompositionPath,
111
+ handle,
112
+ });
113
+ setMeasured(geometry ? { callId, geometry } : null);
114
+ }, [activeCompositionPath, callId, handle, iframeRef, phase]);
115
+
116
+ useEffect(() => {
117
+ const iframe = iframeRef.current;
118
+ if (!iframe || !callId) return;
119
+ const dismiss = () => studioEditLifecycle.dismiss(callId);
120
+ iframe.addEventListener("load", dismiss);
121
+ return () => iframe.removeEventListener("load", dismiss);
122
+ }, [callId, iframeRef]);
123
+
124
+ useEffect(() => {
125
+ if (pendingUnmountDismissRef.current !== null) {
126
+ window.clearTimeout(pendingUnmountDismissRef.current);
127
+ pendingUnmountDismissRef.current = null;
128
+ }
129
+ return () => {
130
+ const ownedCallId = ownedCallIdRef.current;
131
+ if (!ownedCallId) return;
132
+ pendingUnmountDismissRef.current = window.setTimeout(() => {
133
+ pendingUnmountDismissRef.current = null;
134
+ studioEditLifecycle.dismiss(ownedCallId);
135
+ }, 0);
136
+ };
137
+ }, []);
138
+
139
+ const geometry = measured?.callId === callId ? measured.geometry : null;
140
+ const visible = state.phase !== "hidden" && geometry;
141
+ return (
142
+ <div
143
+ ref={overlayRef}
144
+ aria-hidden="true"
145
+ data-topology-lens={visible ? state.phase : "hidden"}
146
+ className="pointer-events-none absolute inset-0 z-[58] overflow-hidden"
147
+ >
148
+ {visible && (
149
+ <>
150
+ {(state.phase === "acquiring" || state.phase === "sealing") && (
151
+ <div
152
+ data-topology-field="true"
153
+ data-topology-node={geometry.field.label}
154
+ data-topology-phase={state.phase}
155
+ className="hf-topology-field pointer-events-none absolute overflow-hidden rounded-md"
156
+ style={rectStyle(geometry.field.rect)}
157
+ >
158
+ {state.phase === "sealing" && (
159
+ <HyperframesMark
160
+ data-topology-seal={state.receiptStage}
161
+ className="hf-topology-seal absolute right-2 top-2 h-7 w-11 overflow-visible"
162
+ viewBox="0 18 100 64"
163
+ />
164
+ )}
165
+ </div>
166
+ )}
167
+ {(state.phase === "acquiring" || state.phase === "sealing") && (
168
+ <>
169
+ {geometry.contours.map(({ label, rect }, index) => (
170
+ <div
171
+ key={index}
172
+ data-topology-contour="true"
173
+ data-topology-node={label}
174
+ className="hf-topology-contour pointer-events-none absolute z-[1] rounded"
175
+ style={{
176
+ ...rectStyle(rect),
177
+ animationDelay: `${Math.min(index, 5) * 28}ms`,
178
+ }}
179
+ />
180
+ ))}
181
+ {!reducedMotion && (
182
+ <div
183
+ data-topology-scan="true"
184
+ className="pointer-events-none absolute z-[2] overflow-hidden rounded"
185
+ style={rectStyle(geometry.field.rect)}
186
+ >
187
+ <div className="hf-topology-scan absolute inset-y-0 left-0 w-1/4" />
188
+ </div>
189
+ )}
190
+ </>
191
+ )}
192
+ <div
193
+ data-topology-target="true"
194
+ data-topology-node={geometry.target.label}
195
+ data-topology-phase={state.phase}
196
+ data-topology-terminal={
197
+ state.phase === "localizing" ? (state.terminal ?? undefined) : undefined
198
+ }
199
+ className="hf-topology-target pointer-events-none absolute z-[3] rounded-md"
200
+ style={rectStyle(geometry.target.rect)}
201
+ />
202
+ </>
203
+ )}
204
+ </div>
205
+ );
206
+ }
@@ -107,6 +107,20 @@ export function toVisibleOverlayRect(
107
107
  return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
108
108
  }
109
109
 
110
+ /** Batch transient chrome through one shared iframe-to-overlay coordinate basis. */
111
+ export function toVisibleOverlayRects(
112
+ overlayEl: HTMLDivElement,
113
+ iframe: HTMLIFrameElement,
114
+ elements: readonly HTMLElement[],
115
+ ): Array<OverlayRect | null> {
116
+ const scale = computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
117
+ if (!scale) return elements.map(() => null);
118
+ return elements.map((element) => {
119
+ const rect = toOverlayRect(overlayEl, iframe, element, scale);
120
+ return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
121
+ });
122
+ }
123
+
110
124
  /**
111
125
  * getComputedStyle(element).transform decomposed into a DOMMatrix, read ONCE.
112
126
  * Shared by orientedOverlayRect's rotation gate and elementCornerOverlayPoints
@@ -0,0 +1,18 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { expect, it, vi } from "vitest";
4
+ import { toVisibleOverlayRects } from "./domEditOverlayGeometry";
5
+
6
+ it("does not fall back to per-element basis reads when the shared basis is unavailable", () => {
7
+ const overlay = document.createElement("div");
8
+ const iframe = document.createElement("iframe");
9
+ document.body.append(overlay, iframe);
10
+ const doc = iframe.contentDocument;
11
+ if (!doc) throw new Error("expected iframe document");
12
+ doc.body.innerHTML = '<main data-composition-id="root"><div id="target"></div></main>';
13
+ const target = doc.getElementById("target") as HTMLElement;
14
+ const targetRead = vi.spyOn(target, "getBoundingClientRect");
15
+
16
+ expect(toVisibleOverlayRects(overlay, iframe, [target])).toEqual([null]);
17
+ expect(targetRead).not.toHaveBeenCalled();
18
+ });
@@ -126,6 +126,15 @@ describe("resolveDomEditSelection — data-hf-group capture", () => {
126
126
  expect(selection?.selector).toBe('[data-hf-group="Group 1"]');
127
127
  });
128
128
 
129
+ it("resolves an explicit agent target without promoting it to the group", async () => {
130
+ const { parent, child } = buildNestedGroups();
131
+ const selection = await resolveDomEditSelection(child, { ...opts, exactTarget: true });
132
+ document.body.removeChild(parent);
133
+
134
+ expect(selection?.element).toBe(child);
135
+ expect(selection?.id).toBe("child");
136
+ });
137
+
129
138
  it("selects the next nested group when drilled into the outer group", async () => {
130
139
  const { parent, outer, inner, child } = buildNestedGroups();
131
140
  const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: outer });
@@ -281,10 +281,6 @@ export function resolveDomEditCapabilities(args: {
281
281
  ).capabilities;
282
282
  }
283
283
 
284
- // ─── Element label ────────────────────────────────────────────────────────────
285
-
286
- // ─── Source probe ────────────────────────────────────────────────────────────
287
-
288
284
  async function probeSourceElement(
289
285
  projectId: string,
290
286
  sourceFile: string,
@@ -310,17 +306,21 @@ async function probeSourceElement(
310
306
  }
311
307
  }
312
308
 
313
- // ─── Selection resolution ────────────────────────────────────────────────────
314
-
315
309
  // fallow-ignore-next-line complexity
316
310
  export async function resolveDomEditSelection(
317
311
  startEl: HTMLElement | null,
318
- options: DomEditContextOptions & { projectId?: string | null; skipSourceProbe?: boolean },
312
+ options: DomEditContextOptions & {
313
+ projectId?: string | null;
314
+ skipSourceProbe?: boolean;
315
+ exactTarget?: boolean;
316
+ },
319
317
  ): Promise<DomEditSelection | null> {
320
318
  if (!startEl) return null;
321
319
  const doc = startEl.ownerDocument;
322
320
 
323
- let capture = resolveGroupCapture(startEl, options.activeGroupElement ?? null);
321
+ let capture = options.exactTarget
322
+ ? ({ kind: "unit", element: startEl } as const)
323
+ : resolveGroupCapture(startEl, options.activeGroupElement ?? null);
324
324
  if (capture.kind === "out-of-scope") {
325
325
  // Drill-in is non-sticky: clicking/hovering OUTSIDE the drilled-into group
326
326
  // exits it and resolves the target normally, rather than selecting nothing
@@ -0,0 +1,44 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { mintElementHandle } from "../../webmcp/handles";
5
+ import { previewDoc } from "../../webmcp/webmcpTestUtils";
6
+ import { resolveTopologyLensElements } from "./topologyLensGeometry";
7
+
8
+ describe("Topology Lens scene geometry", () => {
9
+ it("reveals the nearest same-source context while preserving the exact target", () => {
10
+ const doc = previewDoc(`
11
+ <main data-composition-id="root" data-composition-file="index.html">
12
+ <section id="duplicate"><span id="root-child">root</span></section>
13
+ <section data-composition-id="nested" data-composition-file="compositions/nested.html">
14
+ <article id="nested-context">
15
+ <div id="duplicate"><strong id="nested-child">nested</strong></div>
16
+ <aside id="unrelated">outside target</aside>
17
+ </article>
18
+ </section>
19
+ </main>
20
+ `);
21
+ const handle = mintElementHandle({
22
+ domId: "duplicate",
23
+ sourceFile: "compositions/nested.html",
24
+ activeCompositionPath: "index.html",
25
+ });
26
+ if (!handle) throw new Error("expected source-safe handle");
27
+
28
+ const resolved = resolveTopologyLensElements(doc, "index.html", handle);
29
+
30
+ expect(resolved?.target.textContent).toBe("nested");
31
+ expect(resolved?.context.getAttribute("data-composition-file")).toBe(
32
+ "compositions/nested.html",
33
+ );
34
+ expect(resolved?.contours.map((element) => element.id)).toEqual(["nested-child", "unrelated"]);
35
+ });
36
+
37
+ it("returns no geometry for a stale handle", () => {
38
+ const doc = previewDoc(
39
+ '<main data-composition-id="root" data-composition-file="index.html"></main>',
40
+ );
41
+
42
+ expect(resolveTopologyLensElements(doc, "index.html", "dom:missing")).toBeNull();
43
+ });
44
+ });