@hyperframes/studio 0.8.24 → 0.8.26
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.
- package/dist/assets/{hyperframes-player-CTQCs_KG.js → hyperframes-player-C-CArn13.js} +1 -1
- package/dist/assets/{index-okdE01ky.js → index-BIXyJgK5.js} +1 -1
- package/dist/assets/{index-Bq3M0sjr.js → index-D5yni1t6.js} +218 -218
- package/dist/assets/index-DnRfAiK2.css +1 -0
- package/dist/assets/{index-9WJFcXTP.js → index-pfeP2Y63.js} +1 -1
- package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
- package/dist/chunk-A7S5SGNA.js.map +1 -0
- package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.html +2 -2
- package/dist/index.js +6230 -5019
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/editor/TopologyLens.test.tsx +294 -0
- package/src/components/editor/TopologyLens.tsx +206 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
- package/src/components/editor/domEditingLayers.test.ts +9 -0
- package/src/components/editor/domEditingLayers.ts +8 -8
- package/src/components/editor/topologyLensGeometry.test.ts +44 -0
- package/src/components/editor/topologyLensGeometry.ts +119 -0
- package/src/components/editor/topologyLensState.test.ts +116 -0
- package/src/components/editor/topologyLensState.ts +72 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
- package/src/components/nle/PreviewOverlays.tsx +24 -18
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
- package/src/components/sidebar/CompositionsTab.tsx +10 -3
- package/src/components/ui/HyperframesLoader.tsx +3 -48
- package/src/components/ui/HyperframesMark.tsx +53 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/hooks/domEditCommitRunner.test.ts +59 -0
- package/src/hooks/domEditCommitRunner.ts +55 -20
- package/src/hooks/domEditCommitTypes.ts +8 -1
- package/src/hooks/domEditTextCommitPlan.ts +47 -0
- package/src/hooks/timelineTrackVisibility.test.ts +7 -1
- package/src/hooks/timelineTrackVisibility.ts +5 -5
- package/src/hooks/useDomEditAttributeCommits.ts +4 -1
- package/src/hooks/useDomEditCommits.test.tsx +243 -8
- package/src/hooks/useDomEditCommits.ts +69 -14
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
- package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
- package/src/hooks/useDomEditSession.ts +6 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
- package/src/hooks/useDomEditTextCommits.ts +64 -86
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomSelection.ts +2 -0
- package/src/hooks/useDomSelectionTypes.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
- package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
- package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
- package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
- package/src/hooks/useGsapAnimationOps.ts +4 -4
- package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
- package/src/hooks/useGsapAwareEditing.ts +31 -10
- package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
- package/src/hooks/useGsapSelectionHandlers.ts +21 -11
- package/src/hooks/useRenderClipContent.test.ts +27 -0
- package/src/hooks/useRenderClipContent.ts +5 -0
- package/src/hooks/useStudioExternalFileChanges.ts +5 -0
- package/src/player/components/CompositionThumbnail.test.ts +66 -1
- package/src/player/components/CompositionThumbnail.tsx +6 -0
- package/src/player/hooks/timelineSyncHydration.ts +10 -2
- package/src/player/store/playerStore.test.ts +24 -0
- package/src/player/store/thumbnailSlice.ts +6 -0
- package/src/styles/studio.css +179 -0
- package/src/utils/domEditSaveQueue.test.ts +5 -3
- package/src/utils/domEditSaveQueue.ts +6 -1
- package/src/webmcp/StudioAgentTools.test.ts +70 -0
- package/src/webmcp/StudioAgentTools.tsx +89 -21
- package/src/webmcp/handles.test.ts +172 -15
- package/src/webmcp/handles.ts +152 -35
- package/src/webmcp/tools/animationTools.test.ts +241 -60
- package/src/webmcp/tools/animationTools.ts +191 -104
- package/src/webmcp/tools/contentTools.test.ts +103 -41
- package/src/webmcp/tools/contentTools.ts +149 -105
- package/src/webmcp/tools/inspectTools.test.ts +47 -5
- package/src/webmcp/tools/inspectTools.ts +59 -19
- package/src/webmcp/tools/lookTools.test.ts +185 -38
- package/src/webmcp/tools/lookTools.ts +134 -21
- package/src/webmcp/tools/selectionTools.test.ts +105 -9
- package/src/webmcp/tools/selectionTools.ts +34 -19
- package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
- package/src/webmcp/tools/transformTools.test.ts +140 -54
- package/src/webmcp/tools/transformTools.ts +215 -56
- package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
- package/src/webmcp/useStudioAgentTools.ts +27 -15
- package/src/webmcp/webmcpTestUtils.ts +37 -0
- package/src/webmcp/writeCoordinator.test.ts +375 -0
- package/src/webmcp/writeCoordinator.ts +473 -0
- package/dist/assets/index-yGhfxxoL.css +0 -1
- package/dist/chunk-6BT6DTB4.js.map +0 -1
- /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.
|
|
3
|
+
"version": "0.8.26",
|
|
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.
|
|
52
|
-
"@hyperframes/parsers": "0.8.
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
55
|
-
"@hyperframes/player": "0.8.
|
|
51
|
+
"@hyperframes/core": "0.8.26",
|
|
52
|
+
"@hyperframes/parsers": "0.8.26",
|
|
53
|
+
"@hyperframes/studio-server": "0.8.26",
|
|
54
|
+
"@hyperframes/sdk": "0.8.26",
|
|
55
|
+
"@hyperframes/player": "0.8.26"
|
|
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.
|
|
72
|
+
"@hyperframes/producer": "0.8.26"
|
|
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 & {
|
|
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 =
|
|
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
|
+
});
|