@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
@@ -1,9 +1,49 @@
1
- import { useCallback, useMemo } from "react";
1
+ import { useCallback, useEffect, useMemo } from "react";
2
2
  import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
3
3
  import { useStudioShellContext } from "../contexts/StudioContext";
4
4
  import { usePlayerStore } from "../player";
5
5
  import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
6
- import type { StudioLookSnapshot } from "./tools/lookTools";
6
+ import { collectStudioLookScene, type StudioLookSnapshot } from "./tools/lookTools";
7
+ import { studioEditLifecycle } from "./writeCoordinator";
8
+ import { findElementForSelection } from "../components/editor/domEditingElement";
9
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
+ import {
11
+ applyStudioBoxSizeDraft,
12
+ captureStudioBoxSize,
13
+ restoreStudioBoxSize,
14
+ } from "../components/editor/manualEdits";
15
+
16
+ export function readLiveSelectionBox(
17
+ doc: Document | null | undefined,
18
+ selection: DomEditSelection,
19
+ activeCompositionPath: string | null,
20
+ ) {
21
+ const liveElement = doc
22
+ ? findElementForSelection(doc, selection, activeCompositionPath)
23
+ : selection.element;
24
+ if (!liveElement) throw new Error("the target is missing from the current Studio preview");
25
+ const rect = liveElement.getBoundingClientRect();
26
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
27
+ }
28
+
29
+ type ResizeCommit = (
30
+ selection: DomEditSelection,
31
+ next: { width: number; height: number },
32
+ offset?: { x: number; y: number },
33
+ restore?: () => void,
34
+ ) => Promise<void>;
35
+
36
+ export function resizeSelectionFromAgent(
37
+ selection: DomEditSelection,
38
+ next: { width: number; height: number },
39
+ commit: ResizeCommit,
40
+ ): Promise<void> {
41
+ const previous = captureStudioBoxSize(selection.element);
42
+ applyStudioBoxSizeDraft(selection.element, next);
43
+ return commit(selection, next, undefined, () => {
44
+ restoreStudioBoxSize(selection.element, previous);
45
+ });
46
+ }
7
47
 
