@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
@@ -200,8 +200,13 @@ export function useGsapSelectionHandlers({
200
200
  const handleGsapDeleteAnimation = useCallback(
201
201
  (animId: string, selectionOverride?: DomEditSelection | null) => {
202
202
  const sel = resolveWriteSelection(selectionOverride);
203
- if (!sel) return;
204
- observeGsapMutation(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation");
203
+ if (!sel) return Promise.resolve(false);
204
+ return observeGsapMutation(
205
+ deleteGsapAnimation(sel, animId),
206
+ sel,
207
+ "delete",
208
+ "Delete GSAP animation",
209
+ );
205
210
  },
206
211
  [resolveWriteSelection, deleteGsapAnimation, observeGsapMutation],
207
212
  );
@@ -222,20 +227,25 @@ export function useGsapSelectionHandlers({
222
227
  );
223
228
 
224
229
  const handleGsapAddAnimation = useCallback(
225
- (method: "to" | "from" | "set" | "fromTo") => {
226
- if (!domEditSelection) return;
227
- void addGsapAnimation(domEditSelection, method, usePlayerStore.getState().currentTime).catch(
228
- (error) => {
229
- trackGsapHandlerFailure(error, domEditSelection, "add", `Add GSAP ${method} animation`);
230
- },
230
+ (method: "to" | "from" | "set" | "fromTo", selectionOverride?: DomEditSelection | null) => {
231
+ const selection = resolveWriteSelection(selectionOverride);
232
+ if (!selection) return Promise.resolve(false);
233
+ const landed = observeGsapMutation(
234
+ addGsapAnimation(selection, method, usePlayerStore.getState().currentTime),
235
+ selection,
236
+ "add",
237
+ `Add GSAP ${method} animation`,
231
238
  );
232
- if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
239
+ if (selection.element.hasAttribute("data-hf-studio-path-offset")) {
233
240
  // The reset owns rollback and the position commit already owns user and
234
241
  // telemetry reporting. This is only the fire-and-forget UI boundary.
235
- void handleDomManualEditsReset(domEditSelection).catch(() => undefined);
242
+ void landed.then((didLand) => {
243
+ if (didLand) void handleDomManualEditsReset(selection).catch(() => undefined);
244
+ });
236
245
  }
246
+ return landed;
237
247
  },
238
- [domEditSelection, addGsapAnimation, handleDomManualEditsReset, trackGsapHandlerFailure],
248
+ [resolveWriteSelection, addGsapAnimation, handleDomManualEditsReset, observeGsapMutation],
239
249
  );
240
250
 
241
251
  const handleGsapAddProperty = useCallback(
@@ -241,4 +241,31 @@ describe("useRenderClipContent", () => {
241
241
  });
242
242
  }
243
243
  });
244
+
245
+ it("forwards persisted content revision to mounted composition thumbnails", () => {
246
+ usePlayerStore.setState({
247
+ thumbnailMode: "adaptive",
248
+ timelineSessionEpoch: 7,
249
+ thumbnailContentRevision: 11,
250
+ });
251
+
252
+ const content = renderClipContent({
253
+ id: "nested",
254
+ tag: "div",
255
+ start: 0,
256
+ duration: 4,
257
+ track: 0,
258
+ compositionSrc: "compositions/nested.html",
259
+ });
260
+
261
+ expect(isValidElement(content)).toBe(true);
262
+ if (isValidElement(content)) {
263
+ expect(content.type).toBe(CompositionThumbnail);
264
+ expect(content.props).toMatchObject({
265
+ projectId: "my-project",
266
+ sessionEpoch: 7,
267
+ contentRevision: 11,
268
+ });
269
+ }
270
+ });
244
271
  });
@@ -110,6 +110,7 @@ export function useRenderClipContent({
110
110
  const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
111
111
  const effectiveMode = effectiveThumbnailMode(thumbnailMode);
112
112
  const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
113
+ const contentRevision = usePlayerStore((s) => s.thumbnailContentRevision);
113
114
  return useCallback(
114
115
  // Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
115
116
  // fallow-ignore-next-line complexity
@@ -153,6 +154,7 @@ export function useRenderClipContent({
153
154
  duration: el.duration,
154
155
  projectId: pid,
155
156
  sessionEpoch,
157
+ contentRevision,
156
158
  priority: context.priority,
157
159
  rich: context.rich,
158
160
  });
@@ -179,6 +181,7 @@ export function useRenderClipContent({
179
181
  duration: el.duration,
180
182
  projectId: pid,
181
183
  sessionEpoch,
184
+ contentRevision,
182
185
  priority: context.priority,
183
186
  rich: context.rich,
184
187
  });
@@ -232,6 +235,7 @@ export function useRenderClipContent({
232
235
  duration: el.duration,
233
236
  projectId: pid,
234
237
  sessionEpoch,
238
+ contentRevision,
235
239
  priority: context.priority,
236
240
  rich: context.rich,
237
241
  });
@@ -246,6 +250,7 @@ export function useRenderClipContent({
246
250
  effectiveTimelineDuration,
247
251
  effectiveMode,
248
252
  sessionEpoch,
253
+ contentRevision,
249
254
  ],
250
255
  );
251
256
  }
@@ -1,4 +1,5 @@
1
1
  import { useCallback, type MutableRefObject } from "react";
2
+ import { usePlayerStore } from "../player/store/playerStore";
2
3
  import {
3
4
  deleteExternalConflictSnapshot,
4
5
  loadExternalConflictSnapshot,
@@ -48,6 +49,9 @@ export function useStudioExternalFileChanges({
48
49
  }: UseStudioExternalFileChangesOptions) {
49
50
  const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
50
51
  const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
52
+ const bumpThumbnailContentRevision = usePlayerStore(
53
+ (state) => state.bumpThumbnailContentRevision,
54
+ );
51
55
  const drainPendingChanges = useCallback(async () => {
52
56
  const source = await flushPendingSourceSave();
53
57
  if (source.status !== "clean") return source;
@@ -77,5 +81,6 @@ export function useStudioExternalFileChanges({
77
81
  readProjectFile: fileManager.readProjectFile,
78
82
  onUseExternalFile: fileManager.updateEditingFileContent,
79
83
  resetSaveQueues: resetDomEditSaveQueueBreaker,
84
+ onAcceptedPersistedFileChange: bumpThumbnailContentRevision,
80
85
  });
81
86
  }
@@ -73,7 +73,7 @@ describe("buildCompositionThumbnailUrl", () => {
73
73
  origin: "http://localhost:3000",
74
74
  }),
75
75
  ).toBe(
76
- "http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&selector=.card&selectorIndex=2",
76
+ "http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&revision=0&selector=.card&selectorIndex=2",
77
77
  );
78
78
  });
79
79
 
@@ -91,6 +91,16 @@ describe("buildCompositionThumbnailUrl", () => {
91
91
  "output=storyboard",
92
92
  );
93
93
  });
94
+
95
+ it("includes the persisted content revision in the cache identity", () => {
96
+ const url = buildCompositionThumbnailUrl({
97
+ previewUrl: "/api/projects/demo/preview",
98
+ origin: "http://localhost:3000",
99
+ contentRevision: 7,
100
+ });
101
+
102
+ expect(new URL(url).searchParams.get("revision")).toBe("7");
103
+ });
94
104
  });
95
105
 
96
106
  describe("CompositionThumbnail", () => {
@@ -148,4 +158,59 @@ describe("CompositionThumbnail", () => {
148
158
  expect(probe.src).toBe("");
149
159
  expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:composition-thumbnail");
150
160
  });
161
+
162
+ it("releases the old request and ignores its late result when persisted content changes", async () => {
163
+ const signals: AbortSignal[] = [];
164
+ const resolveFetches: Array<(response: Response) => void> = [];
165
+ globalThis.fetch = vi.fn((_url, init) => {
166
+ signals.push(init?.signal as AbortSignal);
167
+ return new Promise<Response>((resolve) => resolveFetches.push(resolve));
168
+ });
169
+ root = createRoot(host);
170
+
171
+ await act(async () => {
172
+ root!.render(
173
+ React.createElement(CompositionThumbnail, {
174
+ previewUrl: "/api/projects/demo/preview",
175
+ label: "",
176
+ labelColor: "#fff",
177
+ projectId: "demo",
178
+ contentRevision: 0,
179
+ }),
180
+ );
181
+ await new Promise((resolve) => setTimeout(resolve, 0));
182
+ });
183
+ await act(async () => {
184
+ root!.render(
185
+ React.createElement(CompositionThumbnail, {
186
+ previewUrl: "/api/projects/demo/preview",
187
+ label: "",
188
+ labelColor: "#fff",
189
+ projectId: "demo",
190
+ contentRevision: 1,
191
+ }),
192
+ );
193
+ await new Promise((resolve) => setTimeout(resolve, 0));
194
+ });
195
+
196
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
197
+ expect(signals[0]?.aborted).toBe(true);
198
+ expect(signals[1]?.aborted).toBe(false);
199
+ expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[1]?.[0]).toContain(
200
+ "revision=1",
201
+ );
202
+
203
+ await act(async () => {
204
+ resolveFetches[0]?.(new Response(new Blob(["stale"]), { status: 200 }));
205
+ await new Promise((resolve) => setTimeout(resolve, 0));
206
+ });
207
+ expect(MockImage.instances).toHaveLength(0);
208
+
209
+ await act(async () => {
210
+ resolveFetches[1]?.(new Response(new Blob(["fresh"]), { status: 200 }));
211
+ await new Promise((resolve) => setTimeout(resolve, 0));
212
+ });
213
+ expect(MockImage.instances).toHaveLength(1);
214
+ expect(MockImage.instances[0]?.src).toBe("blob:composition-thumbnail");
215
+ });
151
216
  });
