From 01fd0807c617a30d2b672c3e17da8dec4fb2f5f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 04:23:14 +0100 Subject: [PATCH] the preview is 1:1, and the measure is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/SitePreview.tsx | 37 ++++++++------------ web/canvas.ts | 84 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/web/SitePreview.tsx b/web/SitePreview.tsx index c402b3c..744b809 100644 --- a/web/SitePreview.tsx +++ b/web/SitePreview.tsx @@ -108,14 +108,17 @@ export const SitePreview = ({ const [failed, setFailed] = useState(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(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 = ({
{message ? (

{message}

@@ -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 }} /> )}
diff --git a/web/canvas.ts b/web/canvas.ts index bfc541c..e9a6860 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -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('[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');