@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.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-CTQCs_KG.js → hyperframes-player-C-CArn13.js} +1 -1
  2. package/dist/assets/{index-okdE01ky.js → index-BIXyJgK5.js} +1 -1
  3. package/dist/assets/{index-Bq3M0sjr.js → index-D5yni1t6.js} +218 -218
  4. package/dist/assets/index-DnRfAiK2.css +1 -0
  5. package/dist/assets/{index-9WJFcXTP.js → index-pfeP2Y63.js} +1 -1
  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 +6230 -5019
  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/timelineTrackVisibility.test.ts +7 -1
  36. package/src/hooks/timelineTrackVisibility.ts +5 -5
  37. package/src/hooks/useDomEditAttributeCommits.ts +4 -1
  38. package/src/hooks/useDomEditCommits.test.tsx +243 -8
  39. package/src/hooks/useDomEditCommits.ts +69 -14
  40. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  41. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  42. package/src/hooks/useDomEditSession.ts +6 -2
  43. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  44. package/src/hooks/useDomEditTextCommits.ts +64 -86
  45. package/src/hooks/useDomEditWiring.ts +1 -1
  46. package/src/hooks/useDomSelection.ts +2 -0
  47. package/src/hooks/useDomSelectionTypes.ts +2 -0
  48. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  49. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  50. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  51. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  52. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  53. package/src/hooks/useGsapAnimationOps.ts +4 -4
  54. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  55. package/src/hooks/useGsapAwareEditing.ts +31 -10
  56. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  57. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  58. package/src/hooks/useRenderClipContent.test.ts +27 -0
  59. package/src/hooks/useRenderClipContent.ts +5 -0
  60. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  61. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  62. package/src/player/components/CompositionThumbnail.tsx +6 -0
  63. package/src/player/hooks/timelineSyncHydration.ts +10 -2
  64. package/src/player/store/playerStore.test.ts +24 -0
  65. package/src/player/store/thumbnailSlice.ts +6 -0
  66. package/src/styles/studio.css +179 -0
  67. package/src/utils/domEditSaveQueue.test.ts +5 -3
  68. package/src/utils/domEditSaveQueue.ts +6 -1
  69. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  70. package/src/webmcp/StudioAgentTools.tsx +89 -21
  71. package/src/webmcp/handles.test.ts +172 -15
  72. package/src/webmcp/handles.ts +152 -35
  73. package/src/webmcp/tools/animationTools.test.ts +241 -60
  74. package/src/webmcp/tools/animationTools.ts +191 -104
  75. package/src/webmcp/tools/contentTools.test.ts +103 -41
  76. package/src/webmcp/tools/contentTools.ts +149 -105
  77. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  78. package/src/webmcp/tools/inspectTools.ts +59 -19
  79. package/src/webmcp/tools/lookTools.test.ts +185 -38
  80. package/src/webmcp/tools/lookTools.ts +134 -21
  81. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  82. package/src/webmcp/tools/selectionTools.ts +34 -19
  83. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  84. package/src/webmcp/tools/transformTools.test.ts +140 -54
  85. package/src/webmcp/tools/transformTools.ts +215 -56
  86. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  87. package/src/webmcp/useStudioAgentTools.ts +27 -15
  88. package/src/webmcp/webmcpTestUtils.ts +37 -0
  89. package/src/webmcp/writeCoordinator.test.ts +375 -0
  90. package/src/webmcp/writeCoordinator.ts +473 -0
  91. package/dist/assets/index-yGhfxxoL.css +0 -1
  92. package/dist/chunk-6BT6DTB4.js.map +0 -1
  93. /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
- getPreviewDocument: () => null,
29
- buildSelection: async (element) => selectionFor(element),
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 { mintElementHandle, patchTargetAddress, resolveElementHandle } from "../handles";
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
- let animationEditingBlocked: string | null = null;
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: mintElementHandle(patchTargetAddress(selection)),
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 doc = deps.getPreviewDocument();
170
- if (!doc) return toolFailure("blocked", "the preview is not mounted yet");
171
-
172
- const element = resolveElementHandle(doc, input.handle);
173
- if (!element) {
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(selection, deps, current?.element === element);
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 { buildStudioLook, STUDIO_LOOK_INPUT_SCHEMA, type StudioLookSnapshot } from "./lookTools";
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
- elements: [
88
- element({ hfId: "abc", label: "Headline" }),
89
- element({ domId: "cta", label: "Button" }),
90
- element({ selector: ".card", selectorIndex: 2, label: "Card" }),
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(["hf:abc", "dom:cta", "sel:.card#2"]);
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("reports an unaddressable element with a null handle rather than hiding it", () => {
100
- const look = expectOk<{ elements: { handle: string | null }[]; elementCount: number }>(
101
- buildStudioLook(snapshot({ elements: [element({ label: "Anonymous" })] })),
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(look.elementCount).toBe(1);
105
- expect(look.elements[0]?.handle).toBeNull();
121
+ expect(ready).toMatchObject({ sceneStatus: "ready", elements: [], elementCount: 0 });
122
+ expect(loading).toMatchObject({ sceneStatus: "loading", elements: [], elementCount: 0 });
106
123
  });
107
124
 
108
- it("returns an empty list for an empty timeline, not a failure", () => {
109
- const look = expectOk<{ elements: unknown[]; elementCount: number }>(
110
- buildStudioLook(snapshot()),
111
- );
112
-
113
- expect(look.elements).toEqual([]);
114
- expect(look.elementCount).toBe(0);
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 elements = [
119
- element({ hfId: "abc", label: "Headline", tag: "h1" }),
120
- element({ domId: "cta", label: "Button", tag: "button" }),
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({ elements }), { filter: "HEADLINE" }),
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({ elements }), { filter: "button" }),
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({ elements }), { filter: "hf:abc" }),
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(["hf:abc"]);
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({ elements: [element({ domId: "bounded", label: boundedFilter })] }),
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(["dom:bounded"]);
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 elements = Array.from({ length: 5 }, (_, index) =>
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({ elements }), { limit: 2 }),
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 elements = [element({ domId: "a" }), element({ domId: "b" })];
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({ elements }), { limit }),
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
+ }