retire the green and amber lights, and the shallow depth they drove

Maximize could only ever stop below the nav header — the content region is an `absolute z-2` stacking
context and the header a `fixed z-10` sibling, so that was the deepest a panel could get on its own.
Full screen reaches the rest by asking the shell to stand its header down, which leaves the shallow
version as a state nobody picks on purpose.

So it goes, and the two lights with it. `MaximizeMode` and the `{ id, mode }` session value collapse
back to a bare `fullscreenPanelId` — renamed because "maximized" would now be a lie about what it does
— under a new `FULLSCREEN_PANEL:` key, so a tab open across this reads nothing rather than an object
where a string belongs. `MaximizeButton` is gone; locked screens keep only the fullscreen toggle, which
writes no layout and so was always the one control the lock could permit.

Red stays. The amber used to REPLACE it while maximized so the way out could never be a way to delete;
with amber gone that guard would have cost the close button entirely, and red is already absent exactly
where it should be — locked screens render no traffic lights at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 22:08:08 +01:00
co-authored by Claude Opus 5
parent 417860b892
commit c79b5a287b
8 changed files with 146 additions and 260 deletions
+2 -2
View File
@@ -57,10 +57,10 @@ export function clearSessionValue(key: string): void {
*
* Same shape as `useState`: a value, a setter that accepts an updater, and a `reset` that forgets the
* stored copy and returns to `initialValue`. `key` is the sessionStorage key verbatim — scope it
* yourself (`` `MAXIMIZED_PANEL:${dashboardId}` ``) when one screen needs one value per thing.
* yourself (`` `FULLSCREEN_PANEL:${dashboardId}` ``) when one screen needs one value per thing.
*
* `null` is a real stored value, not an absence: setting it persists "explicitly nothing", which is the
* difference between a panel you un-maximised and one you never maximised.
* difference between a panel you took back out of full screen and one you never put there.
*/
export function useSessionState<T>(key: string, initialValue: T) {
// Read the stored value once per mount rather than on every render — `useGlobal` re-evaluates its
@@ -13,7 +13,7 @@ import { PanelSlot } from './PanelSlot';
* "in this mode, which controls exist, and does each one call the handler it is named after" — cheap to
* answer and nobody had.
*
* The mode matrix is the substance. `interactive`, `locked`, `isMobile`, `isLastPanel`, `maximizedPanelId`
* The mode matrix is the substance. `interactive`, `locked`, `isMobile`, `isLastPanel`, `fullscreenPanelId`
* and an app's own `zoomable`/`transparent` flags combine into chrome that ranges from full traffic
* lights down to a bare outline, and the combinations are chosen in six separate ternaries spread over
* the file. A control appearing in a mode that should not have it is a way to edit a locked screen; a
@@ -21,7 +21,7 @@ import { PanelSlot } from './PanelSlot';
*
* `WorkspaceView.test.tsx` covers the same chrome from above, through the real renderer. This drives
* `PanelSlot` directly so it can put the workspace into states a whole view cannot easily be pushed
* into — maximized, mid-swap, mobile.
* into — fullscreen, mid-swap, mobile.
*/
const Icon = ((props: Record<string, unknown>) => <svg {...props} />) as AppRegistryMap[string]['icon'];
@@ -52,7 +52,7 @@ type Calls = {
remove: string[];
zoom: [string, number][];
swap: [string, string][];
maximize: [string | null, string | undefined][];
fullscreen: (string | null)[];
swapSource: (string | null)[];
mobileBack: number;
};
@@ -60,7 +60,7 @@ type Calls = {
let calls: Calls;
beforeEach(() => {
calls = { setApp: [], split: [], remove: [], zoom: [], swap: [], maximize: [], swapSource: [], mobileBack: 0 };
calls = { setApp: [], split: [], remove: [], zoom: [], swap: [], fullscreen: [], swapSource: [], mobileBack: 0 };
});
type MountOptions = {
@@ -83,7 +83,7 @@ function mount(options: MountOptions = {}) {
onMobileBack: null,
onSwap: (source, target) => calls.swap.push([source, target]),
setSwapSourceId: (id) => calls.swapSource.push(id),
setMaximizedPanelId: (id, mode) => calls.maximize.push([id, mode]),
setFullscreenPanelId: (id) => calls.fullscreen.push(id),
onSetZoom: (id, z) => calls.zoom.push([id, z]),
...options.context,
};
@@ -130,26 +130,26 @@ describe('the red button does what its own label says', () => {
});
describe('which chrome each mode gets', () => {
test('interactive and unlocked: close and maximize', () => {
test('interactive and unlocked: close and full screen', () => {
mount();
expect(screen.getByTitle('Close panel')).toBeTruthy();
expect(screen.getByTitle('Maximize')).toBeTruthy();
expect(screen.getByTitle('Full screen')).toBeTruthy();
});
test('locked: maximize only — a locked screen is not the users to edit', () => {
test('locked: full screen only — a locked screen is not the users to edit', () => {
mount({ locked: true });
expect(screen.queryByTitle('Close panel')).toBeNull();
expect(screen.queryByTitle('Clear app')).toBeNull();
expect(screen.getByTitle('Maximize')).toBeTruthy();
expect(screen.getByTitle('Full screen')).toBeTruthy();
});
test('not interactive: no chrome buttons at all', () => {
mount({ interactive: false });
expect(screen.queryByTitle('Close panel')).toBeNull();
expect(screen.queryByTitle('Maximize')).toBeNull();
expect(screen.queryByTitle('Full screen')).toBeNull();
expect(screen.queryByTitle('Zoom in')).toBeNull();
expect(screen.getByTestId('app')).toBeTruthy();
});
@@ -164,28 +164,29 @@ describe('which chrome each mode gets', () => {
expect(calls.mobileBack).toBe(1);
});
test('maximized: Restore replaces close, so the way out is never a way to delete', () => {
mount({ context: { maximizedPanelId: 'p1' } });
expect(screen.queryByTitle('Close panel')).toBeNull();
fireEvent.click(screen.getByTitle('Restore'));
expect(calls.maximize).toEqual([[null, undefined]]);
expect(calls.remove).toEqual([]);
});
test('maximize sets this panel, and a locked maximize toggles', () => {
test('the green and amber lights are gone, at both depths of the old feature', () => {
// They drove a shallower "maximize" that stopped below the nav header — all a panel can reach from
// inside the content region's stacking context. Full screen replaced it outright, so the pair went
// rather than becoming a second way to do a subset of one thing.
const view = mount();
fireEvent.click(screen.getByTitle('Maximize'));
expect(calls.maximize).toEqual([['p1', undefined]]);
expect(screen.queryByTitle('Maximize')).toBeNull();
expect(screen.queryByTitle('Restore')).toBeNull();
view.unmount();
mount({ locked: true, context: { maximizedPanelId: 'p1' } });
fireEvent.click(screen.getByTitle('Restore'));
expect(calls.maximize).toEqual([
['p1', undefined],
[null, undefined],
]);
mount({ context: { fullscreenPanelId: 'p1' } });
expect(screen.queryByTitle('Maximize')).toBeNull();
expect(screen.queryByTitle('Restore')).toBeNull();
});
test('red survives full screen, because closing and shrinking were never the same act', () => {
// The amber light used to REPLACE red while maximized, so the way out could never be a way to delete.
// With amber gone that guard would have cost the close button entirely; the fullscreen toggle sits
// beside red instead, and the two read differently enough to carry it.
mount({ context: { fullscreenPanelId: 'p1' } });
expect(screen.getByTitle('Close panel')).toBeTruthy();
expect(screen.getByTitle('Exit full screen')).toBeTruthy();
expect(calls.remove).toEqual([]);
});
test('noHeader drops the whole header bar but keeps the app', () => {
@@ -197,40 +198,31 @@ describe('which chrome each mode gets', () => {
});
});
describe('full screen is the second depth of maximize', () => {
describe('full screen is the only depth, and the only control', () => {
const wrapper = () => document.querySelector<HTMLElement>('[data-panel-id] > div');
const fullscreen = { maximizedPanelId: 'p1', maximizeMode: 'screen' as const };
test('offered from a tiled panel, so taking the window is one click and not two', () => {
mount();
const fullscreen = { fullscreenPanelId: 'p1' };
test('one toggle in, one toggle out', () => {
const view = mount();
fireEvent.click(screen.getByTitle('Full screen'));
expect(calls.fullscreen).toEqual(['p1']);
view.unmount();
expect(calls.maximize).toEqual([['p1', 'screen']]);
});
test('the toggle steps back to a maximized panel, and the amber light is still the way out', () => {
// Two different exits on purpose, both one click: the toggle undoes the depth it added, the way a mac
// window does, and Restore undoes maximize entirely. Neither is a trap — a fullscreen panel that
// could only be escaped through a state you never asked for would be.
mount({ context: fullscreen });
fireEvent.click(screen.getByTitle('Exit full screen'));
fireEvent.click(screen.getByTitle('Restore'));
expect(calls.maximize).toEqual([
['p1', 'panel'],
[null, undefined],
]);
expect(calls.fullscreen).toEqual(['p1', null]);
});
test('locked screens get it too — it edits nothing', () => {
test('locked screens get it, and it is the only chrome they get', () => {
// Their whole restriction is "not yours to edit". Full screen edits nothing — no layout write, not
// even a persisted-per-dashboard one — so it is the one control that survives the lock.
mount({ locked: true });
fireEvent.click(screen.getByTitle('Full screen'));
expect(calls.maximize).toEqual([['p1', 'screen']]);
expect(calls.fullscreen).toEqual(['p1']);
expect(screen.queryByTitle('Close panel')).toBeNull();
expect(screen.queryByTitle('Clear app')).toBeNull();
});
test('no app opts out: unlike zoom, this is a property of the frame', () => {
@@ -249,19 +241,19 @@ describe('full screen is the second depth of maximize', () => {
expect(screen.queryByTitle('Full screen')).toBeNull();
});
test('it drops the gutter and the rounding that say "floating above the app"', () => {
// The geometry is the feature — `inset-0` is only the whole window because the shell hides its own
// header for it (see panel-fullscreen.ts). Maximize keeps the gutter and clears the nav at
// `top-[56px]`, which is what this is pinned against.
test('it takes the window, dropping the gutter and rounding that say "floating above the app"', () => {
// `inset-0` is only the whole window because the shell hides its own header for it (see
// panel-fullscreen.ts). `top-[56px]` was the old shallower depth clearing the nav it could not paint
// over; it should appear nowhere now.
const view = mount({ context: fullscreen });
expect(wrapper()?.className).toContain('inset-0');
expect(wrapper()?.className).not.toContain('rounded-lg');
expect(wrapper()?.className).not.toContain('top-[56px]');
view.unmount();
mount({ context: { maximizedPanelId: 'p1' } });
expect(wrapper()?.className).toContain('top-[56px]');
mount();
expect(wrapper()?.className).toContain('rounded-lg');
expect(wrapper()?.className).not.toContain('fixed');
});
});
@@ -1,6 +1,6 @@
import type { ComponentType } from 'react';
import { useCallback } from 'react';
import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, Minus, ZoomIn, ZoomOut } from 'lucide-react';
import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, ZoomIn, ZoomOut } from 'lucide-react';
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
import { useWorkspace } from './WorkspaceContext';
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from './layout-utils';
@@ -60,7 +60,7 @@ const PanelContextMenu = ({
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<MaximizeMenuItems panelId={panelId} />
<FullscreenMenuItem panelId={panelId} />
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
@@ -132,9 +132,6 @@ const TrafficLights = ({
onRemove: (panelId: string) => void;
onClearApp: () => void;
}) => {
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
// The button says "Close panel" unless this is the only panel left, and it used to call `onClearApp`
// either way — `isLastPanel` reached here only to choose the tooltip. So closing a panel from its own
// chrome was impossible: the panel stayed, emptied, and the only working path was the context menu.
@@ -145,32 +142,14 @@ const TrafficLights = ({
else onRemove(panelId);
}, [isLastPanel, onClearApp, onRemove, panelId]);
const handleRestore = useCallback(() => {
setMaximizedPanelId(null);
}, [setMaximizedPanelId]);
const handleMaximize = useCallback(() => {
setMaximizedPanelId(panelId);
}, [panelId, setMaximizedPanelId]);
if (isMaximized) {
return (
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
<button
type="button"
onClick={handleRestore}
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}
/>
</button>
</div>
);
}
// One light, and it is not a traffic light any more. The green "maximize" and amber "restore" that used
// to sit here drove a second, shallower depth that stopped below the nav header — the most a panel can
// reach on its own, from inside the content region's stacking context. Once `FullscreenToggle` could
// take the whole window, that half-measure was a state nobody chose on purpose, so both are gone and
// full screen is the only depth.
//
// Red survives because it does something neither of them did, and it is already absent exactly where it
// should be: locked screens render no traffic lights at all.
return (
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
<button
@@ -184,74 +163,25 @@ const TrafficLights = ({
strokeWidth={3}
/>
</button>
<button
type="button"
onClick={handleMaximize}
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"
>
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
</svg>
</button>
</div>
);
};
// Locked screens get maximize only — no close, no clear. Green means "there is room to grow", amber
// means "this one is already filling the screen", the same way the two colours read on a mac window;
// `TrafficLights` below switches on the same pair.
const MaximizeButton = ({ panelId }: { panelId: string }) => {
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
return (
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
<button
type="button"
onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}
className={`group/btn h-3 w-3 rounded-full hover:brightness-90 transition-all cursor-pointer flex items-center justify-center ${isMaximized ? 'bg-[#febc2e]' : 'bg-[#28c840]'}`}
title={isMaximized ? 'Restore' : 'Maximize'}
>
{isMaximized ? (
<Minus
className="h-2 w-2 text-[#5f4a00] 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"
>
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
</svg>
)}
</button>
</div>
);
};
// Full screen is the second DEPTH of maximize, not a rival to it. `panel` fills the dashboard's content
// region and leaves the nav header reachable; `screen` takes the whole browser window, header included.
//
// Offered from every state rather than only from a maximized panel, so it is one click from a tiled
// panel — and it steps back to a maximized panel rather than all the way out, the way a mac window does.
// The amber light is the way out, and it is present at both depths, so neither state is a trap.
// The only way to blow a panel up, and the only way back. It takes the whole browser window, nav header
// included — the shell hides its own chrome for it (`panel-fullscreen.ts`).
//
// Hidden until the header is hovered, like the zoom buttons — except while it is active, where it stays
// visible because the panel's own header is then the only chrome on screen.
// visible: the panel's own header is then the only chrome on screen, so a control that had to be
// discovered by hovering would be the way out of a state with no other exit.
const FullscreenToggle = ({ panelId }: { panelId: string }) => {
const { maximizedPanelId, maximizeMode, setMaximizedPanelId } = useWorkspace();
const isFullscreen = maximizedPanelId === panelId && maximizeMode === 'screen';
const { fullscreenPanelId, setFullscreenPanelId } = useWorkspace();
const isFullscreen = fullscreenPanelId === panelId;
const Icon = isFullscreen ? Minimize2 : Maximize2;
return (
<button
type="button"
onClick={() => setMaximizedPanelId(panelId, isFullscreen ? 'panel' : 'screen')}
onClick={() => setFullscreenPanelId(isFullscreen ? null : panelId)}
title={isFullscreen ? 'Exit full screen' : 'Full screen'}
className={`cursor-pointer rounded p-1 transition-all hover:bg-black/10 focus-visible:opacity-100 ${isFullscreen ? '' : 'opacity-0 group-hover/header:opacity-100'}`}
>
@@ -260,23 +190,16 @@ const FullscreenToggle = ({ panelId }: { panelId: string }) => {
);
};
// The two menus below are the locked and unlocked halves of the same panel, and maximize is the one
// thing both of them keep — so it is written once. Restore from either depth goes all the way out,
// matching the amber light rather than the fullscreen toggle.
const MaximizeMenuItems = ({ panelId }: { panelId: string }) => {
const { maximizedPanelId, maximizeMode, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
const isFullscreen = isMaximized && maximizeMode === 'screen';
// The two menus below are the locked and unlocked halves of the same panel, and full screen is the one
// thing both of them keep — so it is written once.
const FullscreenMenuItem = ({ panelId }: { panelId: string }) => {
const { fullscreenPanelId, setFullscreenPanelId } = useWorkspace();
const isFullscreen = fullscreenPanelId === panelId;
return (
<>
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
{isMaximized ? 'Restore' : 'Maximize'}
</ContextMenuItem>
<ContextMenuItem onClick={() => setMaximizedPanelId(panelId, isFullscreen ? 'panel' : 'screen')}>
{isFullscreen ? 'Exit full screen' : 'Full screen'}
</ContextMenuItem>
</>
<ContextMenuItem onClick={() => setFullscreenPanelId(isFullscreen ? null : panelId)}>
{isFullscreen ? 'Exit full screen' : 'Full screen'}
</ContextMenuItem>
);
};
@@ -338,7 +261,7 @@ const ZoomMenuItems = ({ panelId, zoom }: { panelId: string; zoom: number }) =>
);
};
const MaximizeContextMenu = ({
const LockedContextMenu = ({
panelId,
zoom,
children,
@@ -351,7 +274,7 @@ const MaximizeContextMenu = ({
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<MaximizeMenuItems panelId={panelId} />
<FullscreenMenuItem panelId={panelId} />
{zoom !== null && <ZoomMenuItems panelId={panelId} zoom={zoom} />}
</ContextMenuContent>
</ContextMenu>
@@ -388,9 +311,8 @@ export const PanelSlot = ({
onSplit,
onRemove,
}: PanelSlotProps) => {
const { maximizedPanelId, maximizeMode, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
const isMaximized = maximizedPanelId === panel.id;
const isFullscreen = isMaximized && maximizeMode === 'screen';
const { fullscreenPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
const isFullscreen = fullscreenPanelId === panel.id;
const rawPanelComponent = components?.[panel.id];
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
@@ -412,9 +334,9 @@ export const PanelSlot = ({
const contextMenu = interactive
? locked
? (content: React.ReactNode) => (
<MaximizeContextMenu panelId={panel.id} zoom={zoom}>
<LockedContextMenu panelId={panel.id} zoom={zoom}>
{content}
</MaximizeContextMenu>
</LockedContextMenu>
)
: (content: React.ReactNode) => (
<PanelContextMenu
@@ -501,18 +423,16 @@ export const PanelSlot = ({
</>
) : null;
// Locked screens get none: the red light edits the layout, and that is the one thing a locked screen
// withholds. Their only chrome is the fullscreen toggle below, which changes nothing persistent.
const trafficLights =
interactive && !isMobile ? (
locked ? (
<MaximizeButton panelId={panel.id} />
) : (
<TrafficLights
panelId={panel.id}
isLastPanel={isLastPanel}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
)
interactive && !isMobile && !locked ? (
<TrafficLights
panelId={panel.id}
isLastPanel={isLastPanel}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
) : null;
const mobileBackButton =
@@ -556,9 +476,9 @@ export const PanelSlot = ({
const headerBar = interactive ? (
locked ? (
<MaximizeContextMenu panelId={panel.id} zoom={zoom}>
<LockedContextMenu panelId={panel.id} zoom={zoom}>
{headerContent}
</MaximizeContextMenu>
</LockedContextMenu>
) : (
<PanelContextMenu
panelId={panel.id}
@@ -609,34 +529,29 @@ export const PanelSlot = ({
</>
);
// 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, and
// across the step between the two depths.
// Full screen 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 it.
//
// `panel` fills the content REGION, starting below the global Header. That is not a design choice
// about how much room to take: the panel is confined to the layout's `absolute z-2` content stacking
// context, which sits under the fixed nav Header (z-10), so no z-index it can give itself will paint
// over the nav — a full-viewport overlay would simply put the nav on top of the panel's own header and
// its Restore button. Clearing the header keeps both visible.
// `inset-0` is genuinely the whole window only because the shell stands its own header down while a
// panel is fullscreen (`panel-fullscreen.ts`). Nothing here can achieve that alone: the panel is
// confined to the layout's `absolute z-2` content stacking context, which sits under the fixed nav
// Header (z-10), so no z-index it gives itself will paint over the nav. This used to have a second,
// shallower mode that simply accepted that and started at `top-[56px]`; it was removed once this one
// worked, along with the green/amber traffic lights that drove it.
//
// `screen` gets the rest by the only route available: the shell stands its own header down while a
// panel is fullscreen (`panel-fullscreen.ts`), so `inset-0` is genuinely the whole window. It loses the
// gutter, the rounding and the shadow with it — those read as "a window floating above the app", and
// at this depth there is no app left to float above.
// No gutter, no rounding, no shadow — those read as "a window floating above the app", and here there
// is no app left to float above.
//
// (Maximize only happens in the dashboard shell; previews/mobile no-op it, so the header offset is
// always the right reference.)
// Denser glass than a tiled panel, not a darker one. Panel chrome — header text, icons, the zoom
// control — is black throughout, so the old near-opaque dark fill left every one of them unreadable
// the moment you maximised. This is the nav Header's exact recipe (`rgba(255,255,255,0.25)` over a
// 0.35 border), which is the app's established "raised above the rest" surface and is already proven
// to carry black text.
const maximizedStyle = { backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' };
// control — is black throughout, so the old near-opaque dark fill left every one of them unreadable.
// This is the nav Header's exact recipe (`rgba(255,255,255,0.25)` over a 0.35 border), which is the
// app's established "raised above the rest" surface and is already proven to carry black text.
const fullscreenStyle = { backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' };
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),
...(isFullscreen ? fullscreenStyle : normalStyle),
...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}),
} as React.CSSProperties;
@@ -646,9 +561,7 @@ export const PanelSlot = ({
className={
isFullscreen
? 'fixed inset-0 z-50 overflow-hidden backdrop-blur-xl p-2 flex flex-col'
: 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'
: 'relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col'
}
style={panelStyle}
>
@@ -1,7 +1,7 @@
import { createContext, useContext } from 'react';
import type { DropPosition } from './layout-utils';
import type { MaximizeMode, PanelConfig } from './types';
import type { PanelConfig } from './types';
import type { WorkspaceIdentity } from './workspace-identity';
export type WorkspaceContextValue = {
@@ -24,13 +24,16 @@ export type WorkspaceContextValue = {
setDragSourceId: (id: string | null) => void;
onMove: (sourceId: string, targetId: string, position: DropPosition) => void;
onSetZoom: (panelId: string, zoom: number) => void;
maximizedPanelId: string | null;
/** How far the maximized panel goes. Only meaningful for `maximizedPanelId`; `'panel'` otherwise. */
maximizeMode: MaximizeMode;
// `mode` defaults to `'panel'`, so every existing caller means what it always meant: the green light
// and the context menu maximize into the content region, and only the fullscreen control asks for the
// window.
setMaximizedPanelId: (id: string | null, mode?: MaximizeMode) => void;
/**
* The one panel filling the window, if any. There is a single depth: it takes the whole browser
* window, nav header included — the shell hides its own chrome for it (`panel-fullscreen.ts`).
*
* There used to be a shallower one that stopped below the nav header, driven by a green/amber pair of
* traffic lights. It was only ever shallow because a panel cannot paint over the header from inside
* the content region's stacking context, and once full screen worked the half-measure had no use.
*/
fullscreenPanelId: string | null;
setFullscreenPanelId: (id: string | null) => void;
transitioningPanelId: string | null;
isMobile: boolean;
onMobileBack: (() => void) | null;
@@ -59,9 +62,8 @@ export const inertInteraction = {
setDragSourceId: noop,
onMove: noop,
onSetZoom: noop,
maximizedPanelId: null,
maximizeMode: 'panel' as const,
setMaximizedPanelId: noop,
fullscreenPanelId: null,
setFullscreenPanelId: noop,
transitioningPanelId: null,
} satisfies Partial<WorkspaceContextValue>;
@@ -158,7 +158,7 @@ describe('closing a panel', () => {
expect(screen.queryAllByTitle('Close panel')).toEqual([]);
expect(screen.queryAllByTitle('Clear app')).toEqual([]);
expect(screen.getAllByTitle('Maximize').length).toBe(2);
expect(screen.getAllByTitle('Full screen').length).toBe(2);
});
});
@@ -3,7 +3,7 @@ import { flushSync } from 'react-dom';
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
import { useIsMobile } from 'hooks/useIsMobile';
import { useSessionState } from 'hooks/useSessionState';
import type { LayoutNode, DashboardState, EphemeralPanels, MaximizeMode, PanelComponents, PanelConfig } from './types';
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types';
import type { DropPosition } from './layout-utils';
import {
splitPanel,
@@ -26,9 +26,6 @@ import { WorkspaceRenderer } from './WorkspaceRenderer';
import { usePublishPanelFullscreen } from './panel-fullscreen';
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
/** What this tab has blown up, and how far. Stored as one value so the two cannot drift apart. */
type MaximizedPanel = { id: string; mode: MaximizeMode };
type WorkspaceViewProps = {
workspace: DashboardState;
locked?: boolean;
@@ -83,40 +80,37 @@ export const WorkspaceView = ({
}, [workspace.isLoaded, layout, rawLayout, onLayoutChange]);
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
// Per tab and per dashboard: maximising the chat panel and refreshing should come back maximised, while
// a second window on the same screen keeps its own idea of what is maximised. A stored id whose panel
// has since been removed simply maximises nothing — maximize is a style toggle on the panel itself, so
// there is nothing to strand.
const maximizedKey = `MAXIMIZED_PANEL:${workspace.key}`;
const [maximized, setMaximized] = useSessionState<MaximizedPanel | null>(maximizedKey, null);
// Read through `?.`, not destructured or cast: a tab that was already open when `mode` was added still
// has the old bare-string value under this key, and reading a property off a string yields undefined
// rather than throwing. So the stale shape lands on "nothing is maximized" — the same harmless place a
// stored id whose panel has since been removed lands.
const maximizedPanelId = maximized?.id ?? null;
const maximizeMode: MaximizeMode = maximized?.mode === 'screen' ? 'screen' : 'panel';
// Per tab and per dashboard: putting the chat panel full screen and refreshing should come back full
// screen, while a second window on the same screen keeps its own idea of what is blown up. A stored id
// whose panel has since been removed simply fills nothing — this is a style toggle on the panel itself,
// so there is nothing to strand.
//
// Keyed `FULLSCREEN_PANEL`, not the `MAXIMIZED_PANEL` this used to be. The old key briefly held a
// `{ id, mode }` pair for the two depths; renaming it means a tab open across that change reads nothing
// here rather than reading an object where a string is expected.
const fullscreenKey = `FULLSCREEN_PANEL:${workspace.key}`;
const [fullscreenPanelId, setFullscreenPanelId] = useSessionState<string | null>(fullscreenKey, null);
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
// The shell hides the nav header for a fullscreen panel — a panel cannot paint over it from inside the
// content region's stacking context. See `panel-fullscreen.ts`.
usePublishPanelFullscreen(maximizedPanelId !== null && maximizeMode === 'screen');
// The shell hides the nav header for it — a panel cannot paint over that from inside the content
// region's stacking context. See `panel-fullscreen.ts`.
usePublishPanelFullscreen(fullscreenPanelId !== null);
const setMaximizedAnimated = useCallback(
(id: string | null, mode: MaximizeMode = 'panel') => {
const setFullscreenAnimated = useCallback(
(id: string | null) => {
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
const panelId = maximizedPanelId ?? id;
const next = id ? { id, mode } : null;
const panelId = fullscreenPanelId ?? id;
if (doc.startViewTransition && panelId) {
setTransitioningPanelId(panelId);
requestAnimationFrame(() => {
const transition = doc.startViewTransition(() => flushSync(() => setMaximized(next)));
const transition = doc.startViewTransition(() => flushSync(() => setFullscreenPanelId(id)));
transition.finished.finally(() => setTransitioningPanelId(null));
});
} else {
setMaximized(next);
setFullscreenPanelId(id);
}
},
[maximizedPanelId, setMaximized],
[fullscreenPanelId, setFullscreenPanelId],
);
// What each panel wants done when it is closed, by panel id, stamped with the workspace it was
@@ -315,9 +309,8 @@ export const WorkspaceView = ({
setDragSourceId: startDrag,
onMove: handleMove,
onSetZoom: handleSetZoom,
maximizedPanelId,
maximizeMode,
setMaximizedPanelId: setMaximizedAnimated,
fullscreenPanelId,
setFullscreenPanelId: setFullscreenAnimated,
transitioningPanelId,
isMobile,
onMobileBack,
@@ -336,9 +329,8 @@ export const WorkspaceView = ({
startDrag,
handleMove,
handleSetZoom,
maximizedPanelId,
maximizeMode,
setMaximizedAnimated,
fullscreenPanelId,
setFullscreenAnimated,
transitioningPanelId,
isMobile,
onMobileBack,
@@ -32,7 +32,6 @@ export {
} from './layout-utils';
export type { WorkspaceIdentity } from './workspace-identity';
export { parseWorkspaceKey } from './workspace-identity';
export type { MaximizeMode } from './types';
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
export { usePanelFullscreen } from './panel-fullscreen';
export { usePanelConfig } from './usePanelConfig';
@@ -34,18 +34,6 @@ export type PanelConfig = Record<string, unknown>;
export type LayoutNode = LayoutGroup | LayoutPanel;
/**
* How far a maximized panel goes.
*
* `panel` fills the dashboard's content region and leaves the nav header visible and clickable.
* `screen` takes the whole browser window, header included — the shell hides its own chrome for it
* (see `panel-fullscreen.ts`), because a panel cannot paint out of the region's stacking context.
*
* Not persisted with the layout: which panel is blown up, and how far, is a fact about *this tab* the
* way `zoom` is a fact about the panel. It lives beside the maximized panel id in sessionStorage.
*/
export type MaximizeMode = 'panel' | 'screen';
export type HomeRoot = 'home' | '~' | 'officer.dev';
export type DashboardDefinition = {