It was capped at the device's own height, on the reasoning that a 390x844 phone drawn 1200 pixels tall is not a phone. That was wrong, and the empty band under the preview in View was the tell. Media queries answer WIDTH. The layout at 390 is the phone's layout whatever the height, so a taller frame shows more of the page rather than a different device — a scroll position, not a viewport class. Width stays the device; height is now simply the room there is. DEVICES carries widths only. The heights had exactly one consumer, and it was the cap. One consequence, and it is the right one: the footer is min-h-dvh, so it sits at the bottom of the FRAME. A taller frame puts it lower, exactly as a taller browser window does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
108 lines
4.6 KiB
TypeScript
108 lines
4.6 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
|
|
/**
|
|
* How the open section is being looked at: editing or just viewing, and at what width.
|
|
*
|
|
* ── Why a context, and the same shape as SaveTarget ──
|
|
*
|
|
* The controls live in DetailPanel's bar and the thing they control lives in the section. DetailPanel
|
|
* deliberately knows nothing about the sections beyond a map of keys to components, so the section
|
|
* announces itself rather than being handed props — exactly as it does for Save.
|
|
*
|
|
* ── Why a section has to OPT IN ──
|
|
*
|
|
* Most sections have no preview. Media is a file browser, Administration is a connection form, Messages
|
|
* is an inbox — offering to view any of them at tablet width is offering something that means nothing,
|
|
* and a control that does nothing teaches people that controls do nothing.
|
|
*
|
|
* So the bar shows these only when the open section says it has a preview, the same way Save renders
|
|
* nothing when there is nothing to save.
|
|
*/
|
|
|
|
export type Mode = 'edit' | 'view';
|
|
export type DeviceName = 'desktop' | 'tablet' | 'mobile';
|
|
|
|
/**
|
|
* The widths a preview is rendered at, and why these numbers.
|
|
*
|
|
* Widths ONLY. There were heights too, and they were wrong: media queries answer width, so a frame's
|
|
* height changes how much page you see and never which layout you get. The preview takes whatever
|
|
* height the panel has.
|
|
*
|
|
* They are REAL viewport sizes, and the preview is scaled down to fit rather than being given whatever
|
|
* width the panel happens to have. That distinction is the whole point: a "Desktop" preview squeezed
|
|
* into a 900px panel would render the site's tablet layout and call it desktop, which is precisely the
|
|
* lie a preview exists to prevent.
|
|
*
|
|
* mobile 390 — the iPhone width the site's own slot table already assumes (see media/slots.ts,
|
|
* where MOBILE is 342: this minus the layout's 48px of padding)
|
|
* tablet 834 — iPad portrait, and deliberately ABOVE the site's `md:` breakpoint at 768, so it
|
|
* shows the desktop navigation rather than the drawer. A tablet at 768 exactly sits
|
|
* on the boundary and is the least informative width available.
|
|
* desktop 1440 — a common laptop, comfortably past the 1280 the content column caps at, so the
|
|
* margins either side are visible rather than implied.
|
|
*/
|
|
export const DEVICES: Record<DeviceName, { label: string; width: number }> = {
|
|
desktop: { label: 'Desktop', width: 1440 },
|
|
tablet: { label: 'Tablet', width: 834 },
|
|
mobile: { label: 'Mobile', width: 390 },
|
|
};
|
|
|
|
type ViewModeValue = {
|
|
mode: Mode;
|
|
device: DeviceName;
|
|
setMode: (mode: Mode) => void;
|
|
setDevice: (device: DeviceName) => void;
|
|
/** True while a section with a preview is open. The bar shows its controls only then. */
|
|
previewable: boolean;
|
|
announcePreviewable: (has: boolean) => void;
|
|
};
|
|
|
|
const ViewModeContext = createContext<ViewModeValue>({
|
|
mode: 'edit',
|
|
device: 'desktop',
|
|
setMode: () => {},
|
|
setDevice: () => {},
|
|
previewable: false,
|
|
announcePreviewable: () => {},
|
|
});
|
|
|
|
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
|
|
/*
|
|
View, not Edit.
|
|
|
|
Opening a section is nearly always to LOOK at something — to check what a change did, or to see the
|
|
state of the site before deciding anything. Editing is the deliberate act, so it is the one that
|
|
takes a click. Defaulting the other way puts a wall of controls in front of the thing they act on.
|
|
*/
|
|
const [mode, setMode] = useState<Mode>('view');
|
|
const [device, setDevice] = useState<DeviceName>('desktop');
|
|
const [previewable, setPreviewable] = useState(false);
|
|
|
|
// Stable, so the effect in `usePreviewable` is not re-run by this provider re-rendering.
|
|
const announcePreviewable = useCallback((has: boolean) => setPreviewable(has), []);
|
|
|
|
return (
|
|
<ViewModeContext.Provider value={{ mode, device, setMode, setDevice, previewable, announcePreviewable }}>
|
|
{children}
|
|
</ViewModeContext.Provider>
|
|
);
|
|
};
|
|
|
|
/** Read by a section: how it is being looked at. */
|
|
export const useViewMode = (): ViewModeValue => useContext(ViewModeContext);
|
|
|
|
/**
|
|
* Called by a section that HAS a preview, so the bar offers the controls.
|
|
*
|
|
* The cleanup clears it, so a section that unmounts leaves no controls behind for a preview that is no
|
|
* longer on screen.
|
|
*/
|
|
export const usePreviewable = (): void => {
|
|
const { announcePreviewable } = useContext(ViewModeContext);
|
|
useEffect(() => {
|
|
announcePreviewable(true);
|
|
return () => announcePreviewable(false);
|
|
}, [announcePreviewable]);
|
|
};
|