@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
@@ -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
+ }
@@ -4,17 +4,11 @@ import { useTimelinePlayer, usePlayerStore } from "../../player";
4
4
  import type { TimelineElement } from "../../player";
5
5
  import type { CompositionLevel } from "./CompositionBreadcrumb";
6
6
  import { useCompositionStack } from "./useCompositionStack";
7
- import { MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
8
7
  import { setCompositionSourceMap } from "../editor/domEditingDom";
9
8
  import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
10
- import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
11
9
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
12
10
  import { createStableContext } from "../../utils/hmrStableContext";
13
11
 
14
- // Timeline gets a generous default height so the preview isn't oversized and the
15
- // tracks have room to breathe (CapCut-style). Users can still drag the divider.
16
- const DEFAULT_TIMELINE_H = 360;
17
-
18
12
  export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
19
13
  return compositionLoading;
20
14
  }
@@ -34,10 +28,6 @@ export interface NLEContextValue {
34
28
  handleDrillDown: (element: TimelineElement) => void;
35
29
  compIdToSrc: Map<string, string>;
36
30
  // layout state
37
- timelineH: number;
38
- setTimelineH: React.Dispatch<React.SetStateAction<number>>;
39
- persistTimelineH: (height: number) => void;
40
- containerRef: React.RefObject<HTMLDivElement | null>;
41
31
  // composition loading
42
32
  compositionLoading: boolean;
43
33
  setCompositionLoading: (loading: boolean) => void;
@@ -256,35 +246,6 @@ export function NLEProvider({
256
246
  });
257
247
  }, [compIdToSrc]);
258
248
 
259
- // Resizable timeline height — persisted alongside zoom/pan so the user's
260
- // workspace layout survives reloads.
261
- const [timelineH, setTimelineH] = useState(() => {
262
- const stored = readStudioUiPreferences().timelineHeight;
263
- return stored !== undefined && stored >= MIN_TIMELINE_H ? stored : DEFAULT_TIMELINE_H;
264
- });
265
- const persistTimelineH = useCallback((height: number) => {
266
- writeStudioUiPreferences({ timelineHeight: Math.round(height) });
267
- }, []);
268
- const containerRef = useRef<HTMLDivElement>(null);
269
- // A height persisted on a tall window can exceed this window's container and
270
- // collapse the flex-1 preview to 0px. Observing the container rather than
271
- // clamping once at mount is what makes a window RESIZED after load behave the
272
- // same as one loaded at that size: dragging 760 -> 520 tall used to leave the
273
- // timeline at its stored 429px and the preview at 47px.
274
- useEffect(() => {
275
- const element = containerRef.current;
276
- if (!element || typeof ResizeObserver === "undefined") return;
277
- const reconcile = () => {
278
- const containerH = element.getBoundingClientRect().height;
279
- if (!containerH) return;
280
- setTimelineH((prev) => fitTimelineHeight(containerH, prev));
281
- };
282
- reconcile();
283
- const observer = new ResizeObserver(reconcile);
284
- observer.observe(element);
285
- return () => observer.disconnect();
286
- }, []);
287
-
288
249
  const hasLoadedOnceRef = useRef(false);
289
250
  const [compositionLoading, setCompositionLoadingRaw] = useState(true);
