Skip to content

Commit 98e8372

Browse files
feat(studio): all panels move onto the dockable, persisted layout (#4192)
* feat(studio): every Studio panel is a dock panel and the shell moves onto it EditorShell, App.tsx, the left and right panel columns, and the inspector hooks now render through Dock.Panel instead of the old fixed-width sidebars and split-pane inspector. usePanelLayout becomes a thin facade over the dock store; fitPanels, the resize divider and the split-inspector hook are dead and removed. * refactor(studio): drop dead inspector flags and stale fallow entries after dock migration Collapse inspectorButtonActive into inspectorPanelActive, remove unread layers/design active fields, un-export unused prop types, flatten a nested ternary, and point fallow ignores at the renamed/deleted files. * fix(studio): keep right-panel behaviours the dock migration dropped Block params dismiss on selection or when Design is hidden, the slideshow panel can be closed, the selection outline follows the inspector intent, caption editing focuses Design, and a URL slideshow tab opens once the panel exists. * refactor(studio): name the inspector-open state once in the header * fix(studio): right-panel behaviours survive selection, restore and reopen after the dock migration Block params stay open over an element that was already selected, caption mode wins over block params, the slideshow panel waits for the dock, the Renders tab title survives a reopen, each side column remembers the tab it last showed, tab switches are reported only when the tab changes, and the e2e thumbnail selector follows the dock. * docs(studio): describe the dock layout and point comments at the renamed right panels * fix(studio): block params survive a preview reload of the selected element, and panel titles are covered The dismissal compares the selected element by its target key instead of object identity, because a preview reload rebuilds the selection for the same element. A closed and reopened panel keeps its custom title, with a test, and the e2e selector is formatted. * fix(studio): block params leave with any other tab pick, and a restored slideshow placement survives loading Block params are now dismissed by the tab setter the way their old tab was, including a re-pick of the same element, instead of by comparing selections. The slideshow panel is only closed after a file that was a slideshow stops being one, so a restored layout is not discarded while the file loads. * refactor(studio): drop the duplicate inspector-open computation The rebase onto main left two identical inspectorButtonActive/inspectorOpen constants after a conflict resolution; only the first was still referenced. * chore(studio): allow the dock migration deletions in the guard StudioLeftSidebar.tsx, LeftSidebar.tsx and its storage test, TimelineResizeDivider.tsx, useInspectorSplitResize.ts and fitPanels are replaced by the dock; the CI deletion guard needs each named with its reason. * style(studio): oxfmt the deletion-guard entries * fix(studio): rename Timeline's aria-label so it does not collide with its dock panel wrapper Every dock panel wrapper now carries aria-label=<title>, matching the "Timeline" title for the timeline panel. Timeline.tsx also set aria-label="Timeline" on its own content div, so `document.querySelector('[aria-label=\"Timeline\"]')` found the dock wrapper first and the timeline viewport gate CI check timed out waiting for an attribute the real content div actually carried. Renamed to "Timeline track view" and updated every consumer: the production diagnostics selector, its tests, and the e2e viewport-gate script. * fix(studio): restore inspectorButtonActive as a real prop after a rebase regression Rebasing PR2 onto main replayed an older local commit whose diff did not textually overlap main's newer inspectorButtonActive prop, so git applied it with no conflict marker and silently reverted the header to computing the value itself instead of receiving it. StudioHeader.dom.test.tsx already asserts the prop is respected; restoring it makes that test meaningful again. App.tsx now computes it once (panelLayout.rightCollapsed is already the dock's effective collapse state, no separate railed-by-width case) and passes it down. * chore(studio): wrap a long doc comment in useRightPanelIntents
1 parent 12d8c42 commit 98e8372

59 files changed

Lines changed: 1081 additions & 2337 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.fallowrc.jsonc‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@
226226
// collision.
227227
{ "file": "packages/cli/src/commands/*.ts", "exports": ["examples"] },
228228
// Options type for the render-queue hook's public startRender callback —
229-
// consumed structurally by callers (StudioRightPanel), not by import.
229+
// consumed structurally by callers (StudioRightPanels), not by import.
230230
{
231231
"file": "packages/studio/src/components/renders/useRenderQueue.ts",
232232
"exports": ["StartRenderOptions"],
@@ -457,10 +457,6 @@
457457
// require intrusive middleware changes beyond this PR's scope.
458458
"minLines": 6,
459459
"ignore": [
460-
// FileTree.tsx / LeftSidebar.tsx: pre-existing 8-line structural clone
461-
// (shared sidebar node shape); surfaced by the UX-sweep line shifts.
462-
"packages/studio/src/components/editor/FileTree.tsx",
463-
"packages/studio/src/components/sidebar/LeftSidebar.tsx",
464460
// AWS Lambda and GCP Cloud Run deliberately mirror the same distributed
465461
// rendering lifecycle while retaining provider-specific SDK, storage, and
466462
// retry semantics. The Plan v2 AWS adapter extends that existing symmetry;
@@ -894,8 +890,7 @@
894890
"packages/studio/src/components/sidebar/AudioRow.tsx",
895891
"packages/studio/src/components/sidebar/BlocksTab.tsx",
896892
"packages/studio/src/components/sidebar/CompositionsTab.tsx",
897-
"packages/studio/src/components/sidebar/LeftSidebar.tsx",
898-
"packages/studio/src/components/StudioRightPanel.tsx",
893+
"packages/studio/src/components/StudioRightPanels.tsx",
899894
"packages/studio/src/components/StudioToast.tsx",
900895
"packages/studio/src/components/ui/Tooltip.tsx",
901896
"packages/studio/src/components/ui/useDialogBehavior.ts",

‎docs/studio/index.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ npx hyperframes preview
3232

3333
- **Preview** shows the built project. The live frame is in the middle, the
3434
timeline is below, project tools are on the left, and the Inspector is on the
35-
right.
36-
- **Code**, **Comps**, **Assets**, and **Catalog** expose the project source,
35+
right. Drag any panel tab to move it, and use **Window** in the header to
36+
reopen a closed panel or reset the layout. Studio remembers your layout for
37+
each project.
38+
- **Code**, **Compositions**, **Assets**, and **Catalog** expose the project source,
3739
compositions, media, and reusable visuals. **Design**, **Layers**,
3840
**Variables**, and **Renders** control the selected work.
3941

‎docs/studio/source.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ the surfaces without exporting or converting the project.
2323

2424
## Make a focused source edit
2525

26-
1. Open **Code** in the left sidebar.
26+
1. Open **Code** in the left panel column.
2727
2. Select the exact file in the project tree.
2828
3. Find the value responsible for the visible result.
2929
4. Make one focused change.
@@ -68,7 +68,7 @@ must remain true.
6868

6969
## Run Studio Lint
7070

71-
Choose **Lint** at the bottom of the left sidebar. Studio opens **HyperFrame
71+
Choose **Lint** at the bottom of the left panel column. Studio opens **HyperFrame
7272
Lint Results** with errors, warnings, file locations, and fix hints when they
7373
are available.
7474

‎packages/studio/src/App.tsx‎

Lines changed: 42 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useOwnPreviewIframe, usePreviewIframeStore } from "./player/store/previewIframeStore";
22
import { buildProjectApiPath } from "./utils/projectRouting";
33
import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
4-
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
4+
import { useDismissingTabSetter, useRightPanelIntent } from "./hooks/useRightPanelIntents";
55
import { useRenderQueue } from "./components/renders/useRenderQueue";
66
import { usePlayerStore } from "./player";
77
import { StudioOverlays } from "./components/StudioOverlays";
@@ -48,9 +48,9 @@ import type { DomEditSelection } from "./components/editor/domEditing";
4848
import { StudioHeader } from "./components/StudioHeader";
4949
import { useGestureCommit } from "./hooks/useGestureCommit";
5050
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
51-
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
51+
import { StudioLeftPanels } from "./components/StudioLeftPanels";
5252
import { EditorShell } from "./components/EditorShell";
53-
import { StudioRightPanel } from "./components/StudioRightPanel";
53+
import { StudioRightPanels } from "./components/StudioRightPanels";
5454
import { TimelineToolbar } from "./components/TimelineToolbar";
5555
import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext";
5656
import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
@@ -75,7 +75,6 @@ export function StudioApp() {
7575
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
7676
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
7777
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
78-
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
7978
const captionEditMode = useCaptionStore((s) => s.isEditMode);
8079
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
8180
const captionSync = useCaptionSync(projectId);
@@ -202,6 +201,13 @@ export function StudioApp() {
202201
setRightCollapsed: panelLayout.setRightCollapsed,
203202
setRightPanelTab: panelLayout.setRightPanelTab,
204203
});
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+
205211
const clearDomSelectionRef = useRef<() => void>(() => {});
206212
const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
207213
type DomEditDelete = (s: DomEditSelection, o?: { expandGroup?: boolean }) => Promise<void>;
@@ -234,7 +240,6 @@ export function StudioApp() {
234240
showToast,
235241
syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
236242
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
237-
leftSidebarRef,
238243
handleCopy,
239244
handlePaste,
240245
handleCut,
@@ -248,10 +253,6 @@ export function StudioApp() {
248253
forceReloadSdkSession: sdkHandle.forceReload,
249254
onToggleRecording: () => handleToggleRecordingRef.current(),
250255
});
251-
const sidebarTabRef = useRef({
252-
select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t),
253-
get: () => leftSidebarRef.current?.getTab() ?? "compositions",
254-
});
255256
const domEditSession = useDomEditSession({
256257
projectId,
257258
activeCompPath,
@@ -264,7 +265,7 @@ export function StudioApp() {
264265
setSelectedTimelineElementId,
265266
setTimelineSelectionSet,
266267
setRightCollapsed: panelLayout.setRightCollapsed,
267-
setRightPanelTab: panelLayout.setRightPanelTab,
268+
setRightPanelTab,
268269
showToast,
269270
isRecordingRef: isGestureRecordingRef,
270271
refreshPreviewDocumentVersion,
@@ -286,8 +287,6 @@ export function StudioApp() {
286287
reloadPreview,
287288
setRefreshKey,
288289
openSourceForSelection: fileManager.openSourceForSelection,
289-
selectSidebarTab: sidebarTabRef.current.select,
290-
getSidebarTab: sidebarTabRef.current.get,
291290
sdkSession: editFlowSdkSession,
292291
publishSdkSession: sdkHandle.publish,
293292
forceReloadSdkSession: sdkHandle.forceReload,
@@ -367,20 +366,18 @@ export function StudioApp() {
367366
},
368367
[appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
369368
);
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;
384381
useStudioUrlState({
385382
projectId,
386383
activeCompPath,
@@ -397,7 +394,7 @@ export function StudioApp() {
397394
applyMarqueeSelection: domEditSession.applyMarqueeSelection,
398395
buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
399396
applyDomSelection: domEditSession.applyDomSelection,
400-
setRightPanelTab: panelLayout.setRightPanelTab,
397+
setRightPanelTab,
401398
initialState: initialUrlStateRef.current,
402399
});
403400
const studioCtxValue = buildStudioContextValue({
@@ -437,7 +434,7 @@ export function StudioApp() {
437434
return (
438435
<StudioShellProvider value={studioCtxValue}>
439436
<StudioPlaybackProvider value={studioCtxValue}>
440-
<PanelLayoutProvider value={panelLayout}>
437+
<PanelLayoutProvider value={layout}>
441438
<FileManagerProvider value={fileManager}>
442439
<DomEditProvider value={domEditSession}>
443440
<div
@@ -468,26 +465,23 @@ export function StudioApp() {
468465
)}
469466
<ExternalFileConflictBanner coordinator={externalFileChanges} />
470467
<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
490483
activeBlockParams={activeBlockParams}
484+
onDismissBlockParams={dismissBlockParams}
491485
onCloseBlockParams={() => {
492486
setActiveBlockParams(null);
493487
panelLayout.setRightPanelTab("design");
@@ -504,7 +498,7 @@ export function StudioApp() {
504498
onAutoGroupCarveSources={timelineEditing.handleAutoGroupCarveSources}
505499
onAddMediaOverlay={handleAddMediaOverlay}
506500
/>
507-
)
501+
</>
508502
}
509503
timelineToolbar={timelineToolbar}
510504
renderClipContent={renderClipContent}

‎packages/studio/src/components/EditorShell.selectionSync.test.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ vi.mock("./nle/NLEContext", () => ({
4242
useNLEContext: () => ({
4343
compositionStack: [],
4444
updateCompositionStack: vi.fn(),
45-
containerRef: { current: null },
4645
}),
4746
}));
4847
vi.mock("./nle/useTimelineEditCallbacks", () => ({
@@ -53,7 +52,14 @@ vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
5352
vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
5453
vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));
5554

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+
});
5763

5864
afterEach(() => {
5965
document.body.innerHTML = "";
@@ -69,8 +75,7 @@ describe("EditorShell timeline selection sync", () => {
6975
act(() => {
7076
root.render(
7177
<EditorShell
72-
left={null}
73-
right={null}
78+
panels={null}
7479
timelineToolbar={null}
7580
renderClipContent={() => null}
7681
handleTimelineElementDelete={vi.fn()}

0 commit comments

Comments
 (0)