@hyperframes/studio 0.8.51 → 0.8.52

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 (99) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-I5YRzB02.js} +1 -1
  2. package/dist/assets/{index-Clrb11lj.js → index-BTAfgesM.js} +1 -1
  3. package/dist/assets/index-CxAu_Okg.css +1 -0
  4. package/dist/assets/index-DocJlW2v.js +376 -0
  5. package/dist/assets/{index-CPS3Cvj8.js → index-Du5x7KUR.js} +1 -1
  6. package/dist/index.d.ts +401 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6545 -5869
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -7
  11. package/src/App.tsx +3 -2
  12. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  13. package/src/captions/components/CaptionOverlay.tsx +8 -4
  14. package/src/components/EditorShell.tsx +4 -0
  15. package/src/components/RightPanelTabs.test.tsx +151 -0
  16. package/src/components/RightPanelTabs.tsx +59 -0
  17. package/src/components/StudioHeader.dom.test.tsx +181 -0
  18. package/src/components/StudioHeader.tsx +109 -149
  19. package/src/components/StudioRightPanel.tsx +47 -35
  20. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  21. package/src/components/TimelineHistoryButtons.tsx +54 -0
  22. package/src/components/TimelineToolbar.test.tsx +8 -0
  23. package/src/components/TimelineToolbar.tsx +3 -7
  24. package/src/components/dock/Dock.test.tsx +84 -16
  25. package/src/components/dock/Dock.tsx +19 -2
  26. package/src/components/dock/dock.css +5 -0
  27. package/src/components/dock/dockAccessibility.test.ts +166 -0
  28. package/src/components/dock/dockAccessibility.ts +98 -0
  29. package/src/components/dock/dockLayout.test.ts +134 -0
  30. package/src/components/dock/dockLayout.ts +53 -6
  31. package/src/components/dock/dockTestHarness.ts +11 -0
  32. package/src/components/editor/LayersPanel.test.ts +97 -1
  33. package/src/components/editor/LayersPanel.tsx +4 -2
  34. package/src/components/editor/PreviewGuides.tsx +1 -1
  35. package/src/components/editor/TopologyLens.test.tsx +28 -0
  36. package/src/components/editor/TopologyLens.tsx +3 -1
  37. package/src/components/editor/useColorGradingController.test.ts +38 -0
  38. package/src/components/editor/useColorGradingController.ts +9 -1
  39. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  40. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  41. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  42. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  43. package/src/components/nle/NLEPreview.tsx +1 -1
  44. package/src/components/nle/PreviewPane.tsx +1 -1
  45. package/src/components/pointerTargetSize.test.tsx +8 -0
  46. package/src/components/renders/RenderQueue.tsx +1 -1
  47. package/src/components/timelineToolbarStyles.ts +5 -0
  48. package/src/components/ui/Button.test.tsx +222 -30
  49. package/src/components/ui/Button.tsx +88 -125
  50. package/src/components/ui/IconButton.tsx +38 -0
  51. package/src/components/ui/Input.tsx +115 -0
  52. package/src/components/ui/Menu.test.tsx +374 -0
  53. package/src/components/ui/Menu.tsx +213 -0
  54. package/src/components/ui/NumberField.tsx +122 -0
  55. package/src/components/ui/Popover.tsx +74 -0
  56. package/src/components/ui/Select.tsx +100 -0
  57. package/src/components/ui/Slider.tsx +153 -0
  58. package/src/components/ui/Tabs.test.tsx +98 -0
  59. package/src/components/ui/Tabs.tsx +71 -0
  60. package/src/components/ui/Toggle.tsx +63 -0
  61. package/src/components/ui/Tooltip.test.tsx +110 -0
  62. package/src/components/ui/Tooltip.tsx +38 -112
  63. package/src/components/ui/cn.test.ts +30 -0
  64. package/src/components/ui/cn.ts +25 -0
  65. package/src/components/ui/index.ts +29 -2
  66. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  67. package/src/components/ui/valueControls.test.tsx +524 -0
  68. package/src/hooks/useAppHotkeys.ts +20 -79
  69. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  70. package/src/hooks/useCaptionDetection.ts +3 -1
  71. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  72. package/src/hooks/useEditHistoryActions.ts +106 -0
  73. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  74. package/src/hooks/useServerConnection.ts +43 -10
  75. package/src/index.ts +51 -0
  76. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  77. package/src/player/components/TimelineCanvas.tsx +4 -4
  78. package/src/player/components/TimelineClip.tsx +0 -1
  79. package/src/player/lib/playbackShortcuts.ts +2 -0
  80. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  81. package/src/player/store/previewIframeStore.test.tsx +29 -0
  82. package/src/player/store/previewIframeStore.ts +25 -0
  83. package/src/renderQueueExports.test.tsx +58 -0
  84. package/src/styles/classCandidates.test.ts +105 -0
  85. package/src/styles/classCandidates.ts +281 -0
  86. package/src/styles/hex-baseline.json +84 -0
  87. package/src/styles/hexRatchet.test.ts +201 -0
  88. package/src/styles/styleSources.test.ts +20 -0
  89. package/src/styles/styleSources.ts +47 -0
  90. package/src/styles/theme.css +30 -0
  91. package/src/styles/theme.test.ts +2 -15
  92. package/src/styles/tokenGate.test.ts +109 -0
  93. package/src/utils/sourcePatcher.ts +1 -1
  94. package/src/utils/studioHelpers.test.ts +18 -0
  95. package/src/utils/studioHelpers.ts +11 -1
  96. package/src/utils/typingTarget.ts +4 -0
  97. package/dist/assets/index-C05SNrvY.js +0 -360
  98. package/dist/assets/index-DUR9qzQM.css +0 -1
  99. package/src/components/PanelTabButton.tsx +0 -31
