@hyperframes/studio 0.8.23 → 0.8.25

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 (90) hide show
  1. package/dist/assets/{hyperframes-player-oZ6g4tHJ.js → hyperframes-player-CTP_00ix.js} +1 -1
  2. package/dist/assets/{index-UyvIa_cE.js → index-Bjb4YqS_.js} +1 -1
  3. package/dist/assets/{index-Tiu74FPg.js → index-DSk_ALFr.js} +218 -218
  4. package/dist/assets/{index-DW-H7Y2b.js → index-Di-8Jxqc.js} +1 -1
  5. package/dist/assets/index-DnRfAiK2.css +1 -0
  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 +6206 -5011
  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/useDomEditAttributeCommits.ts +4 -1
  36. package/src/hooks/useDomEditCommits.test.tsx +207 -8
  37. package/src/hooks/useDomEditCommits.ts +54 -14
  38. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  40. package/src/hooks/useDomEditSession.ts +6 -2
  41. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  42. package/src/hooks/useDomEditTextCommits.ts +64 -86
  43. package/src/hooks/useDomEditWiring.ts +1 -1
  44. package/src/hooks/useDomSelection.ts +2 -0
  45. package/src/hooks/useDomSelectionTypes.ts +2 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  48. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  50. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  51. package/src/hooks/useGsapAnimationOps.ts +4 -4
  52. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  53. package/src/hooks/useGsapAwareEditing.ts +31 -10
  54. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  55. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  56. package/src/hooks/useRenderClipContent.test.ts +27 -0
  57. package/src/hooks/useRenderClipContent.ts +5 -0
  58. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  59. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  60. package/src/player/components/CompositionThumbnail.tsx +6 -0
  61. package/src/player/store/playerStore.test.ts +24 -0
  62. package/src/player/store/thumbnailSlice.ts +6 -0
  63. package/src/styles/studio.css +179 -0
  64. package/src/utils/domEditSaveQueue.test.ts +5 -3
  65. package/src/utils/domEditSaveQueue.ts +6 -1
  66. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  67. package/src/webmcp/StudioAgentTools.tsx +89 -21
  68. package/src/webmcp/handles.test.ts +172 -15
  69. package/src/webmcp/handles.ts +152 -35
  70. package/src/webmcp/tools/animationTools.test.ts +241 -60
  71. package/src/webmcp/tools/animationTools.ts +191 -104
  72. package/src/webmcp/tools/contentTools.test.ts +103 -41
  73. package/src/webmcp/tools/contentTools.ts +149 -105
  74. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  75. package/src/webmcp/tools/inspectTools.ts +59 -19
  76. package/src/webmcp/tools/lookTools.test.ts +185 -38
  77. package/src/webmcp/tools/lookTools.ts +134 -21
  78. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  79. package/src/webmcp/tools/selectionTools.ts +34 -19
  80. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  81. package/src/webmcp/tools/transformTools.test.ts +140 -54
  82. package/src/webmcp/tools/transformTools.ts +215 -56
  83. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  84. package/src/webmcp/useStudioAgentTools.ts +27 -15
  85. package/src/webmcp/webmcpTestUtils.ts +37 -0
  86. package/src/webmcp/writeCoordinator.test.ts +375 -0
  87. package/src/webmcp/writeCoordinator.ts +473 -0
  88. package/dist/assets/index-yGhfxxoL.css +0 -1
  89. package/dist/chunk-6BT6DTB4.js.map +0 -1
  90. /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
