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 * 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 * 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 * `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) { export function useSessionState<T>(key: string, initialValue: T) {
// Read the stored value once per mount rather than on every render — `useGlobal` re-evaluates its // 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 * "in this mode, which controls exist, and does each one call the handler it is named after" — cheap to
* answer and nobody had. * 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 * 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 * 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 * 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 * `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 * `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']; const Icon = ((props: Record<string, unknown>) => <svg {...props} />) as AppRegistryMap[string]['icon'];
@@ -52,7 +52,7 @@ type Calls = {
remove: string[]; remove: string[];
zoom: [string, number][]; zoom: [string, number][];
swap: [string, string][]; swap: [string, string][];
maximize: [string | null, string | undefined][]; fullscreen: (string | null)[];
swapSource: (string | null)[]; swapSource: (string | null)[];
mobileBack: number; mobileBack: number;
}; };
@@ -60,7 +60,7 @@ type Calls = {
let calls: Calls; let calls: Calls;
beforeEach(() => { beforeEach(() => {
calls = { setApp: [], split: [], remove: [], zoom: [], swap: [], maximize: [], swapSource: [], mobileBack: 0 }; calls = { setApp: [], split: [], remove: [], zoom: [], swap: [], fullscreen: [], swapSource: [], mobileBack: 0 };
}); });
type MountOptions = { type MountOptions = {
@@ -83,7 +83,7 @@ function mount(options: MountOptions = {}) {
onMobileBack: null, onMobileBack: null,
onSwap: (source, target) => calls.swap.push([source, target]), onSwap: (source, target) => calls.swap.push([source, target]),
setSwapSourceId: (id) => calls.swapSource.push(id), 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]), onSetZoom: (id, z) => calls.zoom.push([id, z]),
...options.context, ...options.context,
}; };
@@ -130,26 +130,26 @@ describe('the red button does what its own label says', () => {
}); });
describe('which chrome each mode gets', () => { describe('which chrome each mode gets', () => {
test('interactive and unlocked: close and maximize', () => { test('interactive and unlocked: close and full screen', () => {
mount(); mount();
expect(screen.getByTitle('Close panel')).toBeTruthy(); 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 }); mount({ locked: true });
expect(screen.queryByTitle('Close panel')).toBeNull(); expect(screen.queryByTitle('Close panel')).toBeNull();
expect(screen.queryByTitle('Clear app')).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', () => { test('not interactive: no chrome buttons at all', () => {
mount({ interactive: false }); mount({ interactive: false });
expect(screen.queryByTitle('Close panel')).toBeNull(); 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.queryByTitle('Zoom in')).toBeNull();
expect(screen.getByTestId('app')).toBeTruthy(); expect(screen.getByTestId('app')).toBeTruthy();
}); });
@@ -164,28 +164,29 @@ describe('which chrome each mode gets', () => {
expect(calls.mobileBack).toBe(1); expect(calls.mobileBack).toBe(1);
}); });
test('maximized: Restore replaces close, so the way out is never a way to delete', () => { test('the green and amber lights are gone, at both depths of the old feature', () => {
mount({ context: { maximizedPanelId: 'p1' } }); // 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
expect(screen.queryByTitle('Close panel')).toBeNull(); // rather than becoming a second way to do a subset of one thing.
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', () => {
const view = mount(); const view = mount();
fireEvent.click(screen.getByTitle('Maximize')); expect(screen.queryByTitle('Maximize')).toBeNull();
expect(calls.maximize).toEqual([['p1', undefined]]); expect(screen.queryByTitle('Restore')).toBeNull();
view.unmount(); view.unmount();
mount({ locked: true, context: { maximizedPanelId: 'p1' } }); mount({ context: { fullscreenPanelId: 'p1' } });
fireEvent.click(screen.getByTitle('Restore')); expect(screen.queryByTitle('Maximize')).toBeNull();
expect(calls.maximize).toEqual([ expect(screen.queryByTitle('Restore')).toBeNull();
['p1', undefined], });
[null, undefined],
]); 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', () => { 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 wrapper = () => document.querySelector<HTMLElement>('[data-panel-id] > div');
const fullscreen = { maximizedPanelId: 'p1', maximizeMode: 'screen' as const }; const fullscreen = { fullscreenPanelId: 'p1' };
test('offered from a tiled panel, so taking the window is one click and not two', () => {
mount();
test('one toggle in, one toggle out', () => {
const view = mount();
fireEvent.click(screen.getByTitle('Full screen')); 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 }); mount({ context: fullscreen });
fireEvent.click(screen.getByTitle('Exit full screen')); fireEvent.click(screen.getByTitle('Exit full screen'));
fireEvent.click(screen.getByTitle('Restore')); expect(calls.fullscreen).toEqual(['p1', null]);
expect(calls.maximize).toEqual([
['p1', 'panel'],
[null, undefined],
]);
}); });
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 }); mount({ locked: true });
fireEvent.click(screen.getByTitle('Full screen')); 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('Close panel')).toBeNull();
expect(screen.queryByTitle('Clear app')).toBeNull();
}); });
test('no app opts out: unlike zoom, this is a property of the frame', () => { 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(); expect(screen.queryByTitle('Full screen')).toBeNull();
}); });
test('it drops the gutter and the rounding that say "floating above the app"', () => { test('it takes the window, dropping the gutter and 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 // `inset-0` is only the whole window because the shell hides its own header for it (see
// header for it (see panel-fullscreen.ts). Maximize keeps the gutter and clears the nav at // panel-fullscreen.ts). `top-[56px]` was the old shallower depth clearing the nav it could not paint
// `top-[56px]`, which is what this is pinned against. // over; it should appear nowhere now.
const view = mount({ context: fullscreen }); const view = mount({ context: fullscreen });
expect(wrapper()?.className).toContain('inset-0'); expect(wrapper()?.className).toContain('inset-0');
expect(wrapper()?.className).not.toContain('rounded-lg'); expect(wrapper()?.className).not.toContain('rounded-lg');
expect(wrapper()?.className).not.toContain('top-[56px]'); expect(wrapper()?.className).not.toContain('top-[56px]');
view.unmount(); view.unmount();
mount({ context: { maximizedPanelId: 'p1' } }); mount();
expect(wrapper()?.className).toContain('top-[56px]');
expect(wrapper()?.className).toContain('rounded-lg'); expect(wrapper()?.className).toContain('rounded-lg');
expect(wrapper()?.className).not.toContain('fixed');
}); });
}); });
@@ -1,6 +1,6 @@
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { useCallback } 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 type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
import { useWorkspace } from './WorkspaceContext'; import { useWorkspace } from './WorkspaceContext';
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from './layout-utils'; import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from './layout-utils';
@@ -60,7 +60,7 @@ const PanelContextMenu = ({
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger> <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<MaximizeMenuItems panelId={panelId} /> <FullscreenMenuItem panelId={panelId} />
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem> <ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem> <ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
@@ -132,9 +132,6 @@ const TrafficLights = ({
onRemove: (panelId: string) => void; onRemove: (panelId: string) => void;
onClearApp: () => 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` // 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 // 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. // 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); else onRemove(panelId);
}, [isLastPanel, onClearApp, onRemove, panelId]); }, [isLastPanel, onClearApp, onRemove, panelId]);
const handleRestore = useCallback(() => { // One light, and it is not a traffic light any more. The green "maximize" and amber "restore" that used
setMaximizedPanelId(null); // to sit here drove a second, shallower depth that stopped below the nav header — the most a panel can
}, [setMaximizedPanelId]); // 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
const handleMaximize = useCallback(() => { // full screen is the only depth.
setMaximizedPanelId(panelId); //
}, [panelId, setMaximizedPanelId]); // 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.
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>
);
}
return ( return (
<div className="flex items-center gap-1.5 shrink-0 ml-auto"> <div className="flex items-center gap-1.5 shrink-0 ml-auto">
<button <button
@@ -184,74 +163,25 @@ const TrafficLights = ({
strokeWidth={3} strokeWidth={3}
/> />
</button> </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> </div>
); );
}; };
// Locked screens get maximize only — no close, no clear. Green means "there is room to grow", amber // The only way to blow a panel up, and the only way back. It takes the whole browser window, nav header
// means "this one is already filling the screen", the same way the two colours read on a mac window; // included — the shell hides its own chrome for it (`panel-fullscreen.ts`).
// `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.
// //
// Hidden until the header is hovered, like the zoom buttons — except while it is active, where it stays // 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 FullscreenToggle = ({ panelId }: { panelId: string }) => {
const { maximizedPanelId, maximizeMode, setMaximizedPanelId } = useWorkspace(); const { fullscreenPanelId, setFullscreenPanelId } = useWorkspace();
const isFullscreen = maximizedPanelId === panelId && maximizeMode === 'screen'; const isFullscreen = fullscreenPanelId === panelId;
const Icon = isFullscreen ? Minimize2 : Maximize2; const Icon = isFullscreen ? Minimize2 : Maximize2;
return ( return (
<button <button
type="button" type="button"
onClick={() => setMaximizedPanelId(panelId, isFullscreen ? 'panel' : 'screen')} onClick={() => setFullscreenPanelId(isFullscreen ? null : panelId)}
title={isFullscreen ? 'Exit full screen' : 'Full screen'} 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'}`} 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 // 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. Restore from either depth goes all the way out, // thing both of them keep — so it is written once.
// matching the amber light rather than the fullscreen toggle. const FullscreenMenuItem = ({ panelId }: { panelId: string }) => {
const MaximizeMenuItems = ({ panelId }: { panelId: string }) => { const { fullscreenPanelId, setFullscreenPanelId } = useWorkspace();
const { maximizedPanelId, maximizeMode, setMaximizedPanelId } = useWorkspace(); const isFullscreen = fullscreenPanelId === panelId;
const isMaximized = maximizedPanelId === panelId;
const isFullscreen = isMaximized && maximizeMode === 'screen';
return ( return (
<> <ContextMenuItem onClick={() => setFullscreenPanelId(isFullscreen ? null : panelId)}>
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}> {isFullscreen ? 'Exit full screen' : 'Full screen'}
{isMaximized ? 'Restore' : 'Maximize'} </ContextMenuItem>
</ContextMenuItem>
<ContextMenuItem onClick={() => setMaximizedPanelId(panelId, isFullscreen ? 'panel' : 'screen')}>
{isFullscreen ? 'Exit full screen' : 'Full screen'}
</ContextMenuItem>
</>
); );
}; };
@@ -338,7 +261,7 @@ const ZoomMenuItems = ({ panelId, zoom }: { panelId: string; zoom: number }) =>
); );
}; };
const MaximizeContextMenu = ({ const LockedContextMenu = ({
panelId, panelId,
zoom, zoom,
children, children,
@@ -351,7 +274,7 @@ const MaximizeContextMenu = ({
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger> <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<MaximizeMenuItems panelId={panelId} /> <FullscreenMenuItem panelId={panelId} />
{zoom !== null && <ZoomMenuItems panelId={panelId} zoom={zoom} />} {zoom !== null && <ZoomMenuItems panelId={panelId} zoom={zoom} />}
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
@@ -388,9 +311,8 @@ export const PanelSlot = ({
onSplit, onSplit,
onRemove, onRemove,
}: PanelSlotProps) => { }: PanelSlotProps) => {
const { maximizedPanelId, maximizeMode, transitioningPanelId, isMobile, onMobileBack } = useWorkspace(); const { fullscreenPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
const isMaximized = maximizedPanelId === panel.id; const isFullscreen = fullscreenPanelId === panel.id;
const isFullscreen = isMaximized && maximizeMode === 'screen';
const rawPanelComponent = components?.[panel.id]; const rawPanelComponent = components?.[panel.id];
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null; const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
@@ -412,9 +334,9 @@ export const PanelSlot = ({
const contextMenu = interactive const contextMenu = interactive
? locked ? locked
? (content: React.ReactNode) => ( ? (content: React.ReactNode) => (
<MaximizeContextMenu panelId={panel.id} zoom={zoom}> <LockedContextMenu panelId={panel.id} zoom={zoom}>
{content} {content}
</MaximizeContextMenu> </LockedContextMenu>
) )
: (content: React.ReactNode) => ( : (content: React.ReactNode) => (
<PanelContextMenu <PanelContextMenu
@@ -501,18 +423,16 @@ export const PanelSlot = ({
</> </>
) : null; ) : 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 = const trafficLights =
interactive && !isMobile ? ( interactive && !isMobile && !locked ? (
locked ? ( <TrafficLights
<MaximizeButton panelId={panel.id} /> panelId={panel.id}
) : ( isLastPanel={isLastPanel}
<TrafficLights onRemove={onRemove}
panelId={panel.id} onClearApp={() => onSetApp(panel.id, null)}
isLastPanel={isLastPanel} />
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
)
) : null; ) : null;
const mobileBackButton = const mobileBackButton =
@@ -556,9 +476,9 @@ export const PanelSlot = ({
const headerBar = interactive ? ( const headerBar = interactive ? (
locked ? ( locked ? (
<MaximizeContextMenu panelId={panel.id} zoom={zoom}> <LockedContextMenu panelId={panel.id} zoom={zoom}>
{headerContent} {headerContent}
</MaximizeContextMenu> </LockedContextMenu>
) : ( ) : (
<PanelContextMenu <PanelContextMenu
panelId={panel.id} 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 // Full screen is a CSS state toggle on THE SAME element — no portal, no remount — so the panel's
// (scroll position, media playback, in-flight state) is preserved exactly across maximize/restore, and // content (scroll position, media playback, in-flight state) is preserved exactly across it.
// across the step between the two depths.
// //
// `panel` fills the content REGION, starting below the global Header. That is not a design choice // `inset-0` is genuinely the whole window only because the shell stands its own header down while a
// about how much room to take: the panel is confined to the layout's `absolute z-2` content stacking // panel is fullscreen (`panel-fullscreen.ts`). Nothing here can achieve that alone: the panel is
// context, which sits under the fixed nav Header (z-10), so no z-index it can give itself will paint // confined to the layout's `absolute z-2` content stacking context, which sits under the fixed nav
// over the nav — a full-viewport overlay would simply put the nav on top of the panel's own header and // Header (z-10), so no z-index it gives itself will paint over the nav. This used to have a second,
// its Restore button. Clearing the header keeps both visible. // 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 // No gutter, no rounding, no shadow — those read as "a window floating above the app", and here there
// panel is fullscreen (`panel-fullscreen.ts`), so `inset-0` is genuinely the whole window. It loses the // is no app left to float above.
// 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.
// //
// (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 // 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 // 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 // This is the nav Header's exact recipe (`rgba(255,255,255,0.25)` over a 0.35 border), which is the
// 0.35 border), which is the app's established "raised above the rest" surface and is already proven // app's established "raised above the rest" surface and is already proven to carry black text.
// to carry black text. const fullscreenStyle = { backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' };
const maximizedStyle = { backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' };
const normalStyle = noHeader 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(0, 0, 0, 0.25)' }
: { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }; : { backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' };
const panelStyle = { const panelStyle = {
...(isMaximized ? maximizedStyle : normalStyle), ...(isFullscreen ? fullscreenStyle : normalStyle),
...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}), ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}),
} as React.CSSProperties; } as React.CSSProperties;
@@ -646,9 +561,7 @@ export const PanelSlot = ({
className={ className={
isFullscreen isFullscreen
? 'fixed inset-0 z-50 overflow-hidden backdrop-blur-xl p-2 flex flex-col' ? 'fixed inset-0 z-50 overflow-hidden backdrop-blur-xl p-2 flex flex-col'
: isMaximized : 'relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col'
? '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} style={panelStyle}
> >
@@ -1,7 +1,7 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import type { DropPosition } from './layout-utils'; import type { DropPosition } from './layout-utils';
import type { MaximizeMode, PanelConfig } from './types'; import type { PanelConfig } from './types';
import type { WorkspaceIdentity } from './workspace-identity'; import type { WorkspaceIdentity } from './workspace-identity';
export type WorkspaceContextValue = { export type WorkspaceContextValue = {
@@ -24,13 +24,16 @@ export type WorkspaceContextValue = {
setDragSourceId: (id: string | null) => void; setDragSourceId: (id: string | null) => void;
onMove: (sourceId: string, targetId: string, position: DropPosition) => void; onMove: (sourceId: string, targetId: string, position: DropPosition) => void;
onSetZoom: (panelId: string, zoom: number) => void; onSetZoom: (panelId: string, zoom: number) => void;
maximizedPanelId: string | null; /**
/** How far the maximized panel goes. Only meaningful for `maximizedPanelId`; `'panel'` otherwise. */ * The one panel filling the window, if any. There is a single depth: it takes the whole browser
maximizeMode: MaximizeMode; * window, nav header included — the shell hides its own chrome for it (`panel-fullscreen.ts`).
// `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 * There used to be a shallower one that stopped below the nav header, driven by a green/amber pair of
// window. * traffic lights. It was only ever shallow because a panel cannot paint over the header from inside
setMaximizedPanelId: (id: string | null, mode?: MaximizeMode) => void; * 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; transitioningPanelId: string | null;
isMobile: boolean; isMobile: boolean;
onMobileBack: (() => void) | null; onMobileBack: (() => void) | null;
@@ -59,9 +62,8 @@ export const inertInteraction = {
setDragSourceId: noop, setDragSourceId: noop,
onMove: noop, onMove: noop,
onSetZoom: noop, onSetZoom: noop,
maximizedPanelId: null, fullscreenPanelId: null,
maximizeMode: 'panel' as const, setFullscreenPanelId: noop,
setMaximizedPanelId: noop,
transitioningPanelId: null, transitioningPanelId: null,
} satisfies Partial<WorkspaceContextValue>; } satisfies Partial<WorkspaceContextValue>;
@@ -158,7 +158,7 @@ describe('closing a panel', () => {
expect(screen.queryAllByTitle('Close panel')).toEqual([]); expect(screen.queryAllByTitle('Close panel')).toEqual([]);
expect(screen.queryAllByTitle('Clear app')).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 { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
import { useIsMobile } from 'hooks/useIsMobile'; import { useIsMobile } from 'hooks/useIsMobile';
import { useSessionState } from 'hooks/useSessionState'; 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 type { DropPosition } from './layout-utils';
import { import {
splitPanel, splitPanel,
@@ -26,9 +26,6 @@ import { WorkspaceRenderer } from './WorkspaceRenderer';
import { usePublishPanelFullscreen } from './panel-fullscreen'; import { usePublishPanelFullscreen } from './panel-fullscreen';
import { useAppRegistry } from '../../AppRegistry/useAppRegistry'; 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 = { type WorkspaceViewProps = {
workspace: DashboardState; workspace: DashboardState;
locked?: boolean; locked?: boolean;
@@ -83,40 +80,37 @@ export const WorkspaceView = ({
}, [workspace.isLoaded, layout, rawLayout, onLayoutChange]); }, [workspace.isLoaded, layout, rawLayout, onLayoutChange]);
const [swapSourceId, setSwapSourceId] = useState<string | null>(null); const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
const [dragSourceId, setDragSourceId] = 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 // Per tab and per dashboard: putting the chat panel full screen and refreshing should come back full
// a second window on the same screen keeps its own idea of what is maximised. A stored id whose panel // screen, while a second window on the same screen keeps its own idea of what is blown up. A stored id
// has since been removed simply maximises nothing — maximize is a style toggle on the panel itself, so // whose panel has since been removed simply fills nothing — this is a style toggle on the panel itself,
// there is nothing to strand. // so there is nothing to strand.
const maximizedKey = `MAXIMIZED_PANEL:${workspace.key}`; //
const [maximized, setMaximized] = useSessionState<MaximizedPanel | null>(maximizedKey, null); // Keyed `FULLSCREEN_PANEL`, not the `MAXIMIZED_PANEL` this used to be. The old key briefly held a
// Read through `?.`, not destructured or cast: a tab that was already open when `mode` was added still // `{ id, mode }` pair for the two depths; renaming it means a tab open across that change reads nothing
// has the old bare-string value under this key, and reading a property off a string yields undefined // here rather than reading an object where a string is expected.
// rather than throwing. So the stale shape lands on "nothing is maximized" — the same harmless place a const fullscreenKey = `FULLSCREEN_PANEL:${workspace.key}`;
// stored id whose panel has since been removed lands. const [fullscreenPanelId, setFullscreenPanelId] = useSessionState<string | null>(fullscreenKey, null);
const maximizedPanelId = maximized?.id ?? null;
const maximizeMode: MaximizeMode = maximized?.mode === 'screen' ? 'screen' : 'panel';
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(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 // The shell hides the nav header for it — a panel cannot paint over that from inside the content
// content region's stacking context. See `panel-fullscreen.ts`. // region's stacking context. See `panel-fullscreen.ts`.
usePublishPanelFullscreen(maximizedPanelId !== null && maximizeMode === 'screen'); usePublishPanelFullscreen(fullscreenPanelId !== null);
const setMaximizedAnimated = useCallback( const setFullscreenAnimated = useCallback(
(id: string | null, mode: MaximizeMode = 'panel') => { (id: string | null) => {
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } }; const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
const panelId = maximizedPanelId ?? id; const panelId = fullscreenPanelId ?? id;
const next = id ? { id, mode } : null;
if (doc.startViewTransition && panelId) { if (doc.startViewTransition && panelId) {
setTransitioningPanelId(panelId); setTransitioningPanelId(panelId);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const transition = doc.startViewTransition(() => flushSync(() => setMaximized(next))); const transition = doc.startViewTransition(() => flushSync(() => setFullscreenPanelId(id)));
transition.finished.finally(() => setTransitioningPanelId(null)); transition.finished.finally(() => setTransitioningPanelId(null));
}); });
} else { } 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 // 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, setDragSourceId: startDrag,
onMove: handleMove, onMove: handleMove,
onSetZoom: handleSetZoom, onSetZoom: handleSetZoom,
maximizedPanelId, fullscreenPanelId,
maximizeMode, setFullscreenPanelId: setFullscreenAnimated,
setMaximizedPanelId: setMaximizedAnimated,
transitioningPanelId, transitioningPanelId,
isMobile, isMobile,
onMobileBack, onMobileBack,
@@ -336,9 +329,8 @@ export const WorkspaceView = ({
startDrag, startDrag,
handleMove, handleMove,
handleSetZoom, handleSetZoom,
maximizedPanelId, fullscreenPanelId,
maximizeMode, setFullscreenAnimated,
setMaximizedAnimated,
transitioningPanelId, transitioningPanelId,
isMobile, isMobile,
onMobileBack, onMobileBack,
@@ -32,7 +32,6 @@ export {
} from './layout-utils'; } from './layout-utils';
export type { WorkspaceIdentity } from './workspace-identity'; export type { WorkspaceIdentity } from './workspace-identity';
export { parseWorkspaceKey } from './workspace-identity'; export { parseWorkspaceKey } from './workspace-identity';
export type { MaximizeMode } from './types';
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext'; export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
export { usePanelFullscreen } from './panel-fullscreen'; export { usePanelFullscreen } from './panel-fullscreen';
export { usePanelConfig } from './usePanelConfig'; export { usePanelConfig } from './usePanelConfig';
@@ -34,18 +34,6 @@ export type PanelConfig = Record<string, unknown>;
export type LayoutNode = LayoutGroup | LayoutPanel; 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 HomeRoot = 'home' | '~' | 'officer.dev';
export type DashboardDefinition = { export type DashboardDefinition = {