?mode=edit docs/navigation-audit.md draws the line: anything ADDRESSABLE goes in the URL, and whether you are editing a page or looking at it is exactly that. It survives a reload, it can be sent to somebody, and a link to a page being edited is a different thing from a link to the page. `mode`, not `view`. The parameter names the AXIS and the value names the state, so the pair reads `mode=edit` and `mode=view` rather than the tautological `view=view`. Absent means View — the default and the common case — so an ordinary link carries nothing and the parameter appears once somebody has chosen. The DEVICE deliberately stays in React state. It is a way of looking rather than a thing being looked at, and nobody has wanted to send one. Worth keeping that distinction deliberate rather than letting both drift into whichever place was convenient. COLOURS AND TYPEFACES ARE TABS. They were two stacked cards taking as much height as both together, and every pixel of it came off the preview — which is the thing being judged. One at a time costs a click and gives the page back most of the panel. The 55% cap on the controls is gone with them: one tab is short enough not to need capping, which is the other thing this bought. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
136 lines
5.8 KiB
TypeScript
136 lines
5.8 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.
|
|
*
|
|
* ── Where each piece of state lives ──
|
|
*
|
|
* The MODE is in the URL: it is addressable, and a link to a page being edited is a different thing
|
|
* from a link to the page. The DEVICE is not, yet — it is a way of looking rather than a thing being
|
|
* looked at, and nobody has wanted to send one. That distinction is worth keeping deliberate rather
|
|
* than letting both drift into whichever place was convenient.
|
|
*/
|
|
|
|
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 }) => {
|
|
const [params, setParams] = useSearchParams();
|
|
|
|
/*
|
|
The mode lives in the URL — `?mode=edit`.
|
|
|
|
docs/navigation-audit.md draws the line: anything ADDRESSABLE goes in the URL. Whether you are
|
|
editing a page or looking at it is exactly that. It survives a reload, it can be sent to somebody,
|
|
and a link to a page being edited is a different thing from a link to the page.
|
|
|
|
`mode`, not `view`. The parameter names the AXIS and the value names the state, so the pair reads
|
|
as `mode=edit` and `mode=view` rather than the tautological `view=view`.
|
|
|
|
Absent means View, which is the default and the common case — so an ordinary link carries nothing,
|
|
and the parameter only appears once somebody has chosen.
|
|
*/
|
|
const mode: Mode = params.get('mode') === 'edit' ? 'edit' : 'view';
|
|
|
|
const setMode = useCallback(
|
|
(next: Mode) => {
|
|
const search = new URLSearchParams(params);
|
|
// Written even for the default, once somebody has touched it: a URL that says `mode=view` is
|
|
// unambiguous, and a toggle that sometimes leaves no trace reads as a toggle that failed.
|
|
search.set('mode', next);
|
|
setParams(search, { replace: true });
|
|
},
|
|
[params, setParams],
|
|
);
|
|
|
|
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]);
|
|
};
|