@@ -17,6 +17,7 @@ interface CompositionThumbnailProps {
17
17
  height?: number;
18
18
  projectId?: string;
19
19
  sessionEpoch?: number;
20
+ contentRevision?: number;
20
21
  priority?: ThumbnailPriority;
21
22
  rich?: boolean;
22
23
  }
@@ -32,6 +33,7 @@ export function buildCompositionThumbnailUrl({
32
33
  selectorIndex,
33
34
  origin,
34
35
  output,
36
+ contentRevision = 0,
35
37
  }: {
36
38
  previewUrl: string;
37
39
  seekTime?: number;
@@ -46,6 +48,7 @@ export function buildCompositionThumbnailUrl({
46
48
  * high-density review size; `"source"` uses the composition's own dimensions.
47
49
  */
48
50
  output?: "source" | "storyboard";
51
+ contentRevision?: number;
49
52
  }): string {
50
53
  const thumbnailBase = previewUrl
51
54
  .replace("/preview/comp/", "/thumbnail/")
@@ -53,6 +56,7 @@ export function buildCompositionThumbnailUrl({
53
56
  const thumbnailUrl = new URL(thumbnailBase, origin);
54
57
  thumbnailUrl.searchParams.set("t", (seekTime + duration / 2).toFixed(2));
55
58
  thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
59
+ thumbnailUrl.searchParams.set("revision", String(contentRevision));
56
60
  if (output) thumbnailUrl.searchParams.set("output", output);
57
61
  if (selector) {
58
62
  thumbnailUrl.searchParams.set("selector", selector);
@@ -96,6 +100,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
96
100
  duration = 5,
97
101
  projectId = previewUrl,
98
102
  sessionEpoch = 0,
103
+ contentRevision = 0,
99
104
  priority = "visible",
100
105
  }: CompositionThumbnailProps) {
101
106
  const [containerWidth, setContainerWidth] = useState(0);
@@ -107,6 +112,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
107
112
  selector,
108
113
  selectorIndex,
109
114
  origin: window.location.origin,
115
+ contentRevision,
110
116
  });
111
117
  const request = useMemo(
112
118
  () => ({
@@ -290,6 +290,15 @@ export function resolveReloadSeekTime(input: {
290
290
  return Math.min(target, input.duration);
291
291
  }
292
292
 
293
+ type SeekablePreview = Pick<PlaybackAdapter, "seek">;
294
+
295
+ /** Re-run the current frame even when the runtime already reports that time. */
296
+ export function reseekPreviewAtTime(preview: SeekablePreview, time: number): void {
297
+ const target = Number.isFinite(time) && time > 0 ? time : 0;
298
+ preview.seek(target > 0.001 ? Math.max(0, target - 0.001) : 0.001);
299
+ preview.seek(target);
300
+ }
301
+
293
302
  export function seekAdapterToRestorePoint(
294
303
  adapter: PlaybackAdapter,
295
304
  pendingSeekRef: { current: number | null },
@@ -303,8 +312,7 @@ export function seekAdapterToRestorePoint(
303
312
  });
304
313
  pendingSeekRef.current = null;
305
314
  if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
306
- adapter.seek(startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001);
307
- adapter.seek(startTime);
315
+ reseekPreviewAtTime(adapter, startTime);
308
316
  return startTime;
309
317
  }
310
318
 
@@ -30,6 +30,30 @@ describe("usePlayerStore", () => {
30
30
  });
31
31
  });
32
32
 
33
+ describe("thumbnail content revision", () => {
34
+ it("advances once per accepted persisted file event", () => {
35
+ const before = usePlayerStore.getState().thumbnailContentRevision;
36
+
37
+ usePlayerStore.getState().bumpThumbnailContentRevision();
38
+
39
+ expect(usePlayerStore.getState().thumbnailContentRevision).toBe(before + 1);
40
+ });
41
+
42
+ it("remains monotonic across soft resets while project identity stays with the session epoch", () => {
43
+ const store = usePlayerStore.getState();
44
+ store.beginTimelineSession("project-a");
45
+ store.bumpThumbnailContentRevision();
46
+ const revision = usePlayerStore.getState().thumbnailContentRevision;
47
+ const firstEpoch = usePlayerStore.getState().timelineSessionEpoch;
48
+
49
+ store.reset();
50
+ expect(usePlayerStore.getState().thumbnailContentRevision).toBe(revision);
51
+ store.beginTimelineSession("project-b");
52
+ expect(usePlayerStore.getState().thumbnailContentRevision).toBe(revision);
53
+ expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch + 1);
54
+ });
55
+ });
56
+
33
57
  describe("expandedClipIds", () => {
34
58
  it("toggles clip membership", () => {
35
59
  const store = usePlayerStore.getState();
@@ -4,15 +4,21 @@ import { defaultThumbnailMode, type ThumbnailMode } from "../lib/thumbnailPolicy
4
4
 
5
5
  export interface ThumbnailSlice {
6
6
  thumbnailMode: ThumbnailMode;
7
+ /** Monotonic identity for persisted project content shown by mounted thumbnails. */
8
+ thumbnailContentRevision: number;
7
9
  setThumbnailMode: (mode: ThumbnailMode) => void;
10
+ bumpThumbnailContentRevision: () => void;
8
11
  }
9
12
 
10
13
  export function createThumbnailSlice(set: StoreApi<ThumbnailSlice>["setState"]): ThumbnailSlice {
11
14
  return {
12
15
  thumbnailMode: defaultThumbnailMode(readStudioUiPreferences().thumbnailMode),
16
+ thumbnailContentRevision: 0,
13
17
  setThumbnailMode: (mode) => {
14
18
  writeStudioUiPreferences({ thumbnailMode: mode });
15
19
  set({ thumbnailMode: mode });
16
20
  },
21
+ bumpThumbnailContentRevision: () =>
22
+ set((state) => ({ thumbnailContentRevision: state.thumbnailContentRevision + 1 })),
17
23
  };
18
24
  }
@@ -618,6 +618,185 @@ body {
618
618
  }
619
619
  }
620
620
 
621
+ /* Receipt-driven WebMCP Topology Lens. Every animation is one-shot Studio
622
+ chrome; none of these nodes enter the composition iframe or rendered output. */
623
+ .hf-topology-contour {
624
+ border: 1px solid rgba(60, 230, 172, 0.62);
625
+ box-shadow:
626
+ 0 0 0 1px rgba(6, 227, 250, 0.08),
627
+ inset 0 0 18px rgba(60, 230, 172, 0.035);
628
+ }
629
+
630
+ .hf-topology-field::before,
631
+ .hf-topology-contour::before,
632
+ .hf-topology-target::before {
633
+ content: attr(data-topology-node);
634
+ position: absolute;
635
+ top: 2px;
636
+ left: 3px;
637
+ z-index: 2;
638
+ padding: 1px 3px;
639
+ border-radius: 2px;
640
+ background: rgba(5, 15, 12, 0.86);
641
+ color: rgb(90, 247, 193);
642
+ font:
643
+ 600 7px/1.3 ui-monospace,
644
+ SFMono-Regular,
645
+ Menlo,
646
+ Monaco,
647
+ Consolas,
648
+ monospace;
649
+ letter-spacing: 0.04em;
650
+ text-transform: lowercase;
651
+ }
652
+
653
+ .hf-topology-field {
654
+ z-index: 0;
655
+ border: 1px solid rgba(60, 230, 172, 0.28);
656
+ background: linear-gradient(
657
+ 90deg,
658
+ rgba(60, 230, 172, 0.025),
659
+ rgba(60, 230, 172, 0.1) 68%,
660
+ rgba(6, 227, 250, 0.06)
661
+ );
662
+ box-shadow: inset 0 0 28px rgba(60, 230, 172, 0.05);
663
+ }
664
+
665
+ .hf-topology-target {
666
+ border: 1px solid rgba(60, 230, 172, 0.82);
667
+ box-shadow:
668
+ 0 0 0 1px rgba(60, 230, 172, 0.14),
669
+ 0 0 18px rgba(6, 227, 250, 0.09);
670
+ }
671
+
672
+ .hf-topology-scan {
673
+ background: linear-gradient(
674
+ 90deg,
675
+ transparent,
676
+ rgba(60, 230, 172, 0.16) 55%,
677
+ rgba(230, 255, 247, 0.9)
678
+ );
679
+ clip-path: polygon(0 0, 100% 0, 72% 100%, 0 100%);
680
+ }
681
+
682
+ .hf-topology-seal {
683
+ filter: drop-shadow(0 0 6px rgba(60, 230, 172, 0.36));
684
+ }
685
+
686
+ @media (prefers-reduced-motion: no-preference) {
687
+ .hf-topology-contour {
688
+ animation: hf-topology-reveal 176ms cubic-bezier(0.77, 0, 0.175, 1) both;
689
+ }
690
+
691
+ .hf-topology-field[data-topology-phase="acquiring"] {
692
+ animation: hf-topology-reveal 240ms cubic-bezier(0.77, 0, 0.175, 1) both;
693
+ }
694
+
695
+ .hf-topology-scan {
696
+ animation: hf-topology-scan 224ms cubic-bezier(0.77, 0, 0.175, 1) both;
697
+ }
698
+
699
+ .hf-topology-target[data-topology-phase="acquiring"] {
700
+ animation: hf-topology-localize 224ms cubic-bezier(0.23, 1, 0.32, 1) 72ms both;
701
+ }
702
+
703
+ .hf-topology-target[data-topology-phase="localizing"] {
704
+ animation: hf-topology-localize 176ms cubic-bezier(0.23, 1, 0.32, 1) both;
705
+ }
706
+
707
+ .hf-topology-target[data-topology-phase="sealing"] {
708
+ animation: hf-topology-localize 144ms cubic-bezier(0.23, 1, 0.32, 1) both;
709
+ }
710
+
711
+ .hf-topology-seal {
712
+ animation: hf-topology-seal 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
713
+ }
714
+
715
+ .hf-topology-seal path:first-of-type {
716
+ animation: hf-topology-seal-left 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
717
+ }
718
+
719
+ .hf-topology-seal path:nth-of-type(2) {
720
+ animation: hf-topology-seal-right 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
721
+ }
722
+ }
723
+
724
+ @keyframes hf-topology-reveal {
725
+ from {
726
+ opacity: 0;
727
+ clip-path: inset(0 100% 0 0);
728
+ }
729
+ to {
730
+ opacity: 1;
731
+ clip-path: inset(0);
732
+ }
733
+ }
734
+
735
+ @keyframes hf-topology-scan {
736
+ from {
737
+ opacity: 0;
738
+ transform: translate3d(-100%, 0, 0);
739
+ }
740
+ 16% {
741
+ opacity: 1;
742
+ }
743
+ to {
744
+ opacity: 0;
745
+ transform: translate3d(400%, 0, 0);
746
+ }
747
+ }
748
+
749
+ @keyframes hf-topology-localize {
750
+ from {
751
+ opacity: 0;
752
+ transform: scaleX(0.76);
753
+ }
754
+ to {
755
+ opacity: 1;
756
+ transform: scaleX(1);
757
+ }
758
+ }
759
+
760
+ @keyframes hf-topology-seal {
761
+ from {
762
+ opacity: 0;
763
+ transform: scale(0.88);
764
+ }
765
+ to {
766
+ opacity: 1;
767
+ transform: scale(1);
768
+ }
769
+ }
770
+
771
+ @keyframes hf-topology-seal-left {
772
+ from {
773
+ transform: translate3d(-9px, 0, 0);
774
+ }
775
+ to {
776
+ transform: translate3d(0, 0, 0);
777
+ }
778
+ }
779
+
780
+ @keyframes hf-topology-seal-right {
781
+ from {
782
+ transform: translate3d(9px, 0, 0);
783
+ }
784
+ to {
785
+ transform: translate3d(0, 0, 0);
786
+ }
787
+ }
788
+
789
+ @media (prefers-reduced-motion: reduce) {
790
+ .hf-topology-contour,
791
+ .hf-topology-field,
792
+ .hf-topology-target,
793
+ .hf-topology-seal,
794
+ .hf-topology-seal path {
795
+ animation: none;
796
+ transform: none;
797
+ }
798
+ }
799
+
621
800
  @keyframes hf-fx-preset-wave {
622
801
  0%,
623
802
  100% {
@@ -51,7 +51,7 @@ describe("dom edit save queue", () => {
51
51
  queue.destroy();
52
52
  });
53
53
 
54
- it("keeps an open breaker paused even when already queued work succeeds", async () => {
54
+ it("stops already queued work when the preceding save opens the breaker", async () => {
55
55
  const onOpen = vi.fn();
56
56
  const onReset = vi.fn();
57
57
  const queue = createDomEditSaveQueue({
@@ -72,13 +72,15 @@ describe("dom edit save queue", () => {
72
72
  resolveFirstStarted?.();
73
73
  }),
74
74
  );
75
- const second = queue.enqueue(async () => {});
75
+ const secondSave = vi.fn(async () => undefined);
76
+ const second = queue.enqueue(secondSave);
76
77
 
77
78
  await firstStarted;
78
79
  expect(rejectFirst).toBeTypeOf("function");
79
80
  rejectFirst?.(new StudioSaveHttpError("Server down", 503));
80
81
  await expect(first).rejects.toThrow("Server down");
81
- await expect(second).resolves.toBeUndefined();
82
+ await expect(second).rejects.toThrow("Auto-save is paused");
83
+ expect(secondSave).not.toHaveBeenCalled();
82
84
 
83
85
  expect(onOpen).toHaveBeenCalledOnce();
84
86
  expect(onReset).not.toHaveBeenCalled();
@@ -81,7 +81,12 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
81
81
  return {
82
82
  enqueue(save) {
83
83
  if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
84
- const queued = tail.catch(() => undefined).then(() => run(save));
84
+ const queued = tail
85
+ .catch(() => undefined)
86
+ .then(() => {
87
+ if (breakerOpen) throw new DomEditSaveQueueOpenError();
88
+ return run(save);
89
+ });
85
90
  tail = queued.then(
86
91
  () => undefined,
87
92
  () => undefined,
@@ -0,0 +1,70 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import { vi } from "vitest";
5
+ import { readLiveSelectionBox, resizeSelectionFromAgent } from "./StudioAgentTools";
6
+
7
+ function rect(width: number, height: number): DOMRect {
8
+ return { x: 10, y: 20, width, height } as DOMRect;
9
+ }
10
+
11
+ describe("readLiveSelectionBox", () => {
12
+ it("measures the replacement preview node after a commit reload", () => {
13
+ const oldElement = document.createElement("h1");
14
+ oldElement.id = "headline";
15
+ oldElement.getBoundingClientRect = () => rect(300, 50);
16
+ const selection = {
17
+ id: "headline",
18
+ sourceFile: "index.html",
19
+ element: oldElement,
20
+ } as DomEditSelection;
21
+
22
+ const liveDocument = document.implementation.createHTMLDocument();
23
+ const replacement = liveDocument.createElement("h1");
24
+ replacement.id = "headline";
25
+ replacement.getBoundingClientRect = () => rect(280, 60);
26
+ liveDocument.body.append(replacement);
27
+
28
+ expect(readLiveSelectionBox(liveDocument, selection, "index.html")).toEqual({
29
+ x: 10,
30
+ y: 20,
31
+ width: 280,
32
+ height: 60,
33
+ });
34
+ });
35
+
36
+ it("does not hide a stale target behind its detached old node", () => {
37
+ const oldElement = document.createElement("h1");
38
+ oldElement.id = "headline";
39
+ const selection = {
40
+ id: "headline",
41
+ sourceFile: "index.html",
42
+ element: oldElement,
43
+ } as DomEditSelection;
44
+ const liveDocument = document.implementation.createHTMLDocument();
45
+
46
+ expect(() => readLiveSelectionBox(liveDocument, selection, "index.html")).toThrow(
47
+ "target is missing",
48
+ );
49
+ });
50
+ });
51
+
52
+ describe("resizeSelectionFromAgent", () => {
53
+ it("applies the same live draft a pointer gesture shows before persistence", async () => {
54
+ const element = document.createElement("div");
55
+ element.style.width = "300px";
56
+ element.style.height = "50px";
57
+ const selection = { element } as DomEditSelection;
58
+ const commit = vi.fn(async (_selection, _next, _offset, restore: () => void) => {
59
+ expect(element.style.width).toBe("280px");
60
+ expect(element.style.height).toBe("60px");
61
+ restore();
62
+ });
63
+
64
+ await resizeSelectionFromAgent(selection, { width: 280, height: 60 }, commit);
65
+
66
+ expect(commit).toHaveBeenCalledOnce();
67
+ expect(element.style.width).toBe("300px");
68
+ expect(element.style.height).toBe("50px");
69
+ });
70
+ });