From 143b34d2d29c55b150cd29f13968d5885a43220e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 02:26:06 +0100 Subject: [PATCH] Edit/View on the left, three widths in the middle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar is three groups now: how you are looking at it, at what width, and what to do with it. The middle is centred against the BAR rather than against its neighbours, because the outer two change width as sections come and go and a middle that drifted with them would never be in the same place twice. A section OPTS IN, the same way it opts into Save. Most have no preview — Media is a file browser, Administration is a connection form, Messages is an inbox — and offering to view any of them at tablet width offers something that means nothing. A control that does nothing teaches people that controls do nothing. The widths are REAL viewport sizes and the frame is SCALED to fit, never squeezed into whatever the panel has. That distinction is the whole point: an iframe given the panel's width has the site's own media queries answering the PANEL's size, so "Desktop" in a narrow panel renders the tablet layout and labels it desktop — the one thing a preview must not do. Never scaled up either: a 390px phone blown up to fill a wide panel is a phone nobody has, and every judgement made against it is wrong in the direction of "looks fine". 390 is the iPhone width slots.ts already assumes. 834 is iPad portrait, deliberately above the site's md: breakpoint at 768 so it shows the desktop nav rather than the drawer — 768 exactly sits on the boundary and is the least informative width available. 1440 is past the 1280 the content column caps at, so the margins either side are visible rather than implied. View gives the preview the whole panel. That is what makes it worth having: at edit height a phone is a letterbox, and a phone is the width somebody most wants to look at properly. Co-Authored-By: Claude Opus 5 (1M context) --- web/DetailPanel.tsx | 102 ++++++++++++++++++++++++++++--- web/ViewMode.tsx | 96 ++++++++++++++++++++++++++++++ web/sections/LayoutPanel.tsx | 112 +++++++++++++++++++++++++++++------ 3 files changed, 283 insertions(+), 27 deletions(-) create mode 100644 web/ViewMode.tsx 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 ( +
+ {options.map(({ value, label, Icon }) => ( + + ))} +
+ ); +}; + +/** 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. - */ -