Two things, both asked for after looking at it. The tint came out. Shading the margins was meant to say "the page does not use this", and against a white page it read as a blue background — a colour the site does not have, on the screen that exists to show the site's colours. The two hairlines say the same thing without claiming anything. And Desktop is fluid. `width: null` means the frame takes whatever the panel gives it, which is the honest answer for a device that has no width: a desktop is whatever window somebody opened. 1440 was a guess that then had to be centred and scrolled inside the panel — a page rendered at one width and looked at inside another, with dark room either side of it. Tablet and mobile stay pinned, because 834 and 390 are claims about real devices and pinning them is the only way the preview can answer "what does this look like on a phone". Fluid, the frame IS a desktop window: the site's media queries answer the panel's real width, and the measure guides mark where the content stops in the pixels you are actually looking at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
150 lines
6.6 KiB
TypeScript
150 lines
6.6 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
import { useSearchParams } from 'react-router';
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* ── Both live in the URL ──
|
|
*
|
|
* `?mode=edit&device=mobile`. I had argued the device should stay in React state — a way of LOOKING
|
|
* rather than a thing being looked at — and that distinction did not survive contact with the obvious
|
|
* use: "here is what it does on a phone" is exactly the link somebody wants to send, and a URL that
|
|
* drops the width sends them the desktop.
|
|
*
|
|
* docs/navigation-audit.md says addressable state goes in the URL, and both of these are addressable.
|
|
* The rule was already right; the exception was not.
|
|
*/
|
|
|
|
/*
|
|
There is no Mode any more.
|
|
|
|
The bar offered Edit and View, and View existed to see the page without the builder's outlines and
|
|
handles on top of it. It was the default, and it turned out to be the wrong default and then the
|
|
wrong feature: this panel is where somebody EDITS, and a second mode meant every arrival had a click
|
|
in front of it and every guard in the panels had a branch that did nothing but wait for that click.
|
|
|
|
The clean page is still one click away — it is the site, on its own URL. What the admin owes is the
|
|
editing surface, always.
|
|
*/
|
|
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.
|
|
*/
|
|
/**
|
|
* `width: null` means FLUID — the frame takes whatever the panel gives it.
|
|
*
|
|
* Desktop is fluid and the other two are not, which is the honest split. Tablet and mobile are claims
|
|
* about a real device: 834 and 390 are widths a page will actually be asked to render at, and pinning
|
|
* them is the only way the preview can answer "what does this look like on a phone".
|
|
*
|
|
* There is no such width for desktop. A desktop is whatever window somebody opened, so 1440 was a
|
|
* guess that then had to be scaled to fit the panel — a page rendered at one width and shown at
|
|
* another, with the measure guides landing wherever the scale put them. Fluid, the frame IS a desktop
|
|
* window: the site's media queries answer the panel's real width and the guides mark the measure in
|
|
* the pixels you are looking at.
|
|
*/
|
|
export const DEVICES: Record<DeviceName, { label: string; width: number | null }> = {
|
|
desktop: { label: 'Desktop', width: null },
|
|
tablet: { label: 'Tablet', width: 834 },
|
|
mobile: { label: 'Mobile', width: 390 },
|
|
};
|
|
|
|
type ViewModeValue = {
|
|
device: DeviceName;
|
|
|
|
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>({
|
|
device: 'desktop',
|
|
|
|
setDevice: () => {},
|
|
previewable: false,
|
|
announcePreviewable: () => {},
|
|
});
|
|
|
|
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
|
|
const [params, setParams] = useSearchParams();
|
|
|
|
/*
|
|
The device lives in the URL, for the reason the whole file gives: it is addressable.
|
|
|
|
Absent means desktop — the default and the common case — so an ordinary link carries neither
|
|
parameter and each appears only once somebody has chosen it.
|
|
*/
|
|
const raw = params.get('device');
|
|
const device: DeviceName = raw === 'tablet' || raw === 'mobile' ? raw : 'desktop';
|
|
|
|
const setDevice = useCallback(
|
|
(next: DeviceName) => {
|
|
const search = new URLSearchParams(params);
|
|
search.set('device', next);
|
|
setParams(search, { replace: true });
|
|
},
|
|
[params, setParams],
|
|
);
|
|
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={{ device, 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]);
|
|
};
|