diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx
index 41088b4..cadfcf8 100644
--- a/web/DetailPanel.tsx
+++ b/web/DetailPanel.tsx
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router';
-import { Check, Loader2 } from 'lucide-react';
+import { Check, Eye, Loader2, Monitor, Pencil, Smartphone, Tablet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DEFAULT_SECTION, isSectionKey, sectionFor, sectionOpen, type SectionKey } from './sections';
import { AdministrationPanel } from './AdministrationPanel';
@@ -8,6 +8,7 @@ import { GalleriesPanel } from './sections/GalleriesPanel';
import { LayoutPanel } from './sections/LayoutPanel';
import { PublishButton } from './PublishButton';
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
+import { DEVICES, ViewModeProvider, useViewMode, type DeviceName, type Mode } from './ViewMode';
import { MessagesPanel } from './MessagesPanel';
import { PlannedSection } from './PlannedSection';
import { useReady } from './useReady';
@@ -79,6 +80,72 @@ const SaveButton = () => {
);
};
+/**
+ * Editing, or just looking.
+ *
+ * Two buttons rather than one that toggles, so the current state is readable without knowing what the
+ * label means. A single button saying "View" has to be interpreted — is that what it is, or what it
+ * would become? — and that question is asked every time.
+ */
+const ModeToggle = () => {
+ const { previewable, mode, setMode } = useViewMode();
+ if (!previewable) return null;
+
+ const options: { value: Mode; label: string; Icon: typeof Pencil }[] = [
+ { value: 'edit', label: 'Edit', Icon: Pencil },
+ { value: 'view', label: 'View', Icon: Eye },
+ ];
+
+ return (
+
+ );
+};
+
+/** The width the preview is rendered at. See DEVICES for why these three and not others. */
+const DeviceToggle = () => {
+ const { previewable, device, setDevice } = useViewMode();
+ if (!previewable) return null;
+
+ const options: { value: DeviceName; Icon: typeof Monitor }[] = [
+ { value: 'desktop', Icon: Monitor },
+ { value: 'tablet', Icon: Tablet },
+ { value: 'mobile', Icon: Smartphone },
+ ];
+
+ return (
+
+ {options.map(({ value, Icon }) => (
+
+ ))}
+
+ );
+};
+
export const DetailPanel = () => {
const [params] = useSearchParams();
const raw = params.get('section');
@@ -120,14 +187,33 @@ export const DetailPanel = () => {
plugin and to the site it administers.
*/}
-
- {/* Left of Publish, and in that order for a reason: save what you wrote, then make it
- public. A row that put them the other way round would read as the sequence backwards. */}
-
-
-
+
+ {/*
+ Three groups: how you are looking at it, at what width, and what to do with it.
-
{Panel ? : }
+ The middle one is centred against the BAR rather than against what is beside it — the two
+ outer groups change width as sections come and go, and a middle that drifted with them
+ would never be in the same place twice.
+ */}
+
+
+
+
+
+
+
+
+
+
+ {/* Left of Publish, and in that order for a reason: save what you wrote, then make it
+ public. A row that put them the other way round would read as the sequence backwards. */}
+
+
+
+
+
+
{Panel ? : }
+
);
diff --git a/web/ViewMode.tsx b/web/ViewMode.tsx
new file mode 100644
index 0000000..61cadac
--- /dev/null
+++ b/web/ViewMode.tsx
@@ -0,0 +1,96 @@
+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.
+ *
+ * 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 = {
+ desktop: { label: 'Desktop', width: 1440, height: 900 },
+ tablet: { label: 'Tablet', width: 834, height: 1112 },
+ mobile: { label: 'Mobile', width: 390, height: 844 },
+};
+
+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({
+ mode: 'edit',
+ device: 'desktop',
+ setMode: () => {},
+ setDevice: () => {},
+ previewable: false,
+ announcePreviewable: () => {},
+});
+
+export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
+ const [mode, setMode] = useState('edit');
+ const [device, setDevice] = useState('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 (
+
+ {children}
+
+ );
+};
+
+/** 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]);
+};
diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx
index 8968de3..88cbf99 100644
--- a/web/sections/LayoutPanel.tsx
+++ b/web/sections/LayoutPanel.tsx
@@ -3,6 +3,7 @@ import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { useSaveTarget } from '../SaveTarget';
+import { DEVICES, usePreviewable, useViewMode } from '../ViewMode';
/**
* The shell every page renders inside: the menu, the footer, the colours and the typefaces.
@@ -141,9 +142,27 @@ export const LayoutPanel = () => {
};
useSaveTarget(dirty, busy, save);
+ // This section has a preview, so the bar offers Edit/View and the three widths.
+ usePreviewable();
+ const { mode } = useViewMode();
if (!draft) return
{error ?? 'Loading…'}
;
+ /*
+ Viewing: the preview and nothing else, filling the panel.
+
+ Not the same thing as hiding the controls. The preview is given the whole height, which is what
+ makes View worth having — at edit height a phone is a letterbox, and a phone is the width somebody
+ most wants to look at properly.
+ */
+ if (mode === 'view') {
+ return (
+
+
+
+ );
+ }
+
return (
{saved &&
Saved
}
@@ -244,7 +263,7 @@ export const LayoutPanel = () => {
* was the other option and it is a guess about where the browser kept it; the platform tried that and
* says so in its own comments.
*/
-const LayoutPreview = ({ port }: { port: number | null }) => {
+const LayoutPreview = ({ port, fill }: { port: number | null; fill?: boolean }) => {
const client = useClient();
/*
Through a ref, and this is the whole reason the preview hung.
@@ -260,9 +279,35 @@ const LayoutPreview = ({ port }: { port: number | null }) => {
const clientRef = useRef(client);
clientRef.current = client;
+ const { device } = useViewMode();
+ const { width, height } = DEVICES[device];
+
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(null);
+ /*
+ How much room there is, so the frame can be SCALED rather than squeezed.
+
+ The iframe is always the real device width — 390, 834, 1440 — and shrunk to fit with a transform.
+ Giving it the panel's width instead would mean the site's own media queries answered the panel's
+ size, so "Desktop" in a narrow panel would render the tablet layout and label it desktop. That is
+ the one thing a preview must not do.
+
+ Measured rather than assumed because the panel is resizable and the workspace splits it.
+ */
+ const box = useRef(null);
+ const [available, setAvailable] = useState({ width: 0, height: 0 });
+
+ useEffect(() => {
+ const element = box.current;
+ if (!element) return;
+ const observer = new ResizeObserver(([entry]) => {
+ if (entry) setAvailable({ width: entry.contentRect.width, height: entry.contentRect.height });
+ });
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
useEffect(() => {
if (!port) return;
let alive = true;
@@ -283,26 +328,55 @@ const LayoutPreview = ({ port }: { port: number | null }) => {
: !ready ? 'Connecting…'
: null;
+ /*
+ Never scaled UP. A 390px phone blown up to fill a wide panel is a phone nobody has, and every
+ judgement made against it — how big that headline is, how much fits above the fold — would be wrong
+ in the direction of "looks fine".
+ */
+ const scale = available.width ? Math.min(1, available.width / width) : 1;
+
+ // In View the frame gets the panel's height; editing, a fixed band, so the controls below stay put
+ // instead of moving every time somebody switches device.
+ const shownHeight = fill ? Math.max(0, available.height) : Math.min(height * scale, 420);
+
return (
-
-
- {message ? (
-
{message}
- ) : (
- /*
- No `sandbox`, for the reason the platform's own Preview gives: a sandbox WITHOUT
- allow-same-origin has an opaque origin and no storage, and one WITH allow-same-origin and
- allow-scripts is worth nothing, because the frame can reach up and remove the attribute.
- This is the owner's own development server on this machine.
- */
-
- )}
+
+
+
+ {message ? (
+
{message}
+ ) : (
+ /*
+ No `sandbox`, for the reason the platform's own Preview gives: a sandbox WITHOUT
+ allow-same-origin has an opaque origin and therefore no storage, and one WITH
+ allow-same-origin and allow-scripts is worth nothing, because the frame can reach up and
+ remove the attribute. This is the owner's own development server on this machine.
+ */
+
+ )}
+
-
The development server, live. Save to see a change here.
+
+ {!fill && (
+
+ The development server, live at {width}px. Save to see a change here.
+