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');