the preview is 1:1, and the measure is visible

TWO CHANGES, and the first is an argument I got wrong.

The frame was scaled to fit the panel with a transform. That made the preview change apparent size as
the panel was dragged, so the same page looked different at every panel width and nothing could be
judged against anything. A preview is 1:1 or it is not a preview.

So it stays at the device's real width and OVERFLOWS when there is not room — a scrollbar rather than a
lie. The width was never negotiable anyway: the site's media queries answer it, so a frame given the
panel's width renders the tablet layout and calls it desktop. What changes is only what happens when it
does not fit, and scrolling is the honest answer.

THE MEASURE, as two fixed lines and a width in pixels.

The page is held to max-w-7xl and centred, so on a wide screen the content stops well short of the
window and nothing says where. That is invisible until something is placed against the edge and turns
out to be in the wrong place — which is the moment it becomes expensive.

Fixed rather than absolute, so they stay while the page scrolls; a guide that scrolls away is a guide
for the top of the page only. Not drawn at all when the column IS the full width, because two lines
against the window edges are noise claiming to be information.

One more site marker: data-content-column. Four attributes now, still no behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 04:23:14 +01:00
co-authored by Claude Opus 5
parent 66812587fd
commit 01fd0807c6
2 changed files with 95 additions and 26 deletions
+15 -22
View File
@@ -108,14 +108,17 @@ export const SitePreview = ({
const [failed, setFailed] = useState<string | null>(null);
/*
How much room there is, so the frame can be SCALED rather than squeezed.
How much room there is. The frame is always the real device width and is NEVER scaled to fit.
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.
It used to shrink with a transform, and that was worse than it sounds: the preview changed apparent
size as the panel was dragged, so the same page looked different at every panel width and nothing
could be judged against anything. A preview that is 1:1 or nothing is a preview you can trust.
Measured rather than assumed because the panel is resizable and the workspace splits it.
So it stays 390, 834 or 1440 and OVERFLOWS when there is not room, which is a scrollbar rather than
a lie. The width was never negotiable anyway: the site's media queries answer it, so a frame given
the panel's width would render the tablet layout and call it desktop.
Still measured, because the height is whatever the panel has.
*/
const box = useRef<HTMLDivElement | null>(null);
const [available, setAvailable] = useState({ width: 0, height: 0 });
@@ -218,12 +221,6 @@ export const SitePreview = ({
: !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;
/*
All the room there is. WIDTH is the device; height is just how much of the page you can see.
@@ -242,10 +239,10 @@ export const SitePreview = ({
<section className="flex min-h-0 flex-1 flex-col gap-2">
<div ref={box} className="min-h-0 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 }}
// `overflow-auto`, not hidden: at Desktop in a narrow panel the frame is wider than the room,
// and scrolling to the rest of it is the honest answer where scaling was not.
className="mx-auto overflow-auto rounded-lg border border-border bg-white"
style={{ width: Math.min(width, available.width || width), height: shownHeight }}
>
{message ? (
<p className="p-6 text-center text-xs text-muted-foreground">{message}</p>
@@ -270,12 +267,8 @@ export const SitePreview = ({
src={`/api/dev-proxy/${port}/${path}`}
title={`The site at /${path}`}
className="border-0"
style={{
width,
height: shownHeight / scale,
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
// The device's real width, and the room's real height. No transform: see the note above.
style={{ width, height: shownHeight }}
/>
)}
</div>
+80 -4
View File
@@ -13,9 +13,10 @@
*
* ── What the SITE has to provide, and it is only this ──
*
* data-page-empty the placeholder shown when a page has no sections
* data-section-id which row a band came from
* data-column a column within a band
* data-page-empty the placeholder shown when a page has no sections
* data-section-id which row a band came from
* data-column a column within a band
* data-content-column the wrapper that holds the page to its measure
*
* Three attributes and no behaviour. Markers rather than classes, so they cannot be mistaken for
* styling, cannot be purged by a build that has never heard of this file, and read as what they are:
@@ -73,7 +74,9 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
// Anything a previous injection left. Cheap insurance: the cleanup should have run, and if a reload
// raced it, a second Add section button is exactly the sort of thing nobody reports as a bug.
root.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
// Across the whole document, not just `main`: the measure guides are appended to body so they can be
// `fixed` without a positioned ancestor interfering.
document.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
/** Everything to undo, in the order it was done. */
const undo: (() => void)[] = [];
@@ -187,6 +190,79 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
});
}
/*
The measure, drawn as two lines.
The page is held to `max-w-7xl` and centred, so on a wide screen the content stops well short of the
window and there is nothing to say where. That is invisible until something is placed against it and
turns out to be in the wrong place, which is the moment it is expensive.
`position:fixed` inside the frame, so they stay put while the page scrolls — a guide that scrolls
away is a guide for the top of the page only. Redrawn on resize, because the frame's width changes
when the device does.
*/
const column = root.querySelector<HTMLElement>('[data-content-column]');
if (column) {
const guides: HTMLElement[] = [];
const place = () => {
for (const guide of guides) guide.remove();
guides.length = 0;
const box = column.getBoundingClientRect();
// Below the frame's own width there is no measure to show — the column simply IS the page, and
// two lines against the edges would be noise claiming to be information.
if (box.width >= (document.documentElement.clientWidth || 0) - 1) return;
for (const [side, x] of [
["left", box.left],
["right", box.right],
] as const) {
const guide = document.createElement("div");
guide.setAttribute(MARK, `measure-${side}`);
style(guide, [
"position:fixed",
"top:0",
"bottom:0",
`left:${Math.round(x)}px`,
"width:1px",
`background:color-mix(in srgb, ${ACCENT} 35%, transparent)`,
// Behind nothing and in front of nothing that matters: it must not swallow a click meant for
// the page underneath it.
"pointer-events:none",
"z-index:2147483000",
]);
document.body.append(guide);
guides.push(guide);
}
const label = document.createElement("div");
label.setAttribute(MARK, "measure-label");
label.textContent = `${Math.round(box.width)}px`;
style(label, [
"position:fixed",
"top:0.25rem",
`left:${Math.round(box.left + 4)}px`,
"font-size:0.625rem",
"font-family:ui-sans-serif, system-ui, sans-serif",
`color:${ACCENT}`,
"opacity:0.8",
"pointer-events:none",
"z-index:2147483000",
]);
document.body.append(label);
guides.push(label);
};
place();
const frameWindow = document.defaultView;
frameWindow?.addEventListener("resize", place);
undo.push(() => {
frameWindow?.removeEventListener("resize", place);
for (const guide of guides) guide.remove();
});
}
// ── Add section, always last ─────────────────────────────────────────────────────────────────
const zone = document.createElement('div');
zone.setAttribute(MARK, 'add-section');