@hyperframes/studio 0.8.51 → 0.8.53-desktop.0

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 (160) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-PTEnAHhg.js} +1 -1
  2. package/dist/assets/{index-CPS3Cvj8.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Clrb11lj.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +808 -12
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8109 -7583
  11. package/dist/index.js.map +1 -1
  12. package/package.json +9 -7
  13. package/src/App.tsx +45 -50
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  17. package/src/components/EditorShell.tsx +56 -56
  18. package/src/components/RightPanelTabs.test.tsx +151 -0
  19. package/src/components/RightPanelTabs.tsx +59 -0
  20. package/src/components/StudioHeader.dom.test.tsx +181 -0
  21. package/src/components/StudioHeader.test.ts +0 -12
  22. package/src/components/StudioHeader.tsx +117 -160
  23. package/src/components/StudioLeftPanels.tsx +202 -0
  24. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  25. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  26. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  27. package/src/components/TimelineHistoryButtons.tsx +54 -0
  28. package/src/components/TimelineToolbar.test.tsx +8 -0
  29. package/src/components/TimelineToolbar.tsx +3 -7
  30. package/src/components/dock/Dock.test.tsx +100 -18
  31. package/src/components/dock/Dock.tsx +32 -5
  32. package/src/components/dock/dock.css +5 -0
  33. package/src/components/dock/dockAccessibility.test.ts +166 -0
  34. package/src/components/dock/dockAccessibility.ts +98 -0
  35. package/src/components/dock/dockLayout.test.ts +134 -0
  36. package/src/components/dock/dockLayout.ts +53 -6
  37. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  38. package/src/components/dock/dockLayoutStore.ts +21 -4
  39. package/src/components/dock/dockTestHarness.ts +11 -0
  40. package/src/components/editor/LayersPanel.test.ts +97 -1
  41. package/src/components/editor/LayersPanel.tsx +4 -2
  42. package/src/components/editor/PreviewGuides.tsx +1 -1
  43. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  44. package/src/components/editor/TopologyLens.test.tsx +28 -0
  45. package/src/components/editor/TopologyLens.tsx +3 -1
  46. package/src/components/editor/useColorGradingController.test.ts +38 -0
  47. package/src/components/editor/useColorGradingController.ts +9 -1
  48. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  49. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  50. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  51. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  52. package/src/components/nle/NLEContext.tsx +0 -43
  53. package/src/components/nle/NLEPreview.tsx +1 -1
  54. package/src/components/nle/PreviewPane.tsx +1 -1
  55. package/src/components/nle/TimelinePane.tsx +53 -67
  56. package/src/components/panels/SlideshowPanel.tsx +1 -1
  57. package/src/components/panels/VariablesPanel.tsx +1 -1
  58. package/src/components/pointerTargetSize.test.tsx +8 -0
  59. package/src/components/renders/RenderQueue.tsx +1 -1
  60. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  61. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  62. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  63. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  64. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  65. package/src/components/timelineToolbarStyles.ts +5 -0
  66. package/src/components/ui/Button.test.tsx +222 -30
  67. package/src/components/ui/Button.tsx +88 -125
  68. package/src/components/ui/IconButton.tsx +38 -0
  69. package/src/components/ui/Input.tsx +115 -0
  70. package/src/components/ui/Menu.test.tsx +374 -0
  71. package/src/components/ui/Menu.tsx +213 -0
  72. package/src/components/ui/NumberField.tsx +122 -0
  73. package/src/components/ui/Popover.tsx +74 -0
  74. package/src/components/ui/Select.tsx +100 -0
  75. package/src/components/ui/Slider.tsx +153 -0
  76. package/src/components/ui/Tabs.test.tsx +98 -0
  77. package/src/components/ui/Tabs.tsx +71 -0
  78. package/src/components/ui/Toggle.tsx +63 -0
  79. package/src/components/ui/Tooltip.test.tsx +110 -0
  80. package/src/components/ui/Tooltip.tsx +38 -112
  81. package/src/components/ui/cn.test.ts +30 -0
  82. package/src/components/ui/cn.ts +25 -0
  83. package/src/components/ui/index.ts +29 -2
  84. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  85. package/src/components/ui/valueControls.test.tsx +524 -0
  86. package/src/contexts/PanelLayoutContext.tsx +3 -57
  87. package/src/hooks/appHotkeysDispatch.ts +3 -4
  88. package/src/hooks/timelineEditPermission.ts +31 -0
  89. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  90. package/src/hooks/useAppHotkeys.test.ts +0 -1
  91. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  92. package/src/hooks/useAppHotkeys.ts +20 -83
  93. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  94. package/src/hooks/useCaptionDetection.ts +3 -1
  95. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  96. package/src/hooks/useDomEditSession.ts +0 -7
  97. package/src/hooks/useDomEditWiring.ts +4 -9
  98. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  99. package/src/hooks/useEditHistoryActions.ts +106 -0
  100. package/src/hooks/useFileManager.ts +21 -123
  101. package/src/hooks/usePanelLayout.test.ts +108 -296
  102. package/src/hooks/usePanelLayout.ts +50 -255
  103. package/src/hooks/usePersistentEditHistory.ts +1 -1
  104. package/src/hooks/useProjectFileWriter.ts +150 -0
  105. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  106. package/src/hooks/useRightPanelIntents.ts +63 -0
  107. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  108. package/src/hooks/useServerConnection.ts +43 -10
  109. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  110. package/src/hooks/useSlideshowTabState.ts +6 -19
  111. package/src/hooks/useStudioContextValue.test.ts +6 -18
  112. package/src/hooks/useStudioContextValue.ts +5 -26
  113. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  114. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  115. package/src/hooks/useTimelineEditing.ts +103 -16
  116. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  117. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  118. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  119. package/src/hostMount.test.tsx +222 -0
  120. package/src/index.ts +87 -0
  121. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  122. package/src/player/components/Timeline.tsx +1 -1
  123. package/src/player/components/TimelineCanvas.tsx +4 -4
  124. package/src/player/components/TimelineClip.tsx +0 -1
  125. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  126. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  127. package/src/player/components/timelineTheme.test.ts +4 -4
  128. package/src/player/components/timelineTheme.ts +30 -30
  129. package/src/player/lib/playbackShortcuts.ts +2 -0
  130. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  131. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  132. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  133. package/src/player/store/previewIframeStore.test.tsx +29 -0
  134. package/src/player/store/previewIframeStore.ts +25 -0
  135. package/src/renderQueueExports.test.tsx +58 -0
  136. package/src/styles/classCandidates.test.ts +105 -0
  137. package/src/styles/classCandidates.ts +281 -0
  138. package/src/styles/hex-baseline.json +84 -0
  139. package/src/styles/hexRatchet.test.ts +201 -0
  140. package/src/styles/styleSources.test.ts +20 -0
  141. package/src/styles/styleSources.ts +47 -0
  142. package/src/styles/theme.css +57 -0
  143. package/src/styles/theme.test.ts +2 -15
  144. package/src/styles/tokenGate.test.ts +109 -0
  145. package/src/utils/sourcePatcher.ts +1 -1
  146. package/src/utils/studioHelpers.test.ts +18 -0
  147. package/src/utils/studioHelpers.ts +11 -8
  148. package/src/utils/studioUiPreferences.test.ts +10 -16
  149. package/src/utils/studioUiPreferences.ts +0 -16
  150. package/src/utils/typingTarget.ts +4 -0
  151. package/dist/assets/index-C05SNrvY.js +0 -360
  152. package/dist/assets/index-DUR9qzQM.css +0 -1
  153. package/src/components/PanelTabButton.tsx +0 -31
  154. package/src/components/StudioLeftSidebar.tsx +0 -207
  155. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  156. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  157. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  158. package/src/hooks/useInspectorSplitResize.ts +0 -51
  159. package/src/utils/fitPanels.test.ts +0 -164
  160. package/src/utils/fitPanels.ts +0 -151
@@ -1,18 +1,64 @@
1
1
  import type { Direction, DockviewApi } from "dockview-react";
2
2
  import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
3
- import { PANEL_DEFINITIONS, type PanelId } from "./panelRegistry";
3
+ import { PANEL_DEFINITIONS, isPanelId, type PanelId } from "./panelRegistry";
4
4
 
5
5
  const MIN_PREVIEW_W = 360;
6
6
  const MIN_PREVIEW_H = 200;
7
7
  const MIN_TIMELINE_H = 100;
8
8
  const MIN_SIDE_W = 200;
9
+ const MIN_SIDE_W_FLOOR = 120;
9
10
  const DEFAULT_TIMELINE_H = 360;
10
11
 
11
- function defaultSideWidths(viewportWidth: number) {
12
- return {
13
- left: Math.max(MIN_SIDE_W, Math.min(384, Math.round(viewportWidth * 0.257))),
14
- right: Math.max(280, Math.min(424, Math.round(viewportWidth * 0.284))),
15
- };
12
+ /** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */
13
+ export function defaultSideWidths(viewportWidth: number) {
14
+ const floor = sideMinimumWidth(viewportWidth);
15
+ const left = Math.max(floor, Math.min(384, Math.round(viewportWidth * 0.257)));
16
+ const right = Math.max(Math.min(280, floor), Math.min(424, Math.round(viewportWidth * 0.284)));
17
+ const excess = left + right - Math.max(0, viewportWidth - MIN_PREVIEW_W);
18
+ if (excess <= 0) return { left, right };
19
+ const shrunkRight = Math.max(floor, right - excess);
20
+ return { left: Math.max(floor, left - (excess - (right - shrunkRight))), right: shrunkRight };
21
+ }
22
+
23
+ /**
24
+ * Side groups keep MIN_SIDE_W while both fit beside the preview's floor, then shrink down to
25
+ * MIN_SIDE_W_FLOOR; below that the dock overflows and clips rather than squeezing the preview.
26
+ */
27
+ export function sideMinimumWidth(dockWidth: number): number {
28
+ const fair = Math.floor((dockWidth - MIN_PREVIEW_W) / 2);
29
+ return Math.min(MIN_SIDE_W, Math.max(MIN_SIDE_W_FLOOR, fair));
30
+ }
31
+
32
+ function holdsPreview(group: DockviewApi["groups"][number]) {
33
+ return group.panels.some((panel) => panel.id === "preview");
34
+ }
35
+
36
+ /** The zone of a group whose every tab is a side panel, else null. */
37
+ function sideZone(group: DockviewApi["groups"][number]): "left" | "right" | null {
38
+ const zones = group.panels.map((panel) =>
39
+ isPanelId(panel.id) ? PANEL_DEFINITIONS[panel.id].zone : "center",
40
+ );
41
+ const first = zones[0];
42
+ const allSide = zones.every((zone) => zone !== "center");
43
+ return allSide && first && first !== "center" ? first : null;
44
+ }
45
+
46
+ /** Idempotent; rewrites every group's minimum, since dockview keeps a constraint once set. */
47
+ export function applySideMinimums(api: DockviewApi, dockWidth = window.innerWidth) {
48
+ const minimumWidth = sideMinimumWidth(dockWidth);
49
+ const cap = defaultSideWidths(dockWidth);
50
+ for (const group of api.groups) {
51
+ if (holdsPreview(group)) {
52
+ group.api.setConstraints({ minimumWidth: MIN_PREVIEW_W });
53
+ continue;
54
+ }
55
+ group.api.setConstraints({ minimumWidth });
56
+ const zone = sideZone(group);
57
+ const limit = zone === "right" ? cap.right : cap.left;
58
+ if (zone && minimumWidth < MIN_SIDE_W && group.width > limit) {
59
+ group.api.setSize({ width: limit });
60
+ }
61
+ }
16
62
  }
17
63
 
18
64
  function minimumSize(id: PanelId) {
@@ -52,6 +98,7 @@ export function buildEditLayout(api: DockviewApi, viewportWidth: number) {
52
98
  }
53
99
  api.getPanel("compositions")?.api.setActive();
54
100
  api.getPanel("design")?.api.setActive();
101
+ applySideMinimums(api, viewportWidth);
55
102
  api.getPanel("compositions")?.group.api.setSize({ width: widths.left });
56
103
  api.getPanel("design")?.group.api.setSize({ width: widths.right });
57
104
  api.getPanel("timeline")?.group.api.setSize({ height: DEFAULT_TIMELINE_H });
@@ -0,0 +1,28 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { useDockLayoutStore } from "./dockLayoutStore";
3
+ import type { PanelId } from "./panelRegistry";
4
+
5
+ const snapshot = (groupActivePanels: PanelId[], activePanel: PanelId | null = null) => ({
6
+ openPanels: new Set<PanelId>(["design", "renders", "compositions"]),
7
+ visiblePanels: new Set<PanelId>(groupActivePanels),
8
+ activePanel,
9
+ groupActivePanels,
10
+ });
11
+
12
+ describe("dock store sync", () => {
13
+ beforeEach(() => useDockLayoutStore.setState({ lastActive: {} }));
14
+
15
+ it("records what every side group shows, not only the globally active panel", () => {
16
+ useDockLayoutStore.getState().sync(snapshot(["compositions", "renders"], "compositions"));
17
+ expect(useDockLayoutStore.getState().lastActive).toEqual({
18
+ left: "compositions",
19
+ right: "renders",
20
+ });
21
+ });
22
+
23
+ it("forgets the previous dock's memory on detach", () => {
24
+ useDockLayoutStore.getState().sync(snapshot(["renders"], "renders"));
25
+ useDockLayoutStore.getState().detach();
26
+ expect(useDockLayoutStore.getState().lastActive).toEqual({});
27
+ });
28
+ });
@@ -25,6 +25,8 @@ export interface DockSnapshot {
25
25
  /** Open panels whose tab is showing and whose group is not hidden. */
26
26
  visiblePanels: ReadonlySet<PanelId>;
27
27
  activePanel: PanelId | null;
28
+ /** The tab each group shows, so a restored layout still records what every side column last showed. */
29
+ groupActivePanels?: readonly PanelId[];
28
30
  }
29
31
 
30
32
  type LastActive = Partial<Record<PanelZone, PanelId>>;
@@ -53,13 +55,17 @@ export const useDockLayoutStore = create<DockLayoutState>((set, get) => ({
53
55
  lastActive: {},
54
56
  pendingActivation: null,
55
57
  attach: (controller) => set({ controller }),
56
- detach: () => set({ controller: null }),
58
+ detach: () => set({ controller: null, lastActive: {} }),
57
59
  sync: (snapshot) =>
58
60
  set((state) => {
61
+ const lastActive = { ...state.lastActive };
62
+ for (const id of snapshot.groupActivePanels ?? []) {
63
+ const zone = PANEL_DEFINITIONS[id].zone;
64
+ if (zone !== "center") lastActive[zone] = id;
65
+ }
59
66
  const { activePanel } = snapshot;
60
- if (!activePanel) return snapshot;
61
- const zone = PANEL_DEFINITIONS[activePanel].zone;
62
- return { ...snapshot, lastActive: { ...state.lastActive, [zone]: activePanel } };
67
+ if (activePanel) lastActive[PANEL_DEFINITIONS[activePanel].zone] = activePanel;
68
+ return { ...snapshot, lastActive };
63
69
  }),
64
70
  takePendingActivation: () => {
65
71
  const { pendingActivation } = get();
@@ -89,3 +95,14 @@ export const useDockLayoutStore = create<DockLayoutState>((set, get) => ({
89
95
  },
90
96
  resetLayout: () => get().controller?.reset(),
91
97
  }));
98
+
99
+ /** The panel of `zone` the user is looking at: the last one focused if still showing, else any showing. */
100
+ export function visiblePanelInZone(
101
+ zone: PanelZone,
102
+ lastActive: LastActive,
103
+ visiblePanels: ReadonlySet<PanelId>,
104
+ ): PanelId | null {
105
+ const last = lastActive[zone];
106
+ if (last && visiblePanels.has(last)) return last;
107
+ return panelsInZone(zone).find((id) => visiblePanels.has(id)) ?? null;
108
+ }
@@ -0,0 +1,11 @@
1
+ import { createDockview, type DockviewApi } from "dockview-react";
2
+
3
+ /** A real dockview mounted on a fresh host element, with empty panel bodies. */
4
+ export function mountBareDockview(): { host: HTMLElement; api: DockviewApi } {
5
+ const host = document.createElement("div");
6
+ document.body.append(host);
7
+ const api = createDockview(host, {
8
+ createComponent: () => ({ element: document.createElement("div"), init() {}, dispose() {} }),
9
+ });
10
+ return { host, api };
11
+ }
@@ -1,12 +1,55 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
+ import { createElement, act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
3
5
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
4
6
  import { Window } from "happy-dom";
7
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
5
8
  import type { DomEditLayerItem } from "./domEditingTypes";
6
- import { createRafThrottle, sortLayersByZIndex } from "./LayersPanel";
9
+ import { createRafThrottle, LayersPanel, sortLayersByZIndex } from "./LayersPanel";
7
10
  import { isLayerDraggable } from "./useLayerDrag";
8
11
  import { liveTime } from "../../player";
9
12
 
13
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ const mocks = vi.hoisted(() => ({
16
+ previewIframeRef: { current: null } as { current: unknown },
17
+ collect: vi.fn(),
18
+ setActiveGroupElement: vi.fn(),
19
+ showToast: vi.fn(),
20
+ }));
21
+
22
+ vi.mock("../../contexts/StudioContext", () => ({
23
+ useStudioShellContext: () => ({
24
+ previewIframeRef: mocks.previewIframeRef,
25
+ activeCompPath: "index.html",
26
+ showToast: mocks.showToast,
27
+ }),
28
+ useStudioPlaybackContext: () => ({
29
+ refreshKey: 0,
30
+ compositionLoading: true,
31
+ timelineElements: [],
32
+ isPlaying: false,
33
+ }),
34
+ }));
35
+ vi.mock("../../contexts/DomEditContext", () => ({
36
+ useDomEditContext: () => ({
37
+ domEditSelection: null,
38
+ activeGroupElement: null,
39
+ applyDomSelection: vi.fn(),
40
+ updateDomEditHoverSelection: vi.fn(),
41
+ handleDomZIndexReorderCommit: vi.fn(),
42
+ setActiveGroupElement: mocks.setActiveGroupElement,
43
+ }),
44
+ }));
45
+ vi.mock("../nle/useCanvasZOrderTimelineMirror", () => ({
46
+ useLayerReorderTimelineMirror: () => vi.fn(),
47
+ }));
48
+ vi.mock("./domEditing", async (importOriginal) => ({
49
+ ...(await importOriginal<typeof import("./domEditing")>()),
50
+ collectDomEditLayerItems: mocks.collect,
51
+ }));
52
+
10
53
  function makeLayer(
11
54
  overrides: Partial<DomEditLayerItem> & { zIndex?: string; locked?: boolean },
12
55
  ): DomEditLayerItem {
@@ -197,3 +240,56 @@ describe("liveTime subscribe / unsubscribe (LayersPanel scrub contract)", () =>
197
240
  unsubscribe();
198
241
  });
199
242
  });
243
+
244
+ describe("LayersPanel preview promotion", () => {
245
+ let root: Root;
246
+ let host: HTMLDivElement;
247
+
248
+ function makeIframe(compId: string): HTMLIFrameElement {
249
+ const iframe = document.createElement("iframe");
250
+ const doc = new Window().document;
251
+ doc.body.innerHTML = `<div data-composition-id="${compId}"></div>`;
252
+ Object.defineProperty(iframe, "contentDocument", { value: doc });
253
+ return iframe;
254
+ }
255
+
256
+ const collectedRootIds = () =>
257
+ mocks.collect.mock.calls.map((call) => (call[0] as HTMLElement).dataset.compositionId);
258
+
259
+ beforeEach(() => {
260
+ mocks.collect.mockReset().mockReturnValue([]);
261
+ host = document.createElement("div");
262
+ document.body.append(host);
263
+ root = createRoot(host);
264
+ });
265
+
266
+ afterEach(() => {
267
+ act(() => root.unmount());
268
+ host.remove();
269
+ usePreviewIframeStore.setState({ iframe: null });
270
+ });
271
+
272
+ it("recollects layers from the promoted document's load, not the replaced one", () => {
273
+ const a = makeIframe("a");
274
+ const b = makeIframe("b");
275
+ mocks.previewIframeRef.current = a;
276
+ usePreviewIframeStore.setState({ iframe: a });
277
+ act(() => root.render(createElement(LayersPanel)));
278
+
279
+ act(() => {
280
+ mocks.previewIframeRef.current = b;
281
+ usePreviewIframeStore.getState().setIframe(b);
282
+ });
283
+ mocks.collect.mockClear();
284
+
285
+ act(() => {
286
+ a.dispatchEvent(new Event("load"));
287
+ });
288
+ expect(mocks.collect).not.toHaveBeenCalled();
289
+
290
+ act(() => {
291
+ b.dispatchEvent(new Event("load"));
292
+ });
293
+ expect(collectedRootIds()).toEqual(["b"]);
294
+ });
295
+ });
@@ -1,3 +1,4 @@
1
+ import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
1
2
  import { memo, useState, useCallback, useEffect, useRef } from "react";
2
3
  import {
3
4
  collectDomEditLayerItems,
@@ -122,6 +123,7 @@ export const LayersPanel = memo(function LayersPanel() {
122
123
 
123
124
  const isMasterView = !activeCompPath || activeCompPath === "index.html";
124
125
 
126
+ const livePreviewIframe = useLivePreviewIframe();
125
127
  const collectLayers = useCallback(() => {
126
128
  const iframe = previewIframeRef.current;
127
129
  if (!iframe) return;
@@ -152,7 +154,7 @@ export const LayersPanel = memo(function LayersPanel() {
152
154
 
153
155
  useEffect(() => {
154
156
  collectLayers();
155
- }, [collectLayers, refreshKey, zEditVersion]);
157
+ }, [collectLayers, refreshKey, zEditVersion, livePreviewIframe]);
156
158
 
157
159
  useEffect(() => {
158
160
  const iframe = previewIframeRef.current;
@@ -163,7 +165,7 @@ export const LayersPanel = memo(function LayersPanel() {
163
165
  };
164
166
  iframe.addEventListener("load", handleLoad);
165
167
  return () => iframe.removeEventListener("load", handleLoad);
166
- }, [previewIframeRef, collectLayers]);
168
+ }, [previewIframeRef, livePreviewIframe, collectLayers]);
167
169
 
168
170
  useEffect(() => {
169
171
  if (!compositionLoading) {
@@ -8,7 +8,7 @@ interface PreviewGuidesProps {
8
8
  }
9
9
 
10
10
  const TICKS = Array.from({ length: 11 }, (_, i) => i * 10);
11
- const INK = "rgba(255,255,255,0.7)";
11
+ const INK = "color-mix(in srgb, white 70%, transparent)";
12
12
 
13
13
  /** Ruler and safe-margin boxes drawn over the preview pane, never inside the composition. */
14
14
  export const PreviewGuides = memo(function PreviewGuides({ iframeRef }: PreviewGuidesProps) {
@@ -5,7 +5,6 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { useAppHotkeys } from "../../hooks/useAppHotkeys";
7
7
  import { usePlayerStore } from "../../player/store/playerStore";
8
- import type { LeftSidebarHandle } from "../sidebar/LeftSidebar";
9
8
  import type { DomEditSelection } from "./domEditing";
10
9
  import { SnapToolbar } from "./SnapToolbar";
11
10
  import { usePreviewGuidesStore } from "./previewGuidesStore";
@@ -31,7 +30,6 @@ function renderToolbar(onSnapChange = vi.fn()) {
31
30
  function AppHotkeyHarness() {
32
31
  const domEditSelectionRef = useRef<DomEditSelection | null>(null);
33
32
  const clearDomSelectionRef = useRef<() => void>(() => undefined);
34
- const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
35
33
 
36
34
  useAppHotkeys({
37
35
  handleTimelineElementsDelete: vi.fn(async () => {}),
@@ -50,7 +48,6 @@ function AppHotkeyHarness() {
50
48
  showToast: vi.fn(),
51
49
  syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
52
50
  waitForPendingDomEditSaves: vi.fn(async () => undefined),
53
- leftSidebarRef,
54
51
  handleCopy: vi.fn(() => false),
55
52
  handlePaste: vi.fn(async () => undefined),
56
53
  handleCut: vi.fn(async () => false),
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { studioEditLifecycle, type StudioWriteResult } from "../../webmcp/writeCoordinator";
7
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
7
8
  import { TopologyLens } from "./TopologyLens";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -291,4 +292,31 @@ describe("TopologyLens", () => {
291
292
  expect(vi.getTimerCount()).toBe(0);
292
293
  expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" });
293
294
  });
295
+
296
+ it("dismisses on the promoted iframe's load, not the replaced one", () => {
297
+ const a = document.createElement("iframe");
298
+ const b = document.createElement("iframe");
299
+ document.body.append(a, b);
300
+ const ref = { current: a as HTMLIFrameElement | null };
301
+ usePreviewIframeStore.setState({ iframe: a });
302
+ window.matchMedia = matchMedia(false);
303
+ host = document.createElement("div");
304
+ document.body.append(host);
305
+ root = createRoot(host);
306
+ act(() => {
307
+ root?.render(<TopologyLens iframeRef={ref} activeCompositionPath="index.html" />);
308
+ });
309
+ begin();
310
+
311
+ act(() => {
312
+ ref.current = b;
313
+ usePreviewIframeStore.getState().setIframe(b);
314
+ });
315
+ act(() => a.dispatchEvent(new Event("load")));
316
+ expect(host.querySelector('[data-topology-lens="hidden"]')).toBeNull();
317
+
318
+ act(() => b.dispatchEvent(new Event("load")));
319
+ expect(host.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
320
+ usePreviewIframeStore.setState({ iframe: null });
321
+ });
294
322
  });
@@ -1,3 +1,4 @@
1
+ import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
1
2
  import {
2
3
  useEffect,
3
4
  useLayoutEffect,
@@ -113,13 +114,14 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
113
114
  setMeasured(geometry ? { callId, geometry } : null);
114
115
  }, [activeCompositionPath, callId, handle, iframeRef, phase]);
115
116
 
117
+ const livePreviewIframe = useLivePreviewIframe();
116
118
  useEffect(() => {
117
119
  const iframe = iframeRef.current;
118
120
  if (!iframe || !callId) return;
119
121
  const dismiss = () => studioEditLifecycle.dismiss(callId);
120
122
  iframe.addEventListener("load", dismiss);
121
123
  return () => iframe.removeEventListener("load", dismiss);
122
- }, [callId, iframeRef]);
124
+ }, [callId, iframeRef, livePreviewIframe]);
123
125
 
124
126
  useEffect(() => {
125
127
  if (pendingUnmountDismissRef.current !== null) {
@@ -4,6 +4,8 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
7
+ import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
8
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
7
9
  import { useColorGradingController } from "./useColorGradingController";
8
10
  import type { DomEditSelection } from "./domEditing";
9
11
 
@@ -22,6 +24,7 @@ function cleanStudioGrading() {
22
24
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
23
25
 
24
26
  afterEach(() => {
27
+ usePreviewIframeStore.setState({ iframe: null });
25
28
  document.body.innerHTML = "";
26
29
  });
27
30
 
@@ -737,3 +740,38 @@ describe("useColorGradingController", () => {
737
740
  vi.unstubAllGlobals();
738
741
  });
739
742
  });
743
+
744
+ describe("useColorGradingController after the preview iframe is replaced", () => {
745
+ it("replays the grade to the new iframe on its ready message and ignores the old one", () => {
746
+ vi.useFakeTimers();
747
+ const a = createPreviewFrame();
748
+ const b = createPreviewFrame();
749
+ const ref = { current: a.iframe as HTMLIFrameElement | null };
750
+ usePreviewIframeStore.getState().setIframe(a.iframe);
751
+ const initialElement = makeElement({
752
+ dataAttributes: { "color-grading": JSON.stringify({ preset: "bright-pop", intensity: 1 }) },
753
+ });
754
+ const { root } = renderHook(vi.fn(), initialElement, ref);
755
+ act(() => vi.advanceTimersByTime(100));
756
+
757
+ const postToA = vi.spyOn(a.contentWindow, "postMessage");
758
+ const postToB = vi.spyOn(b.contentWindow, "postMessage");
759
+ act(() => {
760
+ ref.current = b.iframe;
761
+ usePreviewIframeStore.getState().setIframe(b.iframe);
762
+ });
763
+ const ready = { source: "hf-preview", type: "ready", ...runtimeProtocolMetadata(30) };
764
+ act(() => {
765
+ window.dispatchEvent(new MessageEvent("message", { data: ready, source: a.contentWindow }));
766
+ });
767
+ expect(colorGradingMessages(postToB.mock.calls)).toHaveLength(0);
768
+ act(() => {
769
+ window.dispatchEvent(new MessageEvent("message", { data: ready, source: b.contentWindow }));
770
+ });
771
+
772
+ expect(colorGradingMessages(postToB.mock.calls)).toHaveLength(1);
773
+ expect(postToA).not.toHaveBeenCalled();
774
+ act(() => root.unmount());
775
+ vi.useRealTimers();
776
+ });
777
+ });
@@ -1,3 +1,4 @@
1
+ import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
1
2
  import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
3
  import {
3
4
  HF_COLOR_GRADING_ATTR,
@@ -430,6 +431,7 @@ export function useColorGradingController({
430
431
  [previewIframeRef, target],
431
432
  );
432
433
 
434
+ const livePreviewIframe = useLivePreviewIframe();
433
435
  useEffect(() => {
434
436
  const iframe = previewIframeRef?.current;
435
437
  if (!iframe) return;
@@ -455,7 +457,13 @@ export function useColorGradingController({
455
457
  window.removeEventListener("message", onMessage);
456
458
  window.clearTimeout(timer);
457
459
  };
458
- }, [postColorGrading, postCompare, previewIframeRef, scheduleRuntimeStatusRefresh]);
460
+ }, [
461
+ postColorGrading,
462
+ postCompare,
463
+ previewIframeRef,
464
+ scheduleRuntimeStatusRefresh,
465
+ livePreviewIframe,
466
+ ]);
459
467
 
460
468
  useEffect(
461
469
  () => () => {
@@ -0,0 +1,76 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
7
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
8
+ import { useColorGradingPreviews } from "./useColorGradingPreviews";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ afterEach(() => {
13
+ usePreviewIframeStore.setState({ iframe: null });
14
+ document.body.innerHTML = "";
15
+ });
16
+
17
+ function createFrame() {
18
+ return document.body.appendChild(document.createElement("iframe"));
19
+ }
20
+
21
+ function installRenderer(iframe: HTMLIFrameElement) {
22
+ const renderPreviews = vi.fn().mockResolvedValue({
23
+ width: 160,
24
+ height: 90,
25
+ images: [{ id: "bright-pop", dataUrl: "data:image/png;base64,x" }],
26
+ });
27
+ (iframe.contentWindow as unknown as { __hf: unknown }).__hf = {
28
+ colorGrading: { renderPreviews },
29
+ };
30
+ return renderPreviews;
31
+ }
32
+
33
+ describe("useColorGradingPreviews after the preview iframe is replaced", () => {
34
+ it("requests preset previews from the new iframe's runtime", async () => {
35
+ vi.useFakeTimers();
36
+ const a = createFrame();
37
+ const b = createFrame();
38
+ const renderOnB = installRenderer(b);
39
+ const ref = { current: a as HTMLIFrameElement | null };
40
+ usePreviewIframeStore.getState().setIframe(a);
41
+ const grading = normalizeHfColorGrading({ preset: "neutral" });
42
+ if (!grading) throw new Error("expected neutral grading");
43
+ const gradingRef = { current: grading };
44
+ const target = { selector: "#v" } as never;
45
+ const postColorGrading = vi.fn();
46
+ let requestPresetPreviews: () => void = () => {};
47
+ function Host() {
48
+ requestPresetPreviews = useColorGradingPreviews({
49
+ grading,
50
+ gradingRef,
51
+ identityKey: "k",
52
+ target,
53
+ previewIframeRef: ref,
54
+ postColorGrading,
55
+ }).requestPresetPreviews;
56
+ return null;
57
+ }
58
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
59
+ act(() => root.render(React.createElement(Host)));
60
+ act(() => requestPresetPreviews());
61
+ act(() => vi.advanceTimersByTime(0));
62
+
63
+ act(() => {
64
+ ref.current = b;
65
+ usePreviewIframeStore.getState().setIframe(b);
66
+ });
67
+ act(() => vi.advanceTimersByTime(0));
68
+ await act(async () => {
69
+ await Promise.resolve();
70
+ });
71
+
72
+ expect(renderOnB).toHaveBeenCalled();
73
+ act(() => root.unmount());
74
+ vi.useRealTimers();
75
+ });
76
+ });
@@ -1,3 +1,4 @@
1
+ import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
1
2
  import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
2
3
  import {
3
4
  HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
@@ -155,6 +156,7 @@ export function useColorGradingPreviews({
155
156
  }
156
157
  const previews = state.identityKey === identityKey ? state.previews : emptyPreviews();
157
158
 
159
+ const livePreviewIframe = useLivePreviewIframe();
158
160
  useEffect(() => {
159
161
  if (!request) return;
160
162
  const { kind } = request;
@@ -245,7 +247,15 @@ export function useColorGradingPreviews({
245
247
  iframe.removeEventListener("load", attempt);
246
248
  window.removeEventListener("message", onMessage);
247
249
  };
248
- }, [grading.effects, grading.lut, grading.palette, previewIframeRef, request, target]);
250
+ }, [
251
+ grading.effects,
252
+ grading.lut,
253
+ grading.palette,
254
+ previewIframeRef,
255
+ livePreviewIframe,
256
+ request,
257
+ target,
258
+ ]);
249
259
 
250
260
  const stopAnimatedPreview = useCallback(() => {
251
261
  const session = animatedRef.current;