@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
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
previewDoc,
|
|
9
9
|
previewElement,
|
|
10
10
|
selectionFor,
|
|
11
|
+
selectionToolDeps,
|
|
12
|
+
sourceHandle,
|
|
11
13
|
} from "../webmcpTestUtils";
|
|
12
14
|
|
|
13
15
|
function animation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
|
@@ -25,11 +27,8 @@ function animation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
|
|
25
27
|
|
|
26
28
|
function inspectDeps(overrides: Partial<InspectToolDeps> = {}): InspectToolDeps {
|
|
27
29
|
return {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
applySelection: () => undefined,
|
|
31
|
-
requestSeek: () => undefined,
|
|
32
|
-
readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }),
|
|
30
|
+
...selectionToolDeps(),
|
|
31
|
+
getProjectId: () => "project-a",
|
|
33
32
|
getCurrentSelection: () => null,
|
|
34
33
|
getGsapDiagnostics: () => ({
|
|
35
34
|
animations: [],
|
|
@@ -41,6 +40,20 @@ function inspectDeps(overrides: Partial<InspectToolDeps> = {}): InspectToolDeps
|
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
describe("studioInspect", () => {
|
|
43
|
+
it("refuses a scoped handle from another project before inspecting", async () => {
|
|
44
|
+
const doc = previewDoc('<h1 id="headline">Ship it</h1>');
|
|
45
|
+
|
|
46
|
+
const result = await studioInspect(inspectDeps({ getPreviewDocument: () => doc }), {
|
|
47
|
+
handle: sourceHandle("headline", "project-b"),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result).toMatchObject({
|
|
51
|
+
ok: false,
|
|
52
|
+
kind: "invalid",
|
|
53
|
+
reason: "the handle belongs to a different project",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
44
57
|
it("returns the resolved styles, not the authored ones", async () => {
|
|
45
58
|
const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
|
|
46
59
|
const selection = selectionFor(element);
|
|
@@ -173,6 +186,35 @@ describe("studioInspect", () => {
|
|
|
173
186
|
expect(applySelection).not.toHaveBeenCalled();
|
|
174
187
|
});
|
|
175
188
|
|
|
189
|
+
it("keeps a resolved source-safe handle instead of weakening it", async () => {
|
|
190
|
+
const doc = previewDoc(
|
|
191
|
+
'<main data-composition-id="root" data-composition-file="index.html"><h1 id="headline">A</h1></main>',
|
|
192
|
+
);
|
|
193
|
+
const handle = "dom:v1:index.html:index.html:headline";
|
|
194
|
+
|
|
195
|
+
const ok = expectOk<StudioInspectResult>(
|
|
196
|
+
await studioInspect(inspectDeps({ getPreviewDocument: () => doc }), { handle }),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(ok.handle).toBe(handle);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("mints a source-safe handle for the current selection", async () => {
|
|
203
|
+
const element = previewElement('<h1 id="headline">A</h1>', "headline");
|
|
204
|
+
const current = selectionFor(element, { sourceFile: "compositions/hero.html" });
|
|
205
|
+
|
|
206
|
+
const ok = expectOk<StudioInspectResult>(
|
|
207
|
+
await studioInspect(
|
|
208
|
+
inspectDeps({
|
|
209
|
+
getCompositionPath: () => "index.html",
|
|
210
|
+
getCurrentSelection: () => current,
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(ok.handle).toBe("dom:v2:project-a:index.html:compositions%2Fhero.html:headline");
|
|
216
|
+
});
|
|
217
|
+
|
|
176
218
|
it("fails rather than returning an empty result when nothing is selected", async () => {
|
|
177
219
|
const result = expectFailure(await studioInspect(inspectDeps()));
|
|
178
220
|
|
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
|
16
16
|
import type { DomEditSelection } from "../../components/editor/domEditingTypes";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
elementHandleMatchesProject,
|
|
19
|
+
mintElementHandle,
|
|
20
|
+
patchTargetAddress,
|
|
21
|
+
resolveLiveHandleSelection,
|
|
22
|
+
} from "../handles";
|
|
18
23
|
import { toolFailure, toolOk, type ToolResult } from "../toolResult";
|
|
19
24
|
import type { SelectionToolDeps } from "./selectionTools";
|
|
20
25
|
|
|
@@ -94,10 +99,23 @@ function describeAnimation(animation: GsapAnimation): InspectAnimation {
|
|
|
94
99
|
};
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
function getAnimationEditingBlock(
|
|
103
|
+
isCurrentSelection: boolean,
|
|
104
|
+
diagnostics: ReturnType<InspectToolDeps["getGsapDiagnostics"]>,
|
|
105
|
+
): string | null {
|
|
106
|
+
if (!isCurrentSelection) return "animations are only readable for the current selection";
|
|
107
|
+
if (diagnostics.multipleTimelines) return "this composition has multiple GSAP timelines";
|
|
108
|
+
if (diagnostics.unsupportedTimelinePattern) {
|
|
109
|
+
return "this composition's timeline pattern is not editable by Studio";
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
function describe(
|
|
98
115
|
selection: DomEditSelection,
|
|
99
116
|
deps: InspectToolDeps,
|
|
100
117
|
isCurrentSelection: boolean,
|
|
118
|
+
resolvedHandle?: string,
|
|
101
119
|
): ToolResult<StudioInspectResult> {
|
|
102
120
|
const { capabilities } = selection;
|
|
103
121
|
const gsap = deps.getGsapDiagnostics();
|
|
@@ -107,17 +125,18 @@ function describe(
|
|
|
107
125
|
// which is worse than reporting none.
|
|
108
126
|
const animations = isCurrentSelection ? gsap.animations.map(describeAnimation) : [];
|
|
109
127
|
|
|
110
|
-
|
|
111
|
-
if (!isCurrentSelection) {
|
|
112
|
-
animationEditingBlocked = "animations are only readable for the current selection";
|
|
113
|
-
} else if (gsap.multipleTimelines) {
|
|
114
|
-
animationEditingBlocked = "this composition has multiple GSAP timelines";
|
|
115
|
-
} else if (gsap.unsupportedTimelinePattern) {
|
|
116
|
-
animationEditingBlocked = "this composition's timeline pattern is not editable by Studio";
|
|
117
|
-
}
|
|
128
|
+
const animationEditingBlocked = getAnimationEditingBlock(isCurrentSelection, gsap);
|
|
118
129
|
|
|
119
130
|
return toolOk<StudioInspectResult>({
|
|
120
|
-
handle:
|
|
131
|
+
handle:
|
|
132
|
+
resolvedHandle ??
|
|
133
|
+
mintElementHandle(
|
|
134
|
+
patchTargetAddress(
|
|
135
|
+
selection,
|
|
136
|
+
deps.getCompositionPath() ?? "index.html",
|
|
137
|
+
deps.getProjectId(),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
121
140
|
label: selection.label,
|
|
122
141
|
tagName: selection.tagName,
|
|
123
142
|
sourceFile: selection.sourceFile,
|
|
@@ -165,25 +184,46 @@ export async function studioInspect(
|
|
|
165
184
|
}
|
|
166
185
|
return describe(current, deps, true);
|
|
167
186
|
}
|
|
187
|
+
if (!elementHandleMatchesProject(input.handle, deps.getProjectId())) {
|
|
188
|
+
return toolFailure(
|
|
189
|
+
"invalid",
|
|
190
|
+
"the handle belongs to a different project",
|
|
191
|
+
"Call studio_look in the active project.",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
168
194
|
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
195
|
+
const resolved = await resolveLiveHandleSelection(
|
|
196
|
+
deps.getPreviewDocument,
|
|
197
|
+
input.handle,
|
|
198
|
+
deps.buildSelection,
|
|
199
|
+
);
|
|
200
|
+
if (resolved.status === "preview-unavailable") {
|
|
201
|
+
return toolFailure("blocked", "the preview is not mounted yet");
|
|
202
|
+
}
|
|
203
|
+
if (resolved.status === "not-found") {
|
|
174
204
|
return toolFailure(
|
|
175
205
|
"invalid",
|
|
176
206
|
`no element matches handle ${input.handle}`,
|
|
177
207
|
"Call studio_look for current handles.",
|
|
178
208
|
);
|
|
179
209
|
}
|
|
180
|
-
|
|
181
|
-
const selection = await deps.buildSelection(element);
|
|
182
|
-
if (!selection) {
|
|
210
|
+
if (resolved.status === "unsupported") {
|
|
183
211
|
return toolFailure("blocked", `${input.handle} resolved to an element Studio cannot inspect`);
|
|
184
212
|
}
|
|
213
|
+
if (resolved.status === "changed") {
|
|
214
|
+
return toolFailure(
|
|
215
|
+
"invalid",
|
|
216
|
+
"the target changed while it was resolving",
|
|
217
|
+
"Call studio_look again.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
185
220
|
|
|
186
|
-
return describe(
|
|
221
|
+
return describe(
|
|
222
|
+
resolved.selection,
|
|
223
|
+
deps,
|
|
224
|
+
current?.element === resolved.selection.element,
|
|
225
|
+
input.handle,
|
|
226
|
+
);
|
|
187
227
|
}
|
|
188
228
|
|
|
189
229
|
export const STUDIO_INSPECT_INPUT_SCHEMA = {
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import type { DomEditSelection } from "../../components/editor/domEditingTypes";
|
|
4
4
|
import type { TimelineElement } from "../../player/store/timelineElement";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildStudioLook,
|
|
7
|
+
collectStudioLookScene,
|
|
8
|
+
STUDIO_LOOK_INPUT_SCHEMA,
|
|
9
|
+
type StudioLookSnapshot,
|
|
10
|
+
} from "./lookTools";
|
|
6
11
|
|
|
7
12
|
function element(overrides: Partial<TimelineElement>): TimelineElement {
|
|
8
13
|
return { id: "synthetic", tag: "div", start: 0, duration: 1, track: 0, ...overrides };
|
|
@@ -16,6 +21,7 @@ function snapshot(overrides: Partial<StudioLookSnapshot> = {}): StudioLookSnapsh
|
|
|
16
21
|
duration: 10,
|
|
17
22
|
isPlaying: false,
|
|
18
23
|
elements: [],
|
|
24
|
+
scene: { status: "ready", items: [], drillInItem: null },
|
|
19
25
|
selection: null,
|
|
20
26
|
selectionAnimationCount: 0,
|
|
21
27
|
history: { canUndo: true, canRedo: false, undoLabel: "Move layer", redoLabel: null },
|
|
@@ -84,90 +90,140 @@ describe("buildStudioLook", () => {
|
|
|
84
90
|
const look = expectOk<{ elements: { handle: string | null; label: string | null }[] }>(
|
|
85
91
|
buildStudioLook(
|
|
86
92
|
snapshot({
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
scene: {
|
|
94
|
+
status: "ready",
|
|
95
|
+
items: [
|
|
96
|
+
layer("Headline", { hfId: "abc" }),
|
|
97
|
+
layer("Button", { id: "cta" }),
|
|
98
|
+
layer("Card", { selector: ".card", selectorIndex: 2 }),
|
|
99
|
+
],
|
|
100
|
+
drillInItem: null,
|
|
101
|
+
},
|
|
92
102
|
}),
|
|
93
103
|
),
|
|
94
104
|
);
|
|
95
105
|
|
|
96
|
-
expect(look.elements.map((e) => e.handle)).toEqual([
|
|
106
|
+
expect(look.elements.map((e) => e.handle)).toEqual([
|
|
107
|
+
"hf:v2:demo:index.html:index.html:abc",
|
|
108
|
+
"dom:v2:demo:index.html:index.html:cta",
|
|
109
|
+
"sel:v2:demo:index.html:index.html:.card#2",
|
|
110
|
+
]);
|
|
97
111
|
});
|
|
98
112
|
|
|
99
|
-
it("
|
|
100
|
-
const
|
|
101
|
-
buildStudioLook(snapshot(
|
|
113
|
+
it("distinguishes an empty ready scene from a preview that is still loading", () => {
|
|
114
|
+
const ready = expectOk<{ sceneStatus: string; elements: unknown[]; elementCount: number }>(
|
|
115
|
+
buildStudioLook(snapshot()),
|
|
116
|
+
);
|
|
117
|
+
const loading = expectOk<{ sceneStatus: string; elements: unknown[]; elementCount: number }>(
|
|
118
|
+
buildStudioLook(snapshot({ scene: { status: "loading" } })),
|
|
102
119
|
);
|
|
103
120
|
|
|
104
|
-
expect(
|
|
105
|
-
expect(
|
|
121
|
+
expect(ready).toMatchObject({ sceneStatus: "ready", elements: [], elementCount: 0 });
|
|
122
|
+
expect(loading).toMatchObject({ sceneStatus: "loading", elements: [], elementCount: 0 });
|
|
106
123
|
});
|
|
107
124
|
|
|
108
|
-
it("
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
expect(
|
|
125
|
+
it("treats an about-blank iframe as loading and a mounted empty composition as ready", () => {
|
|
126
|
+
const blank = document.implementation.createHTMLDocument();
|
|
127
|
+
const mounted = document.implementation.createHTMLDocument();
|
|
128
|
+
mounted.body.innerHTML =
|
|
129
|
+
'<main data-composition-id="root" data-composition-file="index.html"></main>';
|
|
130
|
+
|
|
131
|
+
expect(collectStudioLookScene(blank, "index.html", null)).toEqual({ status: "loading" });
|
|
132
|
+
expect(collectStudioLookScene(mounted, "index.html", null)).toEqual({
|
|
133
|
+
status: "ready",
|
|
134
|
+
items: [],
|
|
135
|
+
drillInItem: null,
|
|
136
|
+
});
|
|
115
137
|
});
|
|
116
138
|
|
|
117
139
|
it("filters on label, tag and handle, case-insensitively", () => {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
140
|
+
const items = [
|
|
141
|
+
layer("Headline", { hfId: "abc", tagName: "h1" }),
|
|
142
|
+
layer("Button", { id: "cta", tagName: "button" }),
|
|
121
143
|
];
|
|
122
144
|
|
|
123
145
|
const byLabel = expectOk<{ elements: { handle: string | null }[] }>(
|
|
124
|
-
buildStudioLook(snapshot({
|
|
146
|
+
buildStudioLook(snapshot({ scene: { status: "ready", items, drillInItem: null } }), {
|
|
147
|
+
filter: "HEADLINE",
|
|
148
|
+
}),
|
|
125
149
|
);
|
|
126
150
|
const byTag = expectOk<{ elements: { handle: string | null }[] }>(
|
|
127
|
-
buildStudioLook(snapshot({
|
|
151
|
+
buildStudioLook(snapshot({ scene: { status: "ready", items, drillInItem: null } }), {
|
|
152
|
+
filter: "button",
|
|
153
|
+
}),
|
|
128
154
|
);
|
|
129
155
|
const byHandle = expectOk<{ elements: { handle: string | null }[] }>(
|
|
130
|
-
buildStudioLook(snapshot({
|
|
156
|
+
buildStudioLook(snapshot({ scene: { status: "ready", items, drillInItem: null } }), {
|
|
157
|
+
filter: "hf:v2:demo:index.html:index.html:abc",
|
|
158
|
+
}),
|
|
131
159
|
);
|
|
132
160
|
|
|
133
|
-
expect(byLabel.elements.map((e) => e.handle)).toEqual(["hf:abc"]);
|
|
134
|
-
expect(byTag.elements.map((e) => e.handle)).toEqual(["dom:cta"]);
|
|
135
|
-
expect(byHandle.elements.map((e) => e.handle)).toEqual([
|
|
161
|
+
expect(byLabel.elements.map((e) => e.handle)).toEqual(["hf:v2:demo:index.html:index.html:abc"]);
|
|
162
|
+
expect(byTag.elements.map((e) => e.handle)).toEqual(["dom:v2:demo:index.html:index.html:cta"]);
|
|
163
|
+
expect(byHandle.elements.map((e) => e.handle)).toEqual([
|
|
164
|
+
"hf:v2:demo:index.html:index.html:abc",
|
|
165
|
+
]);
|
|
136
166
|
});
|
|
137
167
|
|
|
138
168
|
it("bounds a filter before normalizing it", () => {
|
|
139
169
|
const boundedFilter = "x".repeat(128);
|
|
140
170
|
const look = expectOk<{ elements: { handle: string | null }[] }>(
|
|
141
171
|
buildStudioLook(
|
|
142
|
-
snapshot({
|
|
172
|
+
snapshot({
|
|
173
|
+
scene: {
|
|
174
|
+
status: "ready",
|
|
175
|
+
items: [layer(boundedFilter, { id: "bounded" })],
|
|
176
|
+
drillInItem: null,
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
143
179
|
{ filter: `${boundedFilter}${"y".repeat(10_000)}` },
|
|
144
180
|
),
|
|
145
181
|
);
|
|
146
182
|
|
|
147
|
-
expect(look.elements.map((entry) => entry.handle)).toEqual([
|
|
183
|
+
expect(look.elements.map((entry) => entry.handle)).toEqual([
|
|
184
|
+
"dom:v2:demo:index.html:index.html:bounded",
|
|
185
|
+
]);
|
|
148
186
|
expect(STUDIO_LOOK_INPUT_SCHEMA.properties.filter.maxLength).toBe(128);
|
|
149
187
|
});
|
|
150
188
|
|
|
151
189
|
it("keeps the true match count when the list is truncated", () => {
|
|
152
|
-
const
|
|
153
|
-
element({ domId: `el-${index}`, label: "Card" }),
|
|
154
|
-
);
|
|
190
|
+
const items = Array.from({ length: 5 }, (_, index) => layer("Card", { id: `el-${index}` }));
|
|
155
191
|
|
|
156
|
-
const look = expectOk<{ elements: unknown[]; elementCount: number }>(
|
|
157
|
-
buildStudioLook(snapshot({
|
|
192
|
+
const look = expectOk<{ elements: unknown[]; elementCount: number; truncated: boolean }>(
|
|
193
|
+
buildStudioLook(snapshot({ scene: { status: "ready", items, drillInItem: null } }), {
|
|
194
|
+
filter: "card",
|
|
195
|
+
limit: 2,
|
|
196
|
+
}),
|
|
158
197
|
);
|
|
159
198
|
|
|
160
199
|
// A truncated list must not read as "that is all there is".
|
|
161
200
|
expect(look.elements).toHaveLength(2);
|
|
162
201
|
expect(look.elementCount).toBe(5);
|
|
202
|
+
expect(look.truncated).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("preserves the 200-item default and maximum response bound", () => {
|
|
206
|
+
const items = Array.from({ length: 205 }, (_, index) =>
|
|
207
|
+
layer(`Layer ${index}`, { id: `el-${index}` }),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const look = expectOk<{ elements: unknown[]; elementCount: number; truncated: boolean }>(
|
|
211
|
+
buildStudioLook(snapshot({ scene: { status: "ready", items, drillInItem: null } })),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(look.elements).toHaveLength(200);
|
|
215
|
+
expect(look.elementCount).toBe(205);
|
|
216
|
+
expect(look.truncated).toBe(true);
|
|
163
217
|
});
|
|
164
218
|
|
|
165
219
|
it("clamps a nonsense limit instead of failing the call", () => {
|
|
166
|
-
const
|
|
220
|
+
const items = [layer("A", { id: "a" }), layer("B", { id: "b" })];
|
|
167
221
|
|
|
168
222
|
for (const limit of [0, -3, 1.5, Number.NaN]) {
|
|
169
223
|
const look = expectOk<{ elements: unknown[] }>(
|
|
170
|
-
buildStudioLook(snapshot({
|
|
224
|
+
buildStudioLook(snapshot({ scene: { status: "ready", items, drillInItem: null } }), {
|
|
225
|
+
limit,
|
|
226
|
+
}),
|
|
171
227
|
);
|
|
172
228
|
expect(look.elements).toHaveLength(2);
|
|
173
229
|
}
|
|
@@ -178,7 +234,7 @@ describe("buildStudioLook", () => {
|
|
|
178
234
|
selection: { handle: string | null; box: { width: number }; can: { editStyles: boolean } };
|
|
179
235
|
}>(buildStudioLook(snapshot({ selection: selection() })));
|
|
180
236
|
|
|
181
|
-
expect(look.selection?.handle).toBe("hf:abc123");
|
|
237
|
+
expect(look.selection?.handle).toBe("hf:v2:demo:index.html:index.html:abc123");
|
|
182
238
|
expect(look.selection?.box.width).toBe(880);
|
|
183
239
|
expect(look.selection?.can.editStyles).toBe(true);
|
|
184
240
|
});
|
|
@@ -222,4 +278,95 @@ describe("buildStudioLook", () => {
|
|
|
222
278
|
expect(look).not.toHaveProperty("canWrite");
|
|
223
279
|
expect(look).not.toHaveProperty("writeBlockedReason");
|
|
224
280
|
});
|
|
281
|
+
|
|
282
|
+
it("returns DOM preorder with source-safe parents, DOM-only descendants, and optional timeline timing", () => {
|
|
283
|
+
const doc = sceneDoc();
|
|
284
|
+
const scene = collectStudioLookScene(doc, "index.html", null);
|
|
285
|
+
const look = expectOk<{
|
|
286
|
+
elements: Array<{
|
|
287
|
+
handle: string;
|
|
288
|
+
parentHandle: string | null;
|
|
289
|
+
depth: number;
|
|
290
|
+
sourceFile: string;
|
|
291
|
+
timeline: { start: number } | null;
|
|
292
|
+
}>;
|
|
293
|
+
}>(
|
|
294
|
+
buildStudioLook(
|
|
295
|
+
snapshot({
|
|
296
|
+
scene,
|
|
297
|
+
elements: [
|
|
298
|
+
element({ domId: "shell", sourceFile: "index.html", start: 2 }),
|
|
299
|
+
element({ domId: "duplicate", sourceFile: "compositions/nested.html", start: 4 }),
|
|
300
|
+
],
|
|
301
|
+
}),
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
expect(look.elements.map(({ sourceFile, depth }) => [sourceFile, depth])).toEqual([
|
|
306
|
+
["index.html", 0],
|
|
307
|
+
["index.html", 1],
|
|
308
|
+
["index.html", 1],
|
|
309
|
+
["compositions/nested.html", 1],
|
|
310
|
+
["compositions/nested.html", 2],
|
|
311
|
+
]);
|
|
312
|
+
expect(look.elements[1]?.parentHandle).toBe(look.elements[0]?.handle);
|
|
313
|
+
expect(look.elements[4]?.parentHandle).toBe(look.elements[3]?.handle);
|
|
314
|
+
expect(look.elements[2]?.timeline).toBeNull();
|
|
315
|
+
expect(look.elements[4]?.timeline?.start).toBe(4);
|
|
316
|
+
expect(look.elements[1]?.handle).not.toBe(look.elements[4]?.handle);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("reports the current drill-in group separately without narrowing the whole scene", () => {
|
|
320
|
+
const doc = sceneDoc();
|
|
321
|
+
const group = doc.getElementById("nested-parent") as HTMLElement;
|
|
322
|
+
const look = expectOk<{
|
|
323
|
+
drillIn: { handle: string; label: string } | null;
|
|
324
|
+
elements: unknown[];
|
|
325
|
+
}>(buildStudioLook(snapshot({ scene: collectStudioLookScene(doc, "index.html", group) })));
|
|
326
|
+
|
|
327
|
+
expect(look.elements).toHaveLength(5);
|
|
328
|
+
expect(look.drillIn?.handle).toContain("nested-parent");
|
|
329
|
+
});
|
|
225
330
|
});
|
|
331
|
+
|
|
332
|
+
function layer(
|
|
333
|
+
label: string,
|
|
334
|
+
overrides: Partial<{
|
|
335
|
+
id: string;
|
|
336
|
+
hfId: string;
|
|
337
|
+
selector: string;
|
|
338
|
+
selectorIndex: number;
|
|
339
|
+
sourceFile: string;
|
|
340
|
+
tagName: string;
|
|
341
|
+
depth: number;
|
|
342
|
+
childCount: number;
|
|
343
|
+
}> = {},
|
|
344
|
+
) {
|
|
345
|
+
const element = document.createElement(overrides.tagName ?? "div");
|
|
346
|
+
return {
|
|
347
|
+
key: label,
|
|
348
|
+
element,
|
|
349
|
+
label,
|
|
350
|
+
tagName: overrides.tagName ?? "div",
|
|
351
|
+
depth: overrides.depth ?? 0,
|
|
352
|
+
childCount: overrides.childCount ?? 0,
|
|
353
|
+
sourceFile: overrides.sourceFile ?? "index.html",
|
|
354
|
+
...overrides,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function sceneDoc(): Document {
|
|
359
|
+
const doc = document.implementation.createHTMLDocument();
|
|
360
|
+
doc.body.innerHTML = `<main data-composition-id="root" data-composition-file="index.html">
|
|
361
|
+
<section id="shell">
|
|
362
|
+
<span id="duplicate">root duplicate</span>
|
|
363
|
+
<em class="dom-only">no timeline</em>
|
|
364
|
+
<div data-composition-id="nested" data-composition-file="compositions/nested.html">
|
|
365
|
+
<section id="nested-parent" data-hf-group>
|
|
366
|
+
<span id="duplicate">nested duplicate</span>
|
|
367
|
+
</section>
|
|
368
|
+
</div>
|
|
369
|
+
</section>
|
|
370
|
+
</main>`;
|
|
371
|
+
return doc;
|
|
372
|
+
}
|