@@ -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) {
@@ -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;
@@ -0,0 +1,95 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act, useRef } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
7
+ import {
8
+ usePreviewCompositionRect,
9
+ type PreviewCompositionRect,
10
+ } from "./usePreviewCompositionRect";
11
+
12
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ afterEach(() => {
15
+ usePreviewIframeStore.getState().setIframe(null);
16
+ document.body.innerHTML = "";
17
+ });
18
+
19
+ function boxed<T extends HTMLElement>(el: T, box: [number, number, number, number]): T {
20
+ const [left, top, width, height] = box;
21
+ el.getBoundingClientRect = () => new DOMRect(left, top, width, height);
22
+ return el;
23
+ }
24
+
25
+ function mountProbe() {
26
+ let rect: PreviewCompositionRect | undefined;
27
+ function Probe() {
28
+ const ref = useRef<HTMLDivElement>(null);
29
+ rect = usePreviewCompositionRect(ref);
30
+ return (
31
+ <div
32
+ ref={(el) => {
33
+ ref.current = el;
34
+ if (el) boxed(el, [10, 20, 1000, 600]);
35
+ }}
36
+ />
37
+ );
38
+ }
39
+ const host = document.createElement("div");
40
+ document.body.append(host);
41
+ act(() => createRoot(host).render(<Probe />));
42
+ return () => rect;
43
+ }
44
+
45
+ function compositionFrame(box: [number, number, number, number], width: number, height: number) {
46
+ const iframe = boxed(document.createElement("iframe"), box);
47
+ document.body.append(iframe);
48
+ const root = iframe.contentDocument?.createElement("div");
49
+ root?.setAttribute("data-composition-id", "main");
50
+ root?.setAttribute("data-width", String(width));
51
+ root?.setAttribute("data-height", String(height));
52
+ if (root) iframe.contentDocument?.body.append(root);
53
+ return iframe;
54
+ }
55
+
56
+ const settle = () => new Promise((r) => setTimeout(r, 400));
57
+
58
+ describe("usePreviewCompositionRect", () => {
59
+ it("reports the live preview iframe's box relative to the overlay, scaled to the composition", async () => {
60
+ const read = mountProbe();
61
+ const iframe = compositionFrame([110, 70, 960, 540], 1920, 1080);
62
+ await act(async () => {
63
+ usePreviewIframeStore.getState().setIframe(iframe);
64
+ await settle();
65
+ });
66
+ expect(read()).toEqual({
67
+ left: 100,
68
+ top: 50,
69
+ width: 960,
70
+ height: 540,
71
+ scaleX: 0.5,
72
+ scaleY: 0.5,
73
+ });
74
+ });
75
+
76
+ it("follows the iframe when a reload replaces it", async () => {
77
+ const read = mountProbe();
78
+ await act(async () => {
79
+ usePreviewIframeStore.getState().setIframe(compositionFrame([110, 70, 960, 540], 1920, 1080));
80
+ await settle();
81
+ });
82
+ await act(async () => {
83
+ usePreviewIframeStore.getState().setIframe(compositionFrame([60, 40, 500, 500], 1000, 1000));
84
+ await settle();
85
+ });
86
+ expect(read()).toEqual({
87
+ left: 50,
88
+ top: 20,
89
+ width: 500,
90
+ height: 500,
91
+ scaleX: 0.5,
92
+ scaleY: 0.5,
93
+ });
94
+ });
95
+ });
@@ -0,0 +1,21 @@
1
+ import { useMemo, type RefObject } from "react";
2
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
3
+ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
4
+
5
+ export type { DomEditCompositionRect as PreviewCompositionRect } from "./useDomEditCompositionRect";
6
+
7
+ /**
8
+ * Where the composition sits inside `overlayRef`, in overlay pixels, plus the scale from
9
+ * composition units to pixels. Follows the live preview iframe across reloads.
10
+ */
11
+ export function usePreviewCompositionRect(overlayRef: RefObject<HTMLDivElement | null>) {
12
+ const iframeRef = useMemo<RefObject<HTMLIFrameElement | null>>(
13
+ () => ({
14
+ get current() {
15
+ return usePreviewIframeStore.getState().iframe;
16
+ },
17
+ }),
18
+ [],
19
+ );
20
+ return useDomEditCompositionRect({ iframeRef, overlayRef });
21
+ }
@@ -442,7 +442,7 @@ export const NLEPreview = memo(function NLEPreview({
442
442
  <div className="flex flex-col h-full min-h-0">
443
443
  <div
444
444
  ref={viewportRef}
445
- className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-hidden focus:ring-1 focus:ring-studio-accent/40 bg-neutral-950"
445
+ className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-hidden focus:ring-1 focus:ring-studio-accent/40 bg-[var(--studio-preview-bg,var(--color-neutral-950))]"
446
446
  tabIndex={0}
447
447
  aria-label="Composition preview"
448
448
  >
@@ -110,7 +110,7 @@ export function PreviewPane({
110
110
  ref={containerRef}
111
111
  // Panel chrome (rounded border) is dropped in fullscreen so the preview
112
112
  // fills the screen edge-to-edge.
113
- className={`flex-1 min-h-0 flex flex-col overflow-hidden bg-neutral-950 ${
113
+ className={`flex-1 min-h-0 flex flex-col overflow-hidden bg-[var(--studio-preview-bg,var(--color-neutral-950))] ${
114
114
  isFullscreen ? "" : "rounded-lg border border-neutral-800/50"
115
115
  }`}
116
116
  data-studio-fullscreen-target=""
@@ -6,6 +6,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { CompositionsTab } from "./sidebar/CompositionsTab";
7
7
  import { TimelineToolbar } from "./TimelineToolbar";
8
8
 
9
+ vi.mock("../contexts/StudioContext", () => ({
10
+ useStudioShellContext: () => ({
11
+ editHistory: { canUndo: false, canRedo: false },
12
+ handleUndo: vi.fn(),
13
+ handleRedo: vi.fn(),
14
+ }),
15
+ }));
16
+
9
17
  // WCAG 2.2 (2.5.8) requires a 24x24 CSS pixel pointer target. happy-dom has no
10
18
  // CSS engine, so getBoundingClientRect() is 0x0 for everything here and the size
11
19
  // itself cannot be measured — these assert the utility classes that PRODUCE the
@@ -22,7 +22,7 @@ type StartRenderHandler = (
22
22
  fps: 24 | 30 | 60,
23
23
  ) => void | Promise<void>;
24
24
 
25
- interface RenderQueueProps {
25
+ export interface RenderQueueProps {
26
26
  jobs: RenderJob[];
27
27
  projectId: string;
28
28
  onDelete: (jobId: string) => void;
@@ -0,0 +1,5 @@
1
+ // CapCut-flat icon buttons: a transparent 28px hit area with a subtle hover wash.
2
+ export const flatBtn = "flex h-7 w-7 items-center justify-center rounded-md transition-colors";
3
+ export const flatIdle = `${flatBtn} text-neutral-400 hover:bg-white/6 hover:text-neutral-200 active:scale-[0.98]`;
4
+ export const flatActive = `${flatBtn} bg-white/8 text-neutral-100 active:scale-[0.98]`;
5
+ export const flatDisabled = `${flatBtn} text-neutral-700 cursor-not-allowed`;