@@ -0,0 +1,119 @@
1
+ import { resolveElementHandle } from "../../webmcp/handles";
2
+ import { collectStudioLookScene } from "../../webmcp/tools/lookTools";
3
+ import type { DomEditLayerItem } from "./domEditingTypes";
4
+ import { toVisibleOverlayRects, type OverlayRect } from "./domEditOverlayGeometry";
5
+
6
+ export interface TopologyLensNode {
7
+ label: string;
8
+ rect: OverlayRect;
9
+ }
10
+
11
+ export interface TopologyLensGeometry {
12
+ field: TopologyLensNode;
13
+ target: TopologyLensNode;
14
+ contours: TopologyLensNode[];
15
+ }
16
+
17
+ function findSourceContextIndex(items: readonly DomEditLayerItem[], targetIndex: number): number {
18
+ const target = items[targetIndex]!;
19
+ let contextIndex = targetIndex;
20
+ let parentDepth = target.depth - 1;
21
+ for (let index = targetIndex - 1; index >= 0 && parentDepth >= 0; index--) {
22
+ const item = items[index]!;
23
+ if (item.depth !== parentDepth) continue;
24
+ if (item.sourceFile !== target.sourceFile) break;
25
+ contextIndex = index;
26
+ parentDepth -= 1;
27
+ }
28
+ return contextIndex;
29
+ }
30
+
31
+ function collectSourceContours(
32
+ items: readonly DomEditLayerItem[],
33
+ contextIndex: number,
34
+ target: DomEditLayerItem,
35
+ ): HTMLElement[] {
36
+ const context = items[contextIndex]!;
37
+ const contours: HTMLElement[] = [];
38
+ for (let index = contextIndex + 1; index < items.length; index++) {
39
+ const item = items[index]!;
40
+ if (item.depth <= context.depth) break;
41
+ if (item.sourceFile === target.sourceFile && item.element !== target.element) {
42
+ contours.push(item.element);
43
+ }
44
+ }
45
+ return contours;
46
+ }
47
+
48
+ /**
49
+ * Resolve the same nested preorder used by studio_look. This keeps the lens on
50
+ * the agent's source-safe scene model instead of guessing hierarchy from an
51
+ * unrelated presentation selector.
52
+ */
53
+ export function resolveTopologyLensElements(
54
+ doc: Document,
55
+ activeCompositionPath: string | null,
56
+ handle: string,
57
+ ): { context: HTMLElement; target: HTMLElement; contours: HTMLElement[] } | null {
58
+ const target = resolveElementHandle(doc, handle);
59
+ if (!target) return null;
60
+ const scene = collectStudioLookScene(doc, activeCompositionPath, null);
61
+ if (scene.status === "loading") return null;
62
+ const targetIndex = scene.items.findIndex((item) => item.element === target);
63
+ if (targetIndex < 0) return { context: target, target, contours: [] };
64
+
65
+ const targetItem = scene.items[targetIndex]!;
66
+ const contextIndex = findSourceContextIndex(scene.items, targetIndex);
67
+ const contextItem = scene.items[contextIndex]!;
68
+ const sourceContext = target.closest<HTMLElement>(
69
+ "[data-composition-file], [data-composition-src]",
70
+ );
71
+ return {
72
+ context: sourceContext ?? contextItem.element,
73
+ target,
74
+ contours: collectSourceContours(scene.items, contextIndex, targetItem),
75
+ };
76
+ }
77
+
78
+ function isRenderableRect(rect: OverlayRect | null | undefined): rect is OverlayRect {
79
+ return Boolean(rect && rect.width > 0 && rect.height > 0);
80
+ }
81
+
82
+ export function measureTopologyLensGeometry(input: {
83
+ overlay: HTMLDivElement;
84
+ iframe: HTMLIFrameElement;
85
+ activeCompositionPath: string | null;
86
+ handle: string;
87
+ }): TopologyLensGeometry | null {
88
+ const doc = input.iframe.contentDocument;
89
+ if (!doc) return null;
90
+ const elements = resolveTopologyLensElements(doc, input.activeCompositionPath, input.handle);
91
+ if (!elements) return null;
92
+ const sharedField = elements.context === elements.target;
93
+ const measured = toVisibleOverlayRects(
94
+ input.overlay,
95
+ input.iframe,
96
+ sharedField
97
+ ? [elements.target, ...elements.contours]
98
+ : [elements.context, elements.target, ...elements.contours],
99
+ );
100
+ const target = measured[sharedField ? 0 : 1];
101
+ if (!isRenderableRect(target)) return null;
102
+ const measuredField = measured[0];
103
+ const fieldUsesContext = !sharedField && isRenderableRect(measuredField);
104
+ const contourOffset = sharedField ? 1 : 2;
105
+ return {
106
+ field: {
107
+ label: (fieldUsesContext ? elements.context : elements.target).tagName.toLowerCase(),
108
+ rect: fieldUsesContext ? measuredField : target,
109
+ },
110
+ target: { label: elements.target.tagName.toLowerCase(), rect: target },
111
+ contours: measured
112
+ .slice(contourOffset)
113
+ .map((rect, index) => ({
114
+ label: elements.contours[index]!.tagName.toLowerCase(),
115
+ rect,
116
+ }))
117
+ .filter((item): item is TopologyLensNode => isRenderableRect(item.rect)),
118
+ };
119
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { reduceTopologyLens, type TopologyLensState } from "./topologyLensState";
3
+ import type { StudioEditLifecycleState } from "../../webmcp/writeCoordinator";
4
+
5
+ const targetA = { handle: "dom:v1:index.html:index.html:target-a", sourceFile: "index.html" };
6
+ const targetB = { handle: "dom:v1:index.html:index.html:target-b", sourceFile: "index.html" };
7
+
8
+ function lifecycle(
9
+ overrides: Partial<Exclude<StudioEditLifecycleState, { phase: "idle" }>> = {},
10
+ ): Exclude<StudioEditLifecycleState, { phase: "idle" }> {
11
+ return {
12
+ callId: "call-a",
13
+ projectId: "project-a",
14
+ target: targetA,
15
+ operation: "set-text",
16
+ targetChanged: true,
17
+ phase: "dispatching",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ function hidden(): TopologyLensState {
23
+ return { phase: "hidden" };
24
+ }
25
+
26
+ describe("Topology Lens state", () => {
27
+ it("reveals a new target but localizes a repeated target immediately", () => {
28
+ expect(reduceTopologyLens(hidden(), { type: "lifecycle", value: lifecycle() })).toMatchObject({
29
+ phase: "acquiring",
30
+ callId: "call-a",
31
+ });
32
+ expect(
33
+ reduceTopologyLens(hidden(), {
34
+ type: "lifecycle",
35
+ value: lifecycle({ callId: "call-repeat", targetChanged: false }),
36
+ }),
37
+ ).toMatchObject({ phase: "localizing", callId: "call-repeat" });
38
+ });
39
+
40
+ it("lets a fast durable receipt interrupt acquisition and seal once", () => {
41
+ const acquiring = reduceTopologyLens(hidden(), { type: "lifecycle", value: lifecycle() });
42
+ const sealing = reduceTopologyLens(acquiring, {
43
+ type: "lifecycle",
44
+ value: lifecycle({ phase: "verified" }),
45
+ });
46
+
47
+ expect(sealing).toMatchObject({ phase: "sealing", receiptStage: "verified" });
48
+ expect(reduceTopologyLens(sealing, { type: "lifecycle", value: { phase: "idle" } })).toEqual(
49
+ hidden(),
50
+ );
51
+ });
52
+
53
+ it.each(["dispatched", "failed"] as const)(
54
+ "retracts %s without manufacturing a success seal",
55
+ (phase) => {
56
+ const localizing = reduceTopologyLens(hidden(), {
57
+ type: "lifecycle",
58
+ value: lifecycle({ phase }),
59
+ });
60
+
61
+ expect(localizing).toMatchObject({ phase: "localizing", terminal: phase });
62
+ expect(
63
+ reduceTopologyLens(localizing, { type: "lifecycle", value: { phase: "idle" } }),
64
+ ).toEqual(hidden());
65
+ },
66
+ );
67
+
68
+ it.each(["saved", "verified"] as const)(
69
+ "retracts a %s no-op without manufacturing a success seal",
70
+ (phase) => {
71
+ const noChange = reduceTopologyLens(hidden(), {
72
+ type: "lifecycle",
73
+ value: lifecycle({
74
+ phase,
75
+ receipt: {
76
+ ok: true,
77
+ stage: phase,
78
+ target: targetA,
79
+ operation: "set-text",
80
+ changed: false,
81
+ evidence: { kind: "content-version", sourceFile: "index.html", version: "v1" },
82
+ },
83
+ }),
84
+ });
85
+
86
+ expect(noChange).toMatchObject({ phase: "localizing", terminal: "no-change" });
87
+ },
88
+ );
89
+
90
+ it("ignores elapsed events owned by an older invocation", () => {
91
+ const newer = reduceTopologyLens(hidden(), {
92
+ type: "lifecycle",
93
+ value: lifecycle({ callId: "call-b", target: targetB }),
94
+ });
95
+
96
+ expect(reduceTopologyLens(newer, { type: "acquisition-elapsed", callId: "call-a" })).toBe(
97
+ newer,
98
+ );
99
+ });
100
+
101
+ it("clears when the coordinator publishes idle", () => {
102
+ const acquiring = reduceTopologyLens(hidden(), { type: "lifecycle", value: lifecycle() });
103
+
104
+ expect(reduceTopologyLens(acquiring, { type: "lifecycle", value: { phase: "idle" } })).toEqual(
105
+ hidden(),
106
+ );
107
+ });
108
+
109
+ it("advances acquisition to localization only for the same visual invocation", () => {
110
+ const acquiring = reduceTopologyLens(hidden(), { type: "lifecycle", value: lifecycle() });
111
+
112
+ expect(
113
+ reduceTopologyLens(acquiring, { type: "acquisition-elapsed", callId: "call-a" }),
114
+ ).toMatchObject({ phase: "localizing", terminal: null });
115
+ });
116
+ });
@@ -0,0 +1,72 @@
1
+ import type {
2
+ StudioEditLifecycleState,
3
+ StudioWriteOperation,
4
+ StudioWriteTarget,
5
+ } from "../../webmcp/writeCoordinator";
6
+
7
+ type TopologyLensBase = {
8
+ callId: string;
9
+ projectId: string;
10
+ target: StudioWriteTarget;
11
+ operation: StudioWriteOperation;
12
+ };
13
+
14
+ export type TopologyLensState =
15
+ | { phase: "hidden" }
16
+ | (TopologyLensBase & { phase: "acquiring" })
17
+ | (TopologyLensBase & {
18
+ phase: "localizing";
19
+ terminal: "dispatched" | "failed" | "no-change" | null;
20
+ })
21
+ | (TopologyLensBase & {
22
+ phase: "sealing";
23
+ receiptStage: "saved" | "verified";
24
+ });
25
+
26
+ export type TopologyLensEvent =
27
+ | { type: "lifecycle"; value: StudioEditLifecycleState }
28
+ | { type: "acquisition-elapsed"; callId: string };
29
+
30
+ function visibleBase(
31
+ value: Exclude<StudioEditLifecycleState, { phase: "idle" }>,
32
+ ): TopologyLensBase {
33
+ return {
34
+ callId: value.callId,
35
+ projectId: value.projectId,
36
+ target: value.target,
37
+ operation: value.operation,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Presentation follows transaction facts. Elapsed events may finish a visual
43
+ * transition, but cannot promote a receipt or invent persistence.
44
+ */
45
+ export function reduceTopologyLens(
46
+ state: TopologyLensState,
47
+ event: TopologyLensEvent,
48
+ ): TopologyLensState {
49
+ if (event.type === "lifecycle") {
50
+ const lifecycle = event.value;
51
+ if (lifecycle.phase === "idle") return { phase: "hidden" };
52
+ const base = visibleBase(lifecycle);
53
+ switch (lifecycle.phase) {
54
+ case "dispatching":
55
+ return lifecycle.targetChanged
56
+ ? { ...base, phase: "acquiring" }
57
+ : { ...base, phase: "localizing", terminal: null };
58
+ case "dispatched":
59
+ case "failed":
60
+ return { ...base, phase: "localizing", terminal: lifecycle.phase };
61
+ case "saved":
62
+ case "verified":
63
+ if (lifecycle.receipt?.ok && lifecycle.receipt.changed === false) {
64
+ return { ...base, phase: "localizing", terminal: "no-change" };
65
+ }
66
+ return { ...base, phase: "sealing", receiptStage: lifecycle.phase };
67
+ }
68
+ }
69
+
70
+ if (state.phase === "hidden" || state.callId !== event.callId) return state;
71
+ return state.phase === "acquiring" ? { ...state, phase: "localizing", terminal: null } : state;
72
+ }
@@ -0,0 +1,126 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { PreviewOverlays } from "./PreviewOverlays";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ const previewState = vi.hoisted(() => ({ captionEditMode: false }));
11
+ const iframeRef = { current: null as HTMLIFrameElement | null };
12
+
13
+ vi.mock("../../contexts/StudioContext", () => ({
14
+ useStudioShellContext: () => ({ activeCompPath: "index.html", previewIframeRef: iframeRef }),
15
+ useStudioPlaybackContext: () => ({
16
+ captionEditMode: previewState.captionEditMode,
17
+ compositionLoading: false,
18
+ isPlaying: false,
19
+ }),
20
+ }));
21
+ vi.mock("../../contexts/DomEditContext", () => ({
22
+ useDomEditSelectionContext: () => ({
23
+ domEditHoverSelection: null,
24
+ domEditSelection: null,
25
+ domEditGroupSelections: [],
26
+ }),
27
+ useDomEditActionsContext: () => ({
28
+ handlePreviewCanvasMouseDown: vi.fn(),
29
+ handlePreviewCanvasPointerMove: vi.fn(),
30
+ handlePreviewCanvasPointerLeave: vi.fn(),
31
+ applyDomSelection: vi.fn(),
32
+ handleBlockedDomMove: vi.fn(),
33
+ handleDomManualDragStart: vi.fn(),
34
+ handleDomPathOffsetCommit: vi.fn(),
35
+ handleDomGroupPathOffsetCommit: vi.fn(),
36
+ handleDomBoxSizeCommit: vi.fn(),
37
+ handleDomRotationCommit: vi.fn(),
38
+ handleDomStyleCommit: vi.fn(),
39
+ applyMarqueeSelection: vi.fn(),
40
+ handleDomEditElementDelete: vi.fn(),
41
+ handleDomZIndexReorderCommit: vi.fn(),
42
+ }),
43
+ }));
44
+ vi.mock("../../captions/store", () => {
45
+ const state = {
46
+ model: null,
47
+ dismissed: false,
48
+ syncError: null,
49
+ clearSelection: vi.fn(),
50
+ setDismissed: vi.fn(),
51
+ setEditMode: vi.fn(),
52
+ setSyncError: vi.fn(),
53
+ };
54
+ return {
55
+ useCaptionStore: Object.assign(
56
+ (selector: (value: typeof state) => unknown) => selector(state),
57
+ { getState: () => state },
58
+ ),
59
+ };
60
+ });
61
+ vi.mock("../../hooks/useCompositionDimensions", () => ({
62
+ useCompositionDimensions: () => null,
63
+ }));
64
+ vi.mock("../../utils/studioUiPreferences", () => ({ readStudioUiPreferences: () => ({}) }));
65
+ vi.mock("./useCanvasZOrderTimelineMirror", () => ({
66
+ useCanvasZOrderTimelineMirror: () => vi.fn(),
67
+ }));
68
+ vi.mock("../editor/TopologyLens", async () => {
69
+ const { createElement } = await import("react");
70
+ return {
71
+ TopologyLens: () => createElement("div", { "data-topology-host": "true" }),
72
+ };
73
+ });
74
+ vi.mock("../../captions/components/CaptionOverlay", () => ({ CaptionOverlay: () => null }));
75
+ vi.mock("../editor/DomEditOverlay", () => ({ DomEditOverlay: () => null }));
76
+ vi.mock("../editor/MotionPathOverlay", () => ({ MotionPathOverlay: () => null }));
77
+ vi.mock("../editor/SnapToolbar", () => ({ SnapToolbar: () => null }));
78
+
79
+ let root: Root | null = null;
80
+ let host: HTMLDivElement | null = null;
81
+
82
+ function render(props: Partial<React.ComponentProps<typeof PreviewOverlays>> = {}): void {
83
+ host = document.createElement("div");
84
+ iframeRef.current = document.createElement("iframe");
85
+ document.body.append(host, iframeRef.current);
86
+ root = createRoot(host);
87
+ act(() => {
88
+ root?.render(
89
+ <PreviewOverlays
90
+ shouldShowMotionPath={false}
91
+ shouldShowSelectedDomBounds={false}
92
+ {...props}
93
+ />,
94
+ );
95
+ });
96
+ }
97
+
98
+ afterEach(() => {
99
+ if (root) act(() => root?.unmount());
100
+ root = null;
101
+ host = null;
102
+ iframeRef.current = null;
103
+ previewState.captionEditMode = false;
104
+ document.body.replaceChildren();
105
+ });
106
+
107
+ describe("PreviewOverlays Topology Lens host", () => {
108
+ it("stays in Studio chrome while a block preview covers the composition", () => {
109
+ render({
110
+ blockPreview: {
111
+ id: "block-a",
112
+ title: "Block",
113
+ posterUrl: "poster.png",
114
+ } as React.ComponentProps<typeof PreviewOverlays>["blockPreview"],
115
+ });
116
+
117
+ expect(host?.querySelector('[data-topology-host="true"]')).not.toBeNull();
118
+ });
119
+
120
+ it("stays independent of caption editing chrome", () => {
121
+ previewState.captionEditMode = true;
122
+ render();
123
+
124
+ expect(host?.querySelector('[data-topology-host="true"]')).not.toBeNull();
125
+ });
126
+ });
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
2
2
  import { CaptionOverlay } from "../../captions/components/CaptionOverlay";
3
3
  import { useCaptionStore } from "../../captions/store";
4
4
  import { DomEditOverlay } from "../editor/DomEditOverlay";
5
+ import { TopologyLens } from "../editor/TopologyLens";
5
6
  import { MotionPathOverlay } from "../editor/MotionPathOverlay";
6
7
  import { SnapToolbar } from "../editor/SnapToolbar";
7
8
  import { useCompositionDimensions } from "../../hooks/useCompositionDimensions";
@@ -191,30 +192,34 @@ export function PreviewOverlays({
191
192
 
192
193
  if (blockPreview) {
193
194
  return (
194
- <div className="absolute inset-0 z-30 bg-black pointer-events-none">
195
- {blockPreview.videoUrl ? (
196
- <video
197
- src={blockPreview.videoUrl}
198
- autoPlay
199
- muted
200
- loop
201
- playsInline
202
- className="w-full h-full object-contain"
203
- />
204
- ) : blockPreview.posterUrl ? (
205
- <img
206
- src={blockPreview.posterUrl}
207
- alt={blockPreview.title}
208
- className="w-full h-full object-contain"
209
- />
210
- ) : null}
211
- </div>
195
+ <>
196
+ <TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
197
+ <div className="absolute inset-0 z-30 bg-black pointer-events-none">
198
+ {blockPreview.videoUrl ? (
199
+ <video
200
+ src={blockPreview.videoUrl}
201
+ autoPlay
202
+ muted
203
+ loop
204
+ playsInline
205
+ className="w-full h-full object-contain"
206
+ />
207
+ ) : blockPreview.posterUrl ? (
208
+ <img
209
+ src={blockPreview.posterUrl}
210
+ alt={blockPreview.title}
211
+ className="w-full h-full object-contain"
212
+ />
213
+ ) : null}
214
+ </div>
215
+ </>
212
216
  );
213
217
  }
214
218
 
215
219
  if (captionEditMode) {
216
220
  return (
217
221
  <>
222
+ <TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
218
223
  <CaptionOverlay iframeRef={previewIframeRef} />
219
224
  {/* Mode indicator + explicit exit */}
220
225
  <div className="absolute top-2 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 rounded-full border border-studio-accent/40 bg-black/70 px-2.5 py-1">
@@ -257,6 +262,7 @@ export function PreviewOverlays({
257
262
 
258
263
  return (
259
264
  <>
265
+ <TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
260
266
  <DomEditOverlay
261
267
  iframeRef={previewIframeRef}
262
268
  activeCompositionPath={activeCompPath}
@@ -3,6 +3,7 @@
3
3
  import { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { usePlayerStore } from "../../player/store/playerStore";
6
7
  import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
7
8
  import { CompositionsTab } from "./CompositionsTab";
8
9
 
@@ -17,6 +18,7 @@ afterEach(() => {
17
18
  if (root) act(() => root?.unmount());
18
19
  root = null;
19
20
  document.body.innerHTML = "";
21
+ usePlayerStore.setState({ thumbnailContentRevision: 0 });
20
22
  });
21
23
 
22
24
  function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
@@ -59,6 +61,20 @@ describe("composition card drag", () => {
59
61
  expect(host.querySelector('img[src*="/thumbnail/"]')).toBeNull();
60
62
  });
61
63
 
64
+ it("retries a failed thumbnail at the next persisted content revision", () => {
65
+ const { host } = mount();
66
+ const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
67
+ if (!thumbnail) throw new Error("composition thumbnail did not render");
68
+ act(() => thumbnail.dispatchEvent(new Event("error")));
69
+ expect(host.textContent).toContain("Preview unavailable");
70
+
71
+ act(() => usePlayerStore.getState().bumpThumbnailContentRevision());
72
+
73
+ const retry = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
74
+ expect(retry).not.toBeNull();
75
+ expect(new URL(retry?.src ?? "").searchParams.get("revision")).toBe("1");
76
+ });
77
+
62
78
  it("mounts one live preview only after sustained hover and removes it on leave", () => {
63
79
  vi.useFakeTimers();
64
80
  const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -1,6 +1,7 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
- import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
3
2
  import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
3
+ import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
4
+ import { usePlayerStore } from "../../player/store/playerStore";
4
5
  import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
5
6
  import { Tooltip } from "../ui/Tooltip";
6
7
 
@@ -120,6 +121,7 @@ function CompCard({
120
121
  isRendering,
121
122
  lintInfo,
122
123
  onAddToTimeline,
124
+ contentRevision,
123
125
  }: {
124
126
  projectId: string;
125
127
  comp: string;
@@ -129,11 +131,12 @@ function CompCard({
129
131
  isRendering?: boolean;
130
132
  lintInfo?: { count: number; messages: string[] };
131
133
  onAddToTimeline?: () => void;
134
+ contentRevision: number;
132
135
  }) {
133
136
  const [hovered, setHovered] = useState(false);
134
137
  const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
135
138
  const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
136
- const [thumbnailFailed, setThumbnailFailed] = useState(false);
139
+ const [failedThumbnailUrl, setFailedThumbnailUrl] = useState<string | null>(null);
137
140
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
138
141
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
139
142
  const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -176,7 +179,9 @@ function CompCard({
176
179
  seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
177
180
  duration: 0,
178
181
  origin: window.location.origin,
182
+ contentRevision,
179
183
  });
184
+ const thumbnailFailed = failedThumbnailUrl === thumbnailUrl;
180
185
  const previewScale = resolveCompositionPreviewScale({
181
186
  cardWidth: CARD_W,
182
187
  cardHeight: CARD_H,
@@ -246,7 +251,7 @@ function CompCard({
246
251
  draggable={false}
247
252
  loading="lazy"
248
253
  decoding="async"
249
- onError={() => setThumbnailFailed(true)}
254
+ onError={() => setFailedThumbnailUrl(thumbnailUrl)}
250
255
  className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
251
256
  livePreviewLoaded ? "opacity-0" : "opacity-100"
252
257
  }`}
@@ -368,6 +373,7 @@ export const CompositionsTab = memo(function CompositionsTab({
368
373
  isRendering,
369
374
  lintFindingsByFile,
370
375
  }: CompositionsTabProps) {
376
+ const contentRevision = usePlayerStore((state) => state.thumbnailContentRevision);
371
377
  if (compositions.length === 0) {
372
378
  return (
373
379
  <div className="flex-1 flex items-center justify-center px-4">
@@ -389,6 +395,7 @@ export const CompositionsTab = memo(function CompositionsTab({
389
395
  onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
390
396
  isRendering={isRendering}
391
397
  lintInfo={lintFindingsByFile?.get(comp)}
398
+ contentRevision={contentRevision}
392
399
  />
393
400
  ))}
394
401
  </div>
@@ -1,3 +1,5 @@
1
+ import { HyperframesMark } from "./HyperframesMark";
2
+
1
3
  export interface HyperframesLoaderProps {
2
4
  /** Status text shown below the mark. */
3
5
  title: string;
@@ -31,54 +33,7 @@ export function HyperframesLoader({
31
33
  style={{ width: markFrameSize, height: markFrameSize }}
32
34
  draggable={false}
33
35
  >
34
- <svg
35
- className="hf-loader-mark"
36
- width={size}
37
- height={size}
38
- viewBox="0 0 100 100"
39
- fill="none"
40
- xmlns="http://www.w3.org/2000/svg"
41
- aria-hidden="true"
42
- >
43
- <g className="hf-loader-mark__mark" transform="translate(50 50)">
44
- <g className="hf-loader-mark__core" transform="scale(1)" opacity=".92">
45
- <g transform="translate(-50 -50)">
46
- <path
47
- d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z"
48
- fill="url(#hf-loader-grad-left)"
49
- />
50
- <path
51
- d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z"
52
- fill="url(#hf-loader-grad-right)"
53
- />
54
- </g>
55
- </g>
56
- </g>
57
- <defs>
58
- <linearGradient
59
- id="hf-loader-grad-left"
60
- x1="48.5676"
61
- y1="25"
62
- x2="44.7804"
63
- y2="71.9384"
64
- gradientUnits="userSpaceOnUse"
65
- >
66
- <stop stopColor="#06E3FA" />
67
- <stop offset="1" stopColor="#4FDB5E" />
68
- </linearGradient>
69
- <linearGradient
70
- id="hf-loader-grad-right"
71
- x1="54.8282"
72
- y1="73.8392"
73
- x2="72.0989"
74
- y2="32.8932"
75
- gradientUnits="userSpaceOnUse"
76
- >
77
- <stop stopColor="#06E3FA" />
78
- <stop offset="1" stopColor="#4FDB5E" />
79
- </linearGradient>
80
- </defs>
81
- </svg>
36
+ <HyperframesMark className="hf-loader-mark" width={size} height={size} />
82
37
  </div>
83
38
  <div className="hf-loader-title">{title}</div>
84
39
  {detail && <div className="hf-loader-detail">{detail}</div>}