8
48
  /**
9
49
  * Mounts Studio's WebMCP tool surface. Renders nothing.
@@ -20,6 +60,7 @@ export function StudioAgentTools() {
20
60
  const { projectId, activeCompPath, editHistory, writeBlockedReason } = useStudioShellContext();
21
61
  const {
22
62
  domEditSelection,
63
+ activeGroupElement,
23
64
  selectedGsapAnimations,
24
65
  gsapMultipleTimelines,
25
66
  gsapUnsupportedTimelinePattern,
@@ -28,8 +69,8 @@ export function StudioAgentTools() {
28
69
  previewIframeRef,
29
70
  buildDomSelectionFromTarget,
30
71
  applyDomSelection,
31
- handleDomTextCommit,
32
- handleDomStyleCommit,
72
+ handleDomTextCommitForSelection,
73
+ handleDomStyleCommitForSelection,
33
74
  handleDomPathOffsetCommit,
34
75
  handleDomBoxSizeCommit,
35
76
  handleDomRotationCommit,
@@ -37,8 +78,14 @@ export function StudioAgentTools() {
37
78
  handleGsapUpdateMeta,
38
79
  handleGsapAddKeyframeBatch,
39
80
  handleGsapDeleteAnimation,
81
+ getGsapAnimationsForSelection,
40
82
  } = useDomEditActionsContext();
41
83
 
84
+ useEffect(() => {
85
+ studioEditLifecycle.activateProject(projectId);
86
+ return () => studioEditLifecycle.reset();
87
+ }, [projectId]);
88
+
42
89
  const getSnapshot = useCallback((): StudioLookSnapshot => {
43
90
  const player = usePlayerStore.getState();
44
91
  return {
@@ -48,6 +95,11 @@ export function StudioAgentTools() {
48
95
  duration: player.duration,
49
96
  isPlaying: player.isPlaying,
50
97
  elements: player.elements,
98
+ scene: collectStudioLookScene(
99
+ previewIframeRef.current?.contentDocument ?? null,
100
+ activeCompPath,
101
+ activeGroupElement,
102
+ ),
51
103
  selection: domEditSelection,
52
104
  selectionAnimationCount: selectedGsapAnimations.length,
53
105
  history: {
@@ -57,13 +109,21 @@ export function StudioAgentTools() {
57
109
  redoLabel: editHistory.redoLabel ?? null,
58
110
  },
59
111
  };
60
- }, [projectId, activeCompPath, domEditSelection, selectedGsapAnimations, editHistory]);
112
+ }, [
113
+ projectId,
114
+ activeCompPath,
115
+ domEditSelection,
116
+ activeGroupElement,
117
+ selectedGsapAnimations,
118
+ editHistory,
119
+ previewIframeRef,
120
+ ]);
61
121
 
62
122
  const deps = useMemo<StudioAgentToolsDeps>(
63
123
  () => ({
64
124
  getSnapshot,
65
125
  getPreviewDocument: () => previewIframeRef.current?.contentDocument ?? null,
66
- buildSelection: (element) => buildDomSelectionFromTarget(element),
126
+ buildSelection: (element) => buildDomSelectionFromTarget(element, { exactTarget: true }),
67
127
  applySelection: (selection) => applyDomSelection(selection, { revealPanel: true }),
68
128
  requestSeek: (time) => usePlayerStore.getState().requestSeek(time),
69
129
  readPlayhead: () => {
@@ -90,22 +150,29 @@ export function StudioAgentTools() {
90
150
  wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
91
151
  getCurrentSelection: () => domEditSelection,
92
152
  getWriteBlockedReason: () => writeBlockedReason,
93
- setText: (value, fieldKey) => handleDomTextCommit(value, fieldKey),
94
- setStyle: (property, value) => handleDomStyleCommit(property, value),
153
+ setText: (selection, value, fieldKey) =>
154
+ handleDomTextCommitForSelection(selection, value, fieldKey),
155
+ setStyle: (selection, property, value) =>
156
+ handleDomStyleCommitForSelection(selection, property, value),
95
157
  // Measured, not authored: the tool compares this before and after to
96
- // tell a real change from a handler that did nothing and resolved.
97
- readBox: (selection) => {
98
- const rect = selection.element.getBoundingClientRect();
99
- return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
100
- },
158
+ // tell a real change from a handler that did nothing and resolved. A
159
+ // successful commit may replace the preview document, so re-resolve the
160
+ // source-safe selection instead of measuring its detached old node.
161
+ readBox: (selection) =>
162
+ readLiveSelectionBox(previewIframeRef.current?.contentDocument, selection, activeCompPath),
101
163
  moveTo: (selection, next) => handleDomPathOffsetCommit(selection, next),
102
- resizeTo: (selection, next) => handleDomBoxSizeCommit(selection, next),
164
+ resizeTo: (selection, next) =>
165
+ resizeSelectionFromAgent(selection, next, handleDomBoxSizeCommit),
103
166
  rotateTo: (selection, next) => handleDomRotationCommit(selection, next),
104
- addAnimation: (method) => handleGsapAddAnimation(method),
105
- updateAnimation: (animationId, updates) => handleGsapUpdateMeta(animationId, updates),
106
- addKeyframe: (animationId, percent, properties) =>
107
- handleGsapAddKeyframeBatch(animationId, percent, properties),
108
- deleteAnimation: (animationId) => handleGsapDeleteAnimation(animationId),
167
+ addAnimation: (selection, method) => handleGsapAddAnimation(method, selection),
168
+ updateAnimation: (selection, animationId, updates) =>
169
+ handleGsapUpdateMeta(animationId, updates, selection),
170
+ addKeyframe: (selection, animationId, percent, properties) =>
171
+ handleGsapAddKeyframeBatch(animationId, percent, properties, undefined, selection),
172
+ deleteAnimation: (selection, animationId) =>
173
+ handleGsapDeleteAnimation(animationId, selection),
174
+ getAnimationsForSelection: async (selection) =>
175
+ await getGsapAnimationsForSelection(selection),
109
176
  getGsapDiagnostics: () => ({
110
177
  animations: selectedGsapAnimations,
111
178
  multipleTimelines: gsapMultipleTimelines,
@@ -120,8 +187,8 @@ export function StudioAgentTools() {
120
187
  projectId,
121
188
  activeCompPath,
122
189
  writeBlockedReason,
123
- handleDomTextCommit,
124
- handleDomStyleCommit,
190
+ handleDomTextCommitForSelection,
191
+ handleDomStyleCommitForSelection,
125
192
  handleDomPathOffsetCommit,
126
193
  handleDomBoxSizeCommit,
127
194
  handleDomRotationCommit,
@@ -129,6 +196,7 @@ export function StudioAgentTools() {
129
196
  handleGsapUpdateMeta,
130
197
  handleGsapAddKeyframeBatch,
131
198
  handleGsapDeleteAnimation,
199
+ getGsapAnimationsForSelection,
132
200
  domEditSelection,
133
201
  selectedGsapAnimations,
134
202
  gsapMultipleTimelines,
@@ -5,23 +5,15 @@ import {
5
5
  mintElementHandle,
6
6
  parseElementHandle,
7
7
  resolveElementHandle,
8
+ resolveLiveHandleSelection,
8
9
  timelineElementAddress,
9
10
  } from "./handles";
11
+ import { previewDoc } from "./webmcpTestUtils";
10
12
 
11
13
  function timelineElement(overrides: Partial<TimelineElement>): TimelineElement {
12
14
  return { id: "synthetic-id", tag: "div", start: 0, duration: 1, track: 0, ...overrides };
13
15
  }
14
16
 
15
- /** A separate document, standing in for the preview iframe's realm. */
16
- function previewDoc(html: string): Document {
17
- const iframe = document.createElement("iframe");
18
- document.body.append(iframe);
19
- const doc = iframe.contentDocument;
20
- if (!doc) throw new Error("expected iframe document");
21
- doc.body.innerHTML = html;
22
- return doc;
23
- }
24
-
25
17
  describe("mintElementHandle", () => {
26
18
  it("prefers data-hf-id, the stable patch target", () => {
27
19
  const handle = mintElementHandle(
@@ -29,7 +21,7 @@ describe("mintElementHandle", () => {
29
21
  timelineElement({ hfId: "abc123", domId: "headline", selector: ".title" }),
30
22
  ),
31
23
  );
32
- expect(handle).toBe("hf:abc123");
24
+ expect(handle).toBe("hf:v1:index.html:index.html:abc123");
33
25
  });
34
26
 
35
27
  it("falls back to the DOM id when there is no hf id", () => {
@@ -37,7 +29,7 @@ describe("mintElementHandle", () => {
37
29
  mintElementHandle(
38
30
  timelineElementAddress(timelineElement({ domId: "headline", selector: ".title" })),
39
31
  ),
40
- ).toBe("dom:headline");
32
+ ).toBe("dom:v1:index.html:index.html:headline");
41
33
  });
42
34
 
43
35
  it("falls back to a selector with its occurrence index", () => {
@@ -45,12 +37,12 @@ describe("mintElementHandle", () => {
45
37
  mintElementHandle(
46
38
  timelineElementAddress(timelineElement({ selector: ".card", selectorIndex: 2 })),
47
39
  ),
48
- ).toBe("sel:.card#2");
40
+ ).toBe("sel:v1:index.html:index.html:.card#2");
49
41
  });
50
42
 
51
43
  it("defaults a missing occurrence index to the first match", () => {
52
44
  expect(mintElementHandle(timelineElementAddress(timelineElement({ selector: ".card" })))).toBe(
53
- "sel:.card#0",
45
+ "sel:v1:index.html:index.html:.card#0",
54
46
  );
55
47
  });
56
48
 
@@ -60,17 +52,110 @@ describe("mintElementHandle", () => {
60
52
  });
61
53
  });
62
54
 
55
+ describe("resolveLiveHandleSelection", () => {
56
+ it("reports a transient change when the replacement preview has not rebuilt the target", async () => {
57
+ const firstDoc = previewDoc('<div id="headline">before</div>');
58
+ const replacementDoc = previewDoc("<main>reloading</main>");
59
+ let currentDoc = firstDoc;
60
+
61
+ const result = await resolveLiveHandleSelection(
62
+ () => currentDoc,
63
+ "dom:headline",
64
+ async (element) => {
65
+ currentDoc = replacementDoc;
66
+ return { element };
67
+ },
68
+ );
69
+
70
+ expect(result).toEqual({ status: "changed" });
71
+ });
72
+
73
+ it("reports a stable missing target as not found", async () => {
74
+ const doc = previewDoc("<main>settled</main>");
75
+
76
+ const result = await resolveLiveHandleSelection(
77
+ () => doc,
78
+ "dom:headline",
79
+ async (element) => ({ element }),
80
+ );
81
+
82
+ expect(result).toEqual({ status: "not-found" });
83
+ });
84
+ });
85
+
63
86
  describe("parseElementHandle", () => {
87
+ it("round-trips source ownership and the active composition", () => {
88
+ expect(
89
+ parseElementHandle(
90
+ mintElementHandle({
91
+ domId: "headline:hero",
92
+ sourceFile: "compositions/hero.html",
93
+ activeCompositionPath: "index.html",
94
+ })!,
95
+ ),
96
+ ).toEqual({
97
+ scheme: "dom",
98
+ version: 1,
99
+ value: "headline:hero",
100
+ index: 0,
101
+ sourceFile: "compositions/hero.html",
102
+ activeCompositionPath: "index.html",
103
+ });
104
+ });
105
+
106
+ it("encodes path, selector, colon, and hash delimiters without ambiguity", () => {
107
+ const handle = mintElementHandle({
108
+ selector: '#hero[data-label="a:b"] > .card:nth-child(2)',
109
+ selectorIndex: 3,
110
+ sourceFile: "compositions/a:b#hero.html",
111
+ activeCompositionPath: "scenes/root:wide.html",
112
+ });
113
+
114
+ expect(parseElementHandle(handle!)).toEqual({
115
+ scheme: "sel",
116
+ version: 1,
117
+ value: '#hero[data-label="a:b"] > .card:nth-child(2)',
118
+ index: 3,
119
+ sourceFile: "compositions/a:b#hero.html",
120
+ activeCompositionPath: "scenes/root:wide.html",
121
+ });
122
+ });
123
+
64
124
  it("splits the index off the LAST hash, so id selectors survive", () => {
65
125
  expect(parseElementHandle("sel:#card > .title#3")).toEqual({
66
126
  scheme: "sel",
127
+ version: 0,
67
128
  value: "#card > .title",
68
129
  index: 3,
69
130
  });
70
131
  });
71
132
 
72
133
  it("treats a selector with no index as the first match", () => {
73
- expect(parseElementHandle("sel:.card")).toEqual({ scheme: "sel", value: ".card", index: 0 });
134
+ expect(parseElementHandle("sel:.card")).toEqual({
135
+ scheme: "sel",
136
+ version: 0,
137
+ value: ".card",
138
+ index: 0,
139
+ });
140
+ });
141
+
142
+ it("round-trips the project identity in a writable v2 handle", () => {
143
+ const handle = mintElementHandle({
144
+ projectId: "project:demo",
145
+ domId: "headline",
146
+ sourceFile: "index.html",
147
+ activeCompositionPath: "index.html",
148
+ });
149
+
150
+ expect(parseElementHandle(handle!)).toEqual({
151
+ scheme: "dom",
152
+ version: 2,
153
+ projectId: "project:demo",
154
+ value: "headline",
155
+ sourceFile: "index.html",
156
+ activeCompositionPath: "index.html",
157
+ index: 0,
158
+ });
74
159
  });
75
160
 
76
161
  it("rejects an unknown scheme", () => {
@@ -106,6 +191,78 @@ describe("resolveElementHandle", () => {
106
191
  expect(resolved instanceof HTMLElement).toBe(false);
107
192
  });
108
193
 
194
+ it("distinguishes duplicate authored ids by source file", () => {
195
+ const doc = previewDoc(
196
+ `<main data-composition-id="root" data-composition-file="index.html">
197
+ <div id="duplicate">root</div>
198
+ <section data-composition-id="nested" data-composition-file="compositions/nested.html">
199
+ <div id="duplicate">nested</div>
200
+ </section>
201
+ </main>`,
202
+ );
203
+ const rootHandle = mintElementHandle({
204
+ domId: "duplicate",
205
+ sourceFile: "index.html",
206
+ activeCompositionPath: "index.html",
207
+ });
208
+ const nestedHandle = mintElementHandle({
209
+ domId: "duplicate",
210
+ sourceFile: "compositions/nested.html",
211
+ activeCompositionPath: "index.html",
212
+ });
213
+
214
+ expect(rootHandle).not.toBe(nestedHandle);
215
+ expect(resolveElementHandle(doc, rootHandle!)?.textContent).toBe("root");
216
+ expect(resolveElementHandle(doc, nestedHandle!)?.textContent).toBe("nested");
217
+ });
218
+
219
+ it("keeps selector occurrence indexes scoped to their source file", () => {
220
+ const doc = previewDoc(
221
+ `<main data-composition-id="root" data-composition-file="index.html">
222
+ <div class="card">root first</div><div class="card">root second</div>
223
+ <section data-composition-id="nested" data-composition-file="compositions/nested.html">
224
+ <div class="card">nested first</div><div class="card">nested second</div>
225
+ </section>
226
+ </main>`,
227
+ );
228
+ const rootSecond = mintElementHandle({
229
+ selector: ".card",
230
+ selectorIndex: 1,
231
+ sourceFile: "index.html",
232
+ activeCompositionPath: "index.html",
233
+ });
234
+ const nestedSecond = mintElementHandle({
235
+ selector: ".card",
236
+ selectorIndex: 1,
237
+ sourceFile: "compositions/nested.html",
238
+ activeCompositionPath: "index.html",
239
+ });
240
+
241
+ expect(resolveElementHandle(doc, rootSecond!)?.textContent).toBe("root second");
242
+ expect(resolveElementHandle(doc, nestedSecond!)?.textContent).toBe("nested second");
243
+ });
244
+
245
+ it("re-resolves a scoped handle against the live document after reload", () => {
246
+ const handle = mintElementHandle({
247
+ domId: "headline",
248
+ sourceFile: "index.html",
249
+ activeCompositionPath: "index.html",
250
+ });
251
+ const firstDoc = previewDoc(
252
+ '<main data-composition-id="root" data-composition-file="index.html"><div id="headline">before</div></main>',
253
+ );
254
+ const first = resolveElementHandle(firstDoc, handle!);
255
+ const reloadedDoc = previewDoc(
256
+ '<main data-composition-id="root" data-composition-file="index.html"><div id="headline">after</div></main>',
257
+ );
258
+ const reloaded = resolveElementHandle(reloadedDoc, handle!);
259
+
260
+ expect(first?.textContent).toBe("before");
261
+ expect(reloaded?.textContent).toBe("after");
262
+ expect(reloaded).not.toBe(first);
263
+ expect(reloaded?.ownerDocument).toBe(reloadedDoc);
264
+ });
265
+
109
266
  it("returns null for a handle that no longer matches", () => {
110
267
  const doc = previewDoc('<div id="headline">A</div>');
111
268
  expect(resolveElementHandle(doc, "dom:deleted")).toBeNull();