Edit/View on the left, three widths in the middle
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
|
||||
|
||||
/*
|
||||
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 (
|
||||
<div className="flex size-full flex-col p-4">
|
||||
<LayoutPreview port={devPort} fill />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
|
||||
{saved && <p className="text-xs text-success">Saved</p>}
|
||||
@@ -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<string | null>(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<HTMLDivElement | null>(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 (
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-white">
|
||||
{message ? (
|
||||
<p className="p-6 text-center text-xs text-muted-foreground">{message}</p>
|
||||
) : (
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
<iframe
|
||||
src={`/api/dev-proxy/${port}/layout`}
|
||||
title="The site's layout"
|
||||
className="h-[420px] w-full border-0"
|
||||
/>
|
||||
)}
|
||||
<section className={`flex flex-col gap-2 ${fill ? 'min-h-0 flex-1' : ''}`}>
|
||||
<div ref={box} className={`min-h-0 ${fill ? 'flex-1' : ''}`}>
|
||||
<div
|
||||
className="mx-auto overflow-hidden rounded-lg border border-border bg-white"
|
||||
// The scaled footprint, so the border hugs the frame and the page below does not jump when
|
||||
// the device changes.
|
||||
style={{ width: Math.min(width * scale, available.width || width), height: shownHeight }}
|
||||
>
|
||||
{message ? (
|
||||
<p className="p-6 text-center text-xs text-muted-foreground">{message}</p>
|
||||
) : (
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
<iframe
|
||||
src={`/api/dev-proxy/${port}/layout`}
|
||||
title="The site's layout"
|
||||
className="border-0"
|
||||
style={{
|
||||
width,
|
||||
height: shownHeight / scale,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">The development server, live. Save to see a change here.</p>
|
||||
|
||||
{!fill && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
The development server, live at {width}px. Save to see a change here.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user