290
251
  const setCompositionLoading = useCallback((loading: boolean) => {
@@ -317,10 +278,6 @@ export function NLEProvider({
317
278
  handleNavigateComposition,
318
279
  handleDrillDown,
319
280
  compIdToSrc,
320
- timelineH,
321
- setTimelineH,
322
- persistTimelineH,
323
- containerRef,
324
281
  compositionLoading,
325
282
  setCompositionLoading,
326
283
  timelineDisabled,
@@ -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=""
@@ -2,7 +2,6 @@ import { useCallback, type ReactNode } from "react";
2
2
  import { Timeline } from "../../player";
3
3
  import type { TimelineElement } from "../../player";
4
4
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
- import { TimelineResizeDivider } from "./TimelineResizeDivider";
6
5
  import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
7
6
  import { trackStudioExpandedClipEdit } from "../../telemetry/events";
8
7
  import { useNLEContext } from "./NLEContext";
@@ -78,6 +77,9 @@ export interface TimelinePaneProps {
78
77
  timelineToolbar?: ReactNode;
79
78
  /** Slot rendered below the timeline tracks */
80
79
  timelineFooter?: ReactNode;
80
+ /** Slot for a host's own overlay spanning the whole timeline area (e.g. a draft/ghost
81
+ * clip), positioned absolutely the same way PreviewPane's previewOverlay is. */
82
+ timelineOverlay?: ReactNode;
81
83
  /** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
82
84
  renderClipContent?: (
83
85
  element: TimelineElement,
@@ -114,6 +116,7 @@ export interface TimelinePaneProps {
114
116
  export function TimelinePane({
115
117
  timelineToolbar,
116
118
  timelineFooter,
119
+ timelineOverlay,
117
120
  renderClipContent,
118
121
  onFileDrop,
119
122
  onDeleteElement,
@@ -132,10 +135,6 @@ export function TimelinePane({
132
135
  handleDrillDown,
133
136
  compositionStack,
134
137
  updateCompositionStack,
135
- timelineH,
136
- setTimelineH,
137
- persistTimelineH,
138
- containerRef,
139
138
  timelineDisabled,
140
139
  timelineSessionEpoch,
141
140
  } = useNLEContext();
@@ -254,72 +253,59 @@ export function TimelinePane({
254
253
  );
255
254
 
256
255
  return (
257
- <>
258
- <TimelineResizeDivider
259
- timelineH={timelineH}
260
- setTimelineH={setTimelineH}
261
- persistTimelineH={persistTimelineH}
262
- containerRef={containerRef}
263
- disabled={timelineDisabled}
264
- />
265
-
266
- {/* Timeline section — inner padding (not margin) keeps the divider's
267
- height math exact while giving the panel a gap from the shell edges. */}
256
+ <div className="relative flex h-full flex-col" aria-disabled={timelineDisabled || undefined}>
268
257
  <div
269
- className="relative flex flex-col shrink-0 px-px pb-px"
270
- style={{ height: timelineH }}
271
- aria-disabled={timelineDisabled || undefined}
258
+ className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
259
+ onDoubleClick={(e) => {
260
+ if ((e.target as HTMLElement).closest("[data-clip]")) return;
261
+ if (timelineDisabled) return;
262
+ if (compositionStack.length > 1) {
263
+ updateCompositionStack((prev) => prev.slice(0, -1));
264
+ }
265
+ }}
272
266
  >
267
+ <div className="shrink-0">{timelineToolbar}</div>
268
+ <Timeline
269
+ sessionEpoch={timelineSessionEpoch}
270
+ onSeek={seek}
271
+ onDrillDown={handleDrillDown}
272
+ renderClipContent={renderClipContent}
273
+ onFileDrop={onFileDrop}
274
+ onDeleteElement={handleDeleteElement}
275
+ onAssetDrop={onAssetDrop}
276
+ onBlockDrop={onBlockDrop}
277
+ onCompositionDrop={onCompositionDrop}
278
+ onMoveElement={handleMoveElement}
279
+ onMoveElements={handleMoveElements}
280
+ onResizeElement={handleResizeElement}
281
+ onResizeElements={handleResizeElements}
282
+ onBlockedEditAttempt={onBlockedEditAttempt}
283
+ onSplitElement={handleSplitElement}
284
+ onSelectElement={onSelectTimelineElement}
285
+ onCopyClip={onCopyClip}
286
+ onPasteClip={onPasteClip}
287
+ onDuplicateClip={onDuplicateClip}
288
+ canPasteClip={canPasteClip}
289
+ />
290
+ </div>
291
+ {timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
292
+ {timelineOverlay && (
293
+ <div className="pointer-events-none absolute inset-0 z-20">{timelineOverlay}</div>
294
+ )}
295
+ {timelineDisabled && (
273
296
  <div
274
- className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
275
- onDoubleClick={(e) => {
276
- if ((e.target as HTMLElement).closest("[data-clip]")) return;
277
- if (timelineDisabled) return;
278
- if (compositionStack.length > 1) {
279
- updateCompositionStack((prev) => prev.slice(0, -1));
280
- }
281
- }}
297
+ className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
298
+ data-testid="timeline-loading-disabled-overlay"
299
+ role="status"
300
+ onPointerDown={(event) => event.preventDefault()}
301
+ onDragOver={(event) => event.preventDefault()}
302
+ onDrop={(event) => event.preventDefault()}
282
303
  >
283
- <div className="shrink-0">{timelineToolbar}</div>
284
- <Timeline
285
- sessionEpoch={timelineSessionEpoch}
286
- onSeek={seek}
287
- onDrillDown={handleDrillDown}
288
- renderClipContent={renderClipContent}
289
- onFileDrop={onFileDrop}
290
- onDeleteElement={handleDeleteElement}
291
- onAssetDrop={onAssetDrop}
292
- onBlockDrop={onBlockDrop}
293
- onCompositionDrop={onCompositionDrop}
294
- onMoveElement={handleMoveElement}
295
- onMoveElements={handleMoveElements}
296
- onResizeElement={handleResizeElement}
297
- onResizeElements={handleResizeElements}
298
- onBlockedEditAttempt={onBlockedEditAttempt}
299
- onSplitElement={handleSplitElement}
300
- onSelectElement={onSelectTimelineElement}
301
- onCopyClip={onCopyClip}
302
- onPasteClip={onPasteClip}
303
- onDuplicateClip={onDuplicateClip}
304
- canPasteClip={canPasteClip}
305
- />
304
+ <span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
305
+ Loading composition…
306
+ </span>
306
307
  </div>
307
- {timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
308
- {timelineDisabled && (
309
- <div
310
- className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
311
- data-testid="timeline-loading-disabled-overlay"
312
- role="status"
313
- onPointerDown={(event) => event.preventDefault()}
314
- onDragOver={(event) => event.preventDefault()}
315
- onDrop={(event) => event.preventDefault()}
316
- >
317
- <span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
318
- Loading composition…
319
- </span>
320
- </div>
321
- )}
322
- </div>
323
- </>
308
+ )}
309
+ </div>
324
310
  );
325
311
  }
@@ -156,7 +156,7 @@ export function makeSlideshowNotesController(): NotesController {
156
156
  // ── Component ─────────────────────────────────────────────────────────────
157
157
 
158
158
  export interface SlideshowPanelProps {
159
- /** Scenes from the live clip manifest (passed from StudioRightPanel). */
159
+ /** Scenes from the live clip manifest (passed from StudioRightPanels). */
160
160
  scenes: import("./slideshowPanelHelpers").SceneInfo[];
161
161
  /**
162
162
  * Called with the updated manifest after every discrete edit (toggle, add,
@@ -246,7 +246,7 @@ const EMPTY_STATE = (
246
246
  </p>
247
247
  );
248
248
 
249
- // Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanel.
249
+ // Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanels.
250
250
  // fallow-ignore-next-line complexity
251
251
  export const VariablesPanel = memo(function VariablesPanel({
252
252
  sdkSession,
@@ -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;
@@ -7,7 +7,7 @@ import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
7
7
  /**
8
8
  * The Renders tab, wired to the shell context.
9
9
  *
10
- * Split out of StudioRightPanel because every field it needs already lives in
10
+ * Split out of StudioRightPanels because every field it needs already lives in
11
11
  * that context, so routing them through the panel only made the panel longer
12
12
  * without giving anything a second reader.
13
13
  */
@@ -4,7 +4,7 @@ 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
6
  import { usePlayerStore } from "../../player/store/playerStore";
7
- import { LeftSidebar } from "./LeftSidebar";
7
+ import { CompositionsPanel } from "./CompositionsPanel";
8
8
 
9
9
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
10
  (
@@ -20,23 +20,20 @@ afterEach(() => {
20
20
  usePlayerStore.setState({ thumbnailContentRevision: 0 });
21
21
  });
22
22
 
23
- describe("LeftSidebar root badge source", () => {
23
+ describe("CompositionsPanel root badge source", () => {
24
24
  it("derives the root badge from the filtered compositions list, not the raw file tree", () => {
25
- // `fileTree` includes a non-composition index.html (a vendored preset) that the
26
- // server's filtered `compositions` list excludes — the badge must source from
27
- // `compositions`, not the raw file tree.
25
+ // The project's raw file tree can hold a non-composition index.html (a vendored
26
+ // preset); the badge must source from the server's filtered `compositions` list.
28
27
  const host = document.createElement("div");
29
28
  document.body.append(host);
30
29
  root = createRoot(host);
31
30
  act(() => {
32
31
  root?.render(
33
- <LeftSidebar
32
+ <CompositionsPanel
34
33
  projectId="demo"
35
34
  compositions={["hero.html"]}
36
- assets={[]}
37
- fileTree={["index.html", "hero.html"]}
38
35
  activeComposition={null}
39
- onSelectComposition={vi.fn()}
36
+ onSelect={vi.fn()}
40
37
  />,
41
38
  );
42
39
  });
@@ -0,0 +1,15 @@
1
+ import type { ComponentProps } from "react";
2
+ import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
3
+ import { CompositionsTab } from "./CompositionsTab";
4
+
5
+ type Props = Omit<ComponentProps<typeof CompositionsTab>, "masterCompositionPath">;
6
+
7
+ /** The Compositions dock panel body; the root badge comes from the filtered composition list. */
8
+ export function CompositionsPanel(props: Props) {
9
+ return (
10
+ <CompositionsTab
11
+ {...props}
12
+ masterCompositionPath={resolveMasterCompositionPath(props.compositions)}
13
+ />
14
+ );
15
+ }
@@ -3,7 +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 { LeftSidebar } from "./LeftSidebar";
6
+ import { SidebarLintButton } from "./SidebarLintButton";
7
7
 
8
8
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
9
9
  (
@@ -24,23 +24,18 @@ function renderBadge(lintHasError: boolean): HTMLElement | null {
24
24
  root = createRoot(host);
25
25
  act(() => {
26
26
  root?.render(
27
- <LeftSidebar
28
- projectId="demo"
29
- compositions={["index.html"]}
30
- assets={[]}
31
- fileTree={["index.html"]}
32
- activeComposition={null}
33
- onSelectComposition={vi.fn()}
27
+ <SidebarLintButton
34
28
  onLint={vi.fn()}
35
- lintFindingCount={2}
36
- lintHasError={lintHasError}
29
+ linting={false}
30
+ findingCount={2}
31
+ hasError={lintHasError}
37
32
  />,
38
33
  );
39
34
  });
40
35
  return host.querySelector<HTMLElement>("[data-lint-badge]");
41
36
  }
42
37
 
43
- describe("LeftSidebar lint badge", () => {
38
+ describe("SidebarLintButton badge", () => {
44
39
  it("pulses, and stops for reduced motion, while an error finding exists", () => {
45
40
  const badge = renderBadge(true);
46
41
  expect(badge?.dataset.lintBadge).toBe("error");
@@ -0,0 +1,50 @@
1
+ export function SidebarLintButton({
2
+ onLint,
3
+ linting,
4
+ findingCount,
5
+ hasError,
6
+ }: {
7
+ onLint: () => void;
8
+ linting: boolean;
9
+ findingCount?: number;
10
+ hasError?: boolean;
11
+ }) {
12
+ return (
13
+ <div className="border-t border-neutral-800 p-2 shrink-0">
14
+ <button
15
+ type="button"
16
+ onClick={onLint}
17
+ disabled={linting}
18
+ className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 enabled:hover:text-amber-300 enabled:hover:bg-neutral-800 enabled:active:scale-[0.98] transition-colors disabled:opacity-40"
19
+ >
20
+ <svg
21
+ width="12"
22
+ height="12"
23
+ viewBox="0 0 24 24"
24
+ fill="none"
25
+ stroke="currentColor"
26
+ strokeWidth="2"
27
+ >
28
+ <path d="M9 11l3 3L22 4" />
29
+ <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
30
+ </svg>
31
+ {linting ? "Linting…" : "Lint"}
32
+ {!linting && findingCount != null && findingCount > 0 && (
33
+ <span
34
+ data-lint-badge={hasError ? "error" : "warning"}
35
+ className={
36
+ hasError
37
+ ? "ml-1 min-w-[16px] rounded-full bg-panel-danger/25 px-1 text-[9px] font-bold text-panel-danger animate-pulse motion-reduce:animate-none"
38
+ : "ml-1 min-w-[16px] rounded-full bg-amber-500/20 px-1 text-[9px] font-bold text-amber-400"
39
+ }
40
+ >
41
+ {findingCount}
42
+ <span className="sr-only">
43
+ {hasError ? " lint findings, including errors" : " lint findings, warnings only"}
44
+ </span>
45
+ </span>
46
+ )}
47
+ </button>
48
+ </div>
49
+ );
50
+ }
@@ -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`;