@hyperframes/studio 0.8.52 → 0.8.53

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 (75) hide show
  1. package/dist/assets/{hyperframes-player-I5YRzB02.js → hyperframes-player-DKWM-Ons.js} +1 -1
  2. package/dist/assets/index-DrHQAuGO.css +1 -0
  3. package/dist/assets/index-HfP3sap2.js +390 -0
  4. package/dist/assets/{index-BTAfgesM.js → index-UvW6BEwf.js} +1 -1
  5. package/dist/assets/{index-Du5x7KUR.js → index-Y4OnpTEM.js} +1 -1
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +13 -6
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +7664 -7953
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles.css +1 -0
  13. package/package.json +8 -7
  14. package/src/App.tsx +42 -48
  15. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  16. package/src/components/EditorShell.tsx +52 -56
  17. package/src/components/StudioHeader.dom.test.tsx +1 -1
  18. package/src/components/StudioHeader.test.ts +0 -12
  19. package/src/components/StudioHeader.tsx +10 -13
  20. package/src/components/StudioLeftPanels.tsx +202 -0
  21. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -206
  22. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  23. package/src/components/dock/Dock.test.tsx +16 -2
  24. package/src/components/dock/Dock.tsx +13 -3
  25. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  26. package/src/components/dock/dockLayoutStore.ts +21 -4
  27. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  28. package/src/components/nle/NLEContext.tsx +0 -43
  29. package/src/components/nle/TimelinePane.tsx +46 -67
  30. package/src/components/panels/SlideshowPanel.tsx +1 -1
  31. package/src/components/panels/VariablesPanel.tsx +1 -1
  32. package/src/components/renders/RenderQueue.test.tsx +133 -25
  33. package/src/components/renders/RenderQueue.tsx +99 -92
  34. package/src/components/renders/RenderQueueItem.tsx +69 -64
  35. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  36. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  37. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  38. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  39. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  40. package/src/components/ui/Select.tsx +4 -0
  41. package/src/contexts/PanelLayoutContext.tsx +3 -57
  42. package/src/headerExports.test.tsx +23 -0
  43. package/src/hooks/appHotkeysDispatch.ts +3 -4
  44. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  45. package/src/hooks/useAppHotkeys.test.ts +0 -1
  46. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  47. package/src/hooks/useAppHotkeys.ts +0 -4
  48. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  49. package/src/hooks/useDomEditSession.ts +0 -7
  50. package/src/hooks/useDomEditWiring.ts +4 -9
  51. package/src/hooks/usePanelLayout.test.ts +108 -296
  52. package/src/hooks/usePanelLayout.ts +50 -255
  53. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  54. package/src/hooks/useRightPanelIntents.ts +64 -0
  55. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  56. package/src/hooks/useSlideshowTabState.ts +6 -19
  57. package/src/hooks/useStudioContextValue.test.ts +6 -18
  58. package/src/hooks/useStudioContextValue.ts +5 -26
  59. package/src/index.ts +2 -1
  60. package/src/main.tsx +26 -0
  61. package/src/player/components/Timeline.tsx +1 -1
  62. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  63. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  64. package/src/utils/studioHelpers.ts +0 -7
  65. package/src/utils/studioUiPreferences.test.ts +10 -16
  66. package/src/utils/studioUiPreferences.ts +0 -16
  67. package/dist/assets/index-CxAu_Okg.css +0 -1
  68. package/dist/assets/index-DocJlW2v.js +0 -376
  69. package/src/components/StudioLeftSidebar.tsx +0 -207
  70. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  71. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  72. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  73. package/src/hooks/useInspectorSplitResize.ts +0 -51
  74. package/src/utils/fitPanels.test.ts +0 -164
  75. package/src/utils/fitPanels.ts +0 -151
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.52",
3
+ "version": "0.8.53",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,7 @@
24
24
  "types": "./dist/styles/tailwind-preset.d.ts"
25
25
  },
26
26
  "./theme.css": "./src/styles/theme.css",
27
+ "./styles.css": "./dist/styles.css",
27
28
  "./package.json": "./package.json"
28
29
  },
29
30
  "publishConfig": {
@@ -50,11 +51,11 @@
50
51
  "dockview-react": "8.3.1",
51
52
  "gsap": "^3.13.0",
52
53
  "mediabunny": "^1.45.3",
53
- "@hyperframes/core": "0.8.52",
54
- "@hyperframes/player": "0.8.52",
55
- "@hyperframes/sdk": "0.8.52",
56
- "@hyperframes/parsers": "0.8.52",
57
- "@hyperframes/studio-server": "0.8.52"
54
+ "@hyperframes/core": "0.8.53",
55
+ "@hyperframes/sdk": "0.8.53",
56
+ "@hyperframes/player": "0.8.53",
57
+ "@hyperframes/parsers": "0.8.53",
58
+ "@hyperframes/studio-server": "0.8.53"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@tailwindcss/vite": "^4.3.3",
@@ -70,7 +71,7 @@
70
71
  "vite": "^6.4.2",
71
72
  "vitest": "^4.1.11",
72
73
  "zustand": "^5.0.0",
73
- "@hyperframes/producer": "0.8.52"
74
+ "@hyperframes/producer": "0.8.53"
74
75
  },
75
76
  "peerDependencies": {
76
77
  "react": "19",
package/src/App.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useOwnPreviewIframe, usePreviewIframeStore } from "./player/store/previewIframeStore";
2
2
  import { buildProjectApiPath } from "./utils/projectRouting";
3
3
  import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
4
- import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
4
+ import { useDismissingTabSetter, useRightPanelIntent } from "./hooks/useRightPanelIntents";
5
5
  import { useRenderQueue } from "./components/renders/useRenderQueue";
6
6
  import { usePlayerStore } from "./player";
7
7
  import { StudioOverlays } from "./components/StudioOverlays";
@@ -48,9 +48,9 @@ import type { DomEditSelection } from "./components/editor/domEditing";
48
48
  import { StudioHeader } from "./components/StudioHeader";
49
49
  import { useGestureCommit } from "./hooks/useGestureCommit";
50
50
  import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
51
- import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
51
+ import { StudioLeftPanels } from "./components/StudioLeftPanels";
52
52
  import { EditorShell } from "./components/EditorShell";
53
- import { StudioRightPanel } from "./components/StudioRightPanel";
53
+ import { StudioRightPanels } from "./components/StudioRightPanels";
54
54
  import { TimelineToolbar } from "./components/TimelineToolbar";
55
55
  import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext";
56
56
  import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
@@ -75,7 +75,6 @@ export function StudioApp() {
75
75
  const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
76
76
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
77
77
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
78
- const leftSidebarRef = useRef<LeftSidebarHandle>(null);
79
78
  const captionEditMode = useCaptionStore((s) => s.isEditMode);
80
79
  const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
81
80
  const captionSync = useCaptionSync(projectId);
@@ -202,6 +201,13 @@ export function StudioApp() {
202
201
  setRightCollapsed: panelLayout.setRightCollapsed,
203
202
  setRightPanelTab: panelLayout.setRightPanelTab,
204
203
  });
204
+ const dismissBlockParams = useCallback(() => setActiveBlockParams(null), [setActiveBlockParams]);
205
+ const setRightPanelTab = useDismissingTabSetter(panelLayout.setRightPanelTab, dismissBlockParams);
206
+ const layout = useMemo(
207
+ () => ({ ...panelLayout, setRightPanelTab }),
208
+ [panelLayout, setRightPanelTab],
209
+ );
210
+
205
211
  const clearDomSelectionRef = useRef<() => void>(() => {});
206
212
  const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
207
213
  type DomEditDelete = (s: DomEditSelection, o?: { expandGroup?: boolean }) => Promise<void>;
@@ -234,7 +240,6 @@ export function StudioApp() {
234
240
  showToast,
235
241
  syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
236
242
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
237
- leftSidebarRef,
238
243
  handleCopy,
239
244
  handlePaste,
240
245
  handleCut,
@@ -248,10 +253,6 @@ export function StudioApp() {
248
253
  forceReloadSdkSession: sdkHandle.forceReload,
249
254
  onToggleRecording: () => handleToggleRecordingRef.current(),
250
255
  });
251
- const sidebarTabRef = useRef({
252
- select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t),
253
- get: () => leftSidebarRef.current?.getTab() ?? "compositions",
254
- });
255
256
  const domEditSession = useDomEditSession({
256
257
  projectId,
257
258
  activeCompPath,
@@ -264,7 +265,7 @@ export function StudioApp() {
264
265
  setSelectedTimelineElementId,
265
266
  setTimelineSelectionSet,
266
267
  setRightCollapsed: panelLayout.setRightCollapsed,
267
- setRightPanelTab: panelLayout.setRightPanelTab,
268
+ setRightPanelTab,
268
269
  showToast,
269
270
  isRecordingRef: isGestureRecordingRef,
270
271
  refreshPreviewDocumentVersion,
@@ -286,8 +287,6 @@ export function StudioApp() {
286
287
  reloadPreview,
287
288
  setRefreshKey,
288
289
  openSourceForSelection: fileManager.openSourceForSelection,
289
- selectSidebarTab: sidebarTabRef.current.select,
290
- getSidebarTab: sidebarTabRef.current.get,
291
290
  sdkSession: editFlowSdkSession,
292
291
  publishSdkSession: sdkHandle.publish,
293
292
  forceReloadSdkSession: sdkHandle.forceReload,
@@ -367,20 +366,18 @@ export function StudioApp() {
367
366
  },
368
367
  [appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
369
368
  );
370
- const {
371
- designPanelActive,
372
- inspectorPanelActive,
373
- inspectorButtonActive,
374
- shouldShowMotionPath,
375
- shouldShowSelectedDomBounds,
376
- } = useInspectorState(
377
- panelLayout.rightPanelTab,
378
- panelLayout.rightInspectorPanes,
379
- panelLayout.effectiveRightCollapsed,
380
- isPlaying,
381
- domEditSession.domEditSelection,
382
- gestureState === "recording",
383
- );
369
+ const rightPanel = useRightPanelIntent();
370
+ const { inspectorPanelActive, shouldShowMotionPath, shouldShowSelectedDomBounds } =
371
+ useInspectorState(
372
+ rightPanel,
373
+ isPlaying,
374
+ domEditSession.domEditSelection,
375
+ gestureState === "recording",
376
+ );
377
+ // The dock has no separate "railed by window width" state (it shrinks
378
+ // panels, never auto-hides the group), so rightCollapsed is already the
379
+ // value that decides whether the panel is actually showing.
380
+ const inspectorButtonActive = !panelLayout.rightCollapsed && inspectorPanelActive;
384
381
  useStudioUrlState({
385
382
  projectId,
386
383
  activeCompPath,
@@ -397,7 +394,7 @@ export function StudioApp() {
397
394
  applyMarqueeSelection: domEditSession.applyMarqueeSelection,
398
395
  buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
399
396
  applyDomSelection: domEditSession.applyDomSelection,
400
- setRightPanelTab: panelLayout.setRightPanelTab,
397
+ setRightPanelTab,
401
398
  initialState: initialUrlStateRef.current,
402
399
  });
403
400
  const studioCtxValue = buildStudioContextValue({
@@ -437,7 +434,7 @@ export function StudioApp() {
437
434
  return (
438
435
  <StudioShellProvider value={studioCtxValue}>
439
436
  <StudioPlaybackProvider value={studioCtxValue}>
440
- <PanelLayoutProvider value={panelLayout}>
437
+ <PanelLayoutProvider value={layout}>
441
438
  <FileManagerProvider value={fileManager}>
442
439
  <DomEditProvider value={domEditSession}>
443
440
  <div
@@ -468,26 +465,23 @@ export function StudioApp() {
468
465
  )}
469
466
  <ExternalFileConflictBanner coordinator={externalFileChanges} />
470
467
  <EditorShell
471
- left={
472
- <StudioLeftSidebar
473
- leftSidebarRef={leftSidebarRef}
474
- onSelectComposition={handleSelectComposition}
475
- onAddBlock={handleAddBlock}
476
- onPreviewBlock={setBlockPreview}
477
- onLint={handleLint}
478
- linting={linting}
479
- lintFindingCount={lintModal?.length ?? findingsByFile.size}
480
- lintFindingsByFile={findingsByFile}
481
- lintHasError={hasLintError}
482
- onAddAssetToTimeline={handleAddAssetAtPlayhead}
483
- onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
484
- />
485
- }
486
- right={
487
- panelLayout.effectiveRightCollapsed ? null : (
488
- <StudioRightPanel
489
- designPanelActive={designPanelActive}
468
+ panels={
469
+ <>
470
+ <StudioLeftPanels
471
+ onSelectComposition={handleSelectComposition}
472
+ onAddBlock={handleAddBlock}
473
+ onPreviewBlock={setBlockPreview}
474
+ onLint={handleLint}
475
+ linting={linting}
476
+ lintFindingCount={lintModal?.length ?? findingsByFile.size}
477
+ lintFindingsByFile={findingsByFile}
478
+ lintHasError={hasLintError}
479
+ onAddAssetToTimeline={handleAddAssetAtPlayhead}
480
+ onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
481
+ />
482
+ <StudioRightPanels
490
483
  activeBlockParams={activeBlockParams}
484
+ onDismissBlockParams={dismissBlockParams}
491
485
  onCloseBlockParams={() => {
492
486
  setActiveBlockParams(null);
493
487
  panelLayout.setRightPanelTab("design");
@@ -504,7 +498,7 @@ export function StudioApp() {
504
498
  onAutoGroupCarveSources={timelineEditing.handleAutoGroupCarveSources}
505
499
  onAddMediaOverlay={handleAddMediaOverlay}
506
500
  />
507
- )
501
+ </>
508
502
  }
509
503
  timelineToolbar={timelineToolbar}
510
504
  renderClipContent={renderClipContent}
@@ -42,7 +42,6 @@ vi.mock("./nle/NLEContext", () => ({
42
42
  useNLEContext: () => ({
43
43
  compositionStack: [],
44
44
  updateCompositionStack: vi.fn(),
45
- containerRef: { current: null },
46
45
  }),
47
46
  }));
48
47
  vi.mock("./nle/useTimelineEditCallbacks", () => ({
@@ -53,7 +52,14 @@ vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
53
52
  vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
54
53
  vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));
55
54
 
56
- Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
55
+ Object.assign(globalThis, {
56
+ IS_REACT_ACT_ENVIRONMENT: true,
57
+ ResizeObserver: class {
58
+ observe() {}
59
+ unobserve() {}
60
+ disconnect() {}
61
+ },
62
+ });
57
63
 
58
64
  afterEach(() => {
59
65
  document.body.innerHTML = "";
@@ -69,8 +75,7 @@ describe("EditorShell timeline selection sync", () => {
69
75
  act(() => {
70
76
  root.render(
71
77
  <EditorShell
72
- left={null}
73
- right={null}
78
+ panels={null}
74
79
  timelineToolbar={null}
75
80
  renderClipContent={() => null}
76
81
  handleTimelineElementDelete={vi.fn()}
@@ -17,6 +17,7 @@ import type { GestureRecordingState } from "./editor/GestureRecordControl";
17
17
  import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
18
18
  import { StudioAgentTools } from "../webmcp/StudioAgentTools";
19
19
  import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
20
+ import { Dock } from "./dock/Dock";
20
21
 
21
22
  type RenderClipContent = (
22
23
  element: TimelineElement,
@@ -26,10 +27,8 @@ type RenderClipContent = (
26
27
  // The seven move/resize/split/razor handlers come from TimelineEditCallbackDeps
27
28
  // (shared with useTimelineEditCallbacks); the rest are drop + wiring props.
28
29
  export interface EditorShellProps extends TimelineEditCallbackDeps {
29
- /** Left sidebar (media/library), rendered in the top row. */
30
- left: ReactNode;
31
- /** Right panel (inspector/design) or null when collapsed, in the top row. */
32
- right: ReactNode;
30
+ /** Dock.Panel elements for every panel except the built-in preview and timeline. */
31
+ panels: ReactNode;
33
32
  timelineToolbar: ReactNode;
34
33
  renderClipContent: RenderClipContent;
35
34
  handleTimelineElementDelete: (element: TimelineElement) => Promise<void> | void;
@@ -72,12 +71,11 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
72
71
  gestureOverlay?: ReactNode;
73
72
  }
74
73
 
75
- // The CapCut-style shell: [left | preview | right] in a top row, with a
76
- // full-width timeline spanning the bottom. Owns the shared player +
77
- // composition-stack state via NLEProvider so both rows share one player.
74
+ // The dockable shell: every panel lives in one Dock, arranged by the user's
75
+ // saved layout. Owns the shared player + composition-stack state via
76
+ // NLEProvider so every panel shares one player.
78
77
  export function EditorShell({
79
- left,
80
- right,
78
+ panels,
81
79
  timelineToolbar,
82
80
  renderClipContent,
83
81
  handleTimelineElementDelete,
@@ -178,8 +176,8 @@ export function EditorShell({
178
176
  }}
179
177
  >
180
178
  <EditorShellBody
181
- left={left}
182
- right={right}
179
+ projectId={projectId}
180
+ panels={panels}
183
181
  captionEditMode={captionEditMode}
184
182
  onSelectTimelineElement={handleTimelineElementSelect}
185
183
  onPreviewBlockDrop={handlePreviewBlockDrop}
@@ -213,8 +211,8 @@ export function EditorShell({
213
211
  }
214
212
 
215
213
  interface EditorShellBodyProps {
216
- left: ReactNode;
217
- right: ReactNode;
214
+ panels: ReactNode;
215
+ projectId: string;
218
216
  captionEditMode: boolean;
219
217
  previewOverlay: ReactNode;
220
218
  onSelectTimelineElement: (element: TimelineElement | null) => void;
@@ -239,8 +237,8 @@ interface EditorShellBodyProps {
239
237
  }
240
238
 
241
239
  function EditorShellBody({
242
- left,
243
- right,
240
+ panels,
241
+ projectId,
244
242
  captionEditMode,
245
243
  previewOverlay,
246
244
  onSelectTimelineElement,
@@ -257,7 +255,7 @@ function EditorShellBody({
257
255
  onDuplicateClip,
258
256
  canPasteClip,
259
257
  }: EditorShellBodyProps) {
260
- const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
258
+ const { compositionStack, updateCompositionStack } = useNLEContext();
261
259
 
262
260
  // The caption track's blocks are seek targets; CaptionTimeline took an onSeek
263
261
  // prop that nothing ever passed, so clicking a block did nothing.
@@ -277,7 +275,6 @@ function EditorShellBody({
277
275
 
278
276
  return (
279
277
  <div
280
- ref={containerRef}
281
278
  // Shell canvas is a step LIGHTER than the near-black panel cards so the
282
279
  // gaps between panels read as visible seams (CapCut-style).
283
280
  className="flex flex-col flex-1 min-h-0 bg-panel-surface"
@@ -287,47 +284,46 @@ function EditorShellBody({
287
284
  {/* Renders nothing; exposes Studio's state to an agentic browser. Mounted
288
285
  here rather than in App because it needs the DomEdit contexts. */}
289
286
  <StudioAgentTools />
290
- {/* Top row: [left | preview | right] — outer padding + the 8px resize
291
- seams give the panels CapCut-style separation on the dark canvas. */}
292
- <div className="flex flex-row flex-1 min-h-0 px-px pt-px">
293
- {left}
294
- <div className="flex-1 min-w-0 flex flex-col relative">
295
- <PreviewPane
296
- previewOverlay={previewOverlay}
287
+ <Dock.Root projectId={projectId}>
288
+ <Dock.Panel id="preview">
289
+ <div className="relative flex h-full w-full min-w-0 flex-col">
290
+ <PreviewPane
291
+ previewOverlay={previewOverlay}
292
+ onSelectTimelineElement={onSelectTimelineElement}
293
+ onPreviewBlockDrop={onPreviewBlockDrop}
294
+ />
295
+ </div>
296
+ </Dock.Panel>
297
+ <Dock.Panel id="timeline">
298
+ <TimelinePane
299
+ timelineToolbar={timelineToolbar}
300
+ renderClipContent={renderClipContent}
301
+ onFileDrop={onFileDrop}
302
+ onAssetDrop={onAssetDrop}
303
+ onBlockDrop={onBlockDrop}
304
+ onCompositionDrop={onCompositionDrop}
305
+ onDeleteElement={onDeleteElement}
306
+ onCopyClip={onCopyClip}
307
+ onPasteClip={onPasteClip}
308
+ onDuplicateClip={onDuplicateClip}
309
+ canPasteClip={canPasteClip}
297
310
  onSelectTimelineElement={onSelectTimelineElement}
298
- onPreviewBlockDrop={onPreviewBlockDrop}
311
+ timelineFooter={
312
+ captionEditMode ? (
313
+ <div className="border-t border-neutral-800/30 shrink-0" style={{ height: 60 }}>
314
+ <div className="flex items-center gap-1.5 px-2 py-0.5">
315
+ <span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
316
+ Captions
317
+ </span>
318
+ </div>
319
+ <CaptionTimeline pixelsPerSecond={100} onSeek={seekCaptionTime} />
320
+ </div>
321
+ ) : undefined
322
+ }
299
323
  />
300
- </div>
301
- {right}
302
- </div>
303
-
304
- {/* Full-width timeline row */}
305
- <TimelinePane
306
- timelineToolbar={timelineToolbar}
307
- renderClipContent={renderClipContent}
308
- onFileDrop={onFileDrop}
309
- onAssetDrop={onAssetDrop}
310
- onBlockDrop={onBlockDrop}
311
- onCompositionDrop={onCompositionDrop}
312
- onDeleteElement={onDeleteElement}
313
- onCopyClip={onCopyClip}
314
- onPasteClip={onPasteClip}
315
- onDuplicateClip={onDuplicateClip}
316
- canPasteClip={canPasteClip}
317
- onSelectTimelineElement={onSelectTimelineElement}
318
- timelineFooter={
319
- captionEditMode ? (
320
- <div className="border-t border-neutral-800/30 shrink-0" style={{ height: 60 }}>
321
- <div className="flex items-center gap-1.5 px-2 py-0.5">
322
- <span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
323
- Captions
324
- </span>
325
- </div>
326
- <CaptionTimeline pixelsPerSecond={100} onSeek={seekCaptionTime} />
327
- </div>
328
- ) : undefined
329
- }
330
- />
324
+ </Dock.Panel>
325
+ {panels}
326
+ </Dock.Root>
331
327
  </div>
332
328
  );
333
329
  }
@@ -33,7 +33,7 @@ vi.mock("../contexts/StudioContext", () => ({
33
33
 
34
34
  vi.mock("../contexts/PanelLayoutContext", () => ({
35
35
  usePanelLayoutContext: () => ({
36
- effectiveRightCollapsed: false,
36
+ rightCollapsed: false,
37
37
  setRightCollapsed: vi.fn(),
38
38
  setRightPanelTab: vi.fn(),
39
39
  }),
@@ -13,16 +13,4 @@ describe("shouldOpenInspector", () => {
13
13
  it("closes when the inspector is genuinely on screen", () => {
14
14
  expect(shouldOpenInspector(false, true)).toBe(false);
15
15
  });
16
-
17
- it("opens when the window railed the panel away", () => {
18
- // The regression this guards: the button used to branch on the raw
19
- // rightCollapsed intent, which is still `false` while the window has the
20
- // panel railed. That took the close branch, wrote rightCollapsed=true, and
21
- // since that value is synced into the shareable Studio URL, a click that
22
- // did nothing visible rewrote the link.
23
- const userIntentIsOpen = false;
24
- const windowRailedItAway = true;
25
- expect(shouldOpenInspector(windowRailedItAway, true)).toBe(true);
26
- expect(shouldOpenInspector(userIntentIsOpen, true)).toBe(false);
27
- });
28
16
  });
@@ -4,6 +4,7 @@ import { useStudioShellContext } from "../contexts/StudioContext";
4
4
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
5
5
  import { trackStudioEvent } from "../utils/studioTelemetry";
6
6
  import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
7
+ import { Dock } from "./dock/Dock";
7
8
 
8
9
  export interface StudioHeaderProps {
9
10
  captureFrameHref: string;
@@ -16,7 +17,7 @@ export interface StudioHeaderProps {
16
17
  onExport?: () => void;
17
18
  }
18
19
 
19
- function HyperframesLogo() {
20
+ export function HyperframesLogo() {
20
21
  // Full logo from logo-dark.svg (263×79): heygen label + gradient mark + hyperframes wordmark.
21
22
  // All fill="black" paths inverted to white for the dark header.
22
23
  const height = 28;
@@ -140,16 +141,15 @@ function HyperframesLogo() {
140
141
  /**
141
142
  * Does the header's Inspector button open the panel, or close it?
142
143
  *
143
- * Takes the EFFECTIVE collapse state, so a panel the window has railed away
144
- * counts as closed even though the user's stored intent still says open. The
145
- * argument name is the guard: passing raw intent here is the bug this exists
146
- * to keep out.
144
+ * The dock has no separate "railed by window width" state (it shrinks panels,
145
+ * never auto-hides the group), so `rightCollapsed` here is already the state
146
+ * that decides whether the panel is actually showing.
147
147
  */
148
148
  export function shouldOpenInspector(
149
- effectiveRightCollapsed: boolean,
149
+ rightCollapsed: boolean,
150
150
  inspectorPanelActive: boolean,
151
151
  ): boolean {
152
- return effectiveRightCollapsed || !inspectorPanelActive;
152
+ return rightCollapsed || !inspectorPanelActive;
153
153
  }
154
154
 
155
155
  // fallow-ignore-next-line complexity
@@ -164,11 +164,7 @@ export function StudioHeader({
164
164
  onExport,
165
165
  }: StudioHeaderProps) {
166
166
  const { projectId, renderQueue } = useStudioShellContext();
167
- // effectiveRightCollapsed, not the raw intent: in the auto-railed state the
168
- // intent is still "open" while the panel is hidden, so branching on intent
169
- // made this button write rightCollapsed=true — and that value is synced into
170
- // the shareable Studio URL, so a dead click would rewrite a link.
171
- const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
167
+ const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
172
168
  const isRendering = renderQueue.isRendering;
173
169
  const ffmpegMissing = renderQueue.ffmpegMissing;
174
170
 
@@ -265,7 +261,7 @@ export function StudioHeader({
265
261
  </svg>
266
262
  }
267
263
  onClick={() => {
268
- if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
264
+ if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) {
269
265
  trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
270
266
  setRightPanelTab("design");
271
267
  setRightCollapsed(false);
@@ -281,6 +277,7 @@ export function StudioHeader({
281
277
  </Button>
282
278
  </Tooltip>
283
279
  </div>
280
+ <Dock.WindowMenu />
284
281
  <Tooltip
285
282
  label={
286
283
  ffmpegMissing