workspace: maximize a panel without remounting its content

Maximize was swapping the app into a createPortal(document.body) branch, so React
unmounted + remounted it — losing scroll, playback, and in-flight state on every
maximize/restore. Now it's a pure CSS state toggle on the same element (fixed,
filling the content region), so the app instance is preserved and content stays
exactly as it was. The panel is confined to the layout's z-2 content stacking
context (under the fixed nav Header at z-10), so the maximized box starts below
the header — keeping the panel's own header/Restore visible and the nav reachable.
Dropped the portal + placeholder and the now-unused createPortal import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:38:05 +00:00
co-authored by Claude Opus 4.8
parent 1534bc2ea7
commit eca1f83162
@@ -1,6 +1,5 @@
import type { ComponentType } from 'react';
import { useCallback } from 'react';
import { createPortal } from 'react-dom';
import { ArrowLeftRight, ChevronLeft, X, Minus } from 'lucide-react';
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
import { useWorkspace } from './WorkspaceContext';
@@ -40,7 +39,15 @@ type PanelContextMenuProps = {
const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelComponentEntry =>
typeof v === 'object' && v !== null && 'component' in v;
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
const PanelContextMenu = ({
panelId,
hasApp,
isLastPanel,
onSplit,
onRemove,
onClearApp,
children,
}: PanelContextMenuProps) => {
const { swapSourceId, setSwapSourceId, maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
@@ -110,7 +117,17 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
);
};
const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId: string; isLastPanel: boolean; onRemove: (panelId: string) => void; onClearApp: () => void }) => {
const TrafficLights = ({
panelId,
isLastPanel,
onRemove,
onClearApp,
}: {
panelId: string;
isLastPanel: boolean;
onRemove: (panelId: string) => void;
onClearApp: () => void;
}) => {
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
@@ -135,7 +152,10 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
className="group/btn h-3 w-3 rounded-full bg-[#febc2e] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title="Restore"
>
<Minus className="h-2 w-2 text-[#5f4a00] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
<Minus
className="h-2 w-2 text-[#5f4a00] opacity-0 group-hover/btn:opacity-100 transition-opacity"
strokeWidth={3}
/>
</button>
</div>
);
@@ -149,7 +169,10 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
className="group/btn h-3 w-3 rounded-full bg-[#ff5f57] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title={isLastPanel ? 'Clear app' : 'Close panel'}
>
<X className="h-2 w-2 text-[#4a0002] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
<X
className="h-2 w-2 text-[#4a0002] opacity-0 group-hover/btn:opacity-100 transition-opacity"
strokeWidth={3}
/>
</button>
<button
type="button"
@@ -157,7 +180,10 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title="Maximize"
>
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity">
<svg
viewBox="0 0 10 10"
className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity"
>
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
</svg>
</button>
@@ -178,9 +204,15 @@ const MaximizeButton = ({ panelId }: { panelId: string }) => {
title={isMaximized ? 'Restore' : 'Maximize'}
>
{isMaximized ? (
<Minus className="h-2 w-2 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
<Minus
className="h-2 w-2 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity"
strokeWidth={3}
/>
) : (
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity">
<svg
viewBox="0 0 10 10"
className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity"
>
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
</svg>
)}
@@ -223,7 +255,18 @@ const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children:
// return <div className="absolute inset-0 z-20 rounded-lg bg-duck-dark/10 pointer-events-none" />;
// };
export const PanelSlot = ({ panel, registry, components, interactive, locked, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
export const PanelSlot = ({
panel,
registry,
components,
interactive,
locked,
noHeader,
isLastPanel,
onSetApp,
onSplit,
onRemove,
}: PanelSlotProps) => {
const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
const isMaximized = maximizedPanelId === panel.id;
@@ -241,17 +284,23 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
const contextMenu = interactive
? locked
? (content: React.ReactNode) => (
<MaximizeContextMenu panelId={panel.id}>{content}</MaximizeContextMenu>
)
? (content: React.ReactNode) => <MaximizeContextMenu panelId={panel.id}>{content}</MaximizeContextMenu>
: (content: React.ReactNode) => (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
<PanelContextMenu
panelId={panel.id}
hasApp={!!AppComponent}
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
>
{content}
</PanelContextMenu>
)
: (content: React.ReactNode) => <>{content}</>;
const overlays = interactive && !locked ? (
const overlays =
interactive && !locked ? (
<>
<SwapOverlay panelId={panel.id} />
<SwapSourceIndicator panelId={panel.id} />
@@ -289,24 +338,33 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
}
// All apps get a chrome header — custom HeaderComponent or default from registry icon+name
const DefaultHeader = entry ? () => (
const DefaultHeader = entry
? () => (
<>
<entry.icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
</>
) : null;
)
: null;
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
const trafficLights = interactive && !isMobile ? (
const trafficLights =
interactive && !isMobile ? (
locked ? (
<MaximizeButton panelId={panel.id} />
) : (
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
<TrafficLights
panelId={panel.id}
isLastPanel={isLastPanel}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
)
) : null;
const mobileBackButton = isMobile && onMobileBack ? (
const mobileBackButton =
isMobile && onMobileBack ? (
<button
type="button"
onClick={onMobileBack}
@@ -322,10 +380,7 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
{mobileBackButton}
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
{!mobileBackButton && onClose && (
<button
onClick={onClose}
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
>
<button onClick={onClose} className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer">
<X className="h-3.5 w-3.5" />
</button>
)}
@@ -337,11 +392,20 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
locked ? (
<MaximizeContextMenu panelId={panel.id}>{headerContent}</MaximizeContextMenu>
) : (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
<PanelContextMenu
panelId={panel.id}
hasApp={!!AppComponent}
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
>
{headerContent}
</PanelContextMenu>
)
) : headerContent;
) : (
headerContent
);
const body = (
<div className="@container flex-1 min-h-0">
@@ -363,38 +427,31 @@ export const PanelSlot = ({ panel, registry, components, interactive, locked, no
</>
);
if (isMaximized) {
return (
<>
{/* Placeholder to preserve layout space */}
<div data-panel-id={panel.id} className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border border-dashed border-white/20" />
</div>
{/* Maximized overlay — portalled to escape stacking contexts */}
{createPortal(
<div className="fixed inset-0 z-50 p-2">
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl"
style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
>
{inner}
</div>
</div>,
document.body,
)}
</>
);
}
const panelStyle = noHeader
? { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(0, 0, 0, 0.25)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) }
: { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) };
// Maximize is a CSS state toggle on THE SAME element — no portal, no remount — so the panel's content
// (scroll position, media playback, in-flight state) is preserved exactly across maximize/restore.
// It fills the content REGION (starting below the global Header), not the whole viewport: the panel is
// confined to the layout's `absolute z-2` content stacking context, which sits under the fixed nav
// Header (z-10) — so a full-viewport overlay would hide the panel's own header/Restore behind the nav.
// Clearing the header keeps it visible + the nav accessible. (Maximize only happens in the dashboard
// shell; previews/mobile no-op it, so the header offset is always the right reference.)
const maximizedStyle = { backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: 'rgba(255, 255, 255, 0.2)' };
const normalStyle = noHeader
? { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(0, 0, 0, 0.25)' }
: { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' };
const panelStyle = {
...(isMaximized ? maximizedStyle : normalStyle),
...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}),
} as React.CSSProperties;
return (
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
style={panelStyle as React.CSSProperties}
className={
isMaximized
? 'fixed inset-x-2 bottom-2 top-[56px] z-50 overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl md:top-[68px]'
: 'relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col'
}
style={panelStyle}
>
{inner}
</div>