Layout shows the measure too

The content column's edges were drawn only by the page builder, and they answer a question the Layout
section has just as much: 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 marks where.

`paintMeasure` is its own function now, called by both. The builder also draws sections, columns and a
drop zone; none of those belong in Layout, whose bands are code and cannot be rearranged from there.
What the two share is exactly the measure and nothing else.

Nothing in View, for the same reason as the builder: chrome of any kind means you are not looking at
the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 04:56:26 +01:00
co-authored by Claude Opus 5
parent 5b1c5a2590
commit dc45fb7f11
2 changed files with 104 additions and 73 deletions
+87 -72
View File
@@ -299,78 +299,8 @@ 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();
});
}
// The measure. Drawn for anything that shows a page — see paintMeasure.
undo.push(paintMeasure(document));
// ── Add section, always last ─────────────────────────────────────────────────────────────────
/*
@@ -427,3 +357,88 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
for (const step of undo.reverse()) step();
};
}
/**
* The content column's edges, as two lines and a width.
*
* ── Why this is on its own ──
*
* Every preview wants it and only one of them wants the rest. The page builder draws sections, columns
* and a drop zone; the Layout section draws none of those — its bands are code and nothing there can be
* rearranged — but it has exactly the same question about where the measure ends.
*
* ── Why it needs saying at all ──
*
* 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 marks 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.
*
* Returns a cleanup, like everything else injected here.
*/
export function paintMeasure(document: Document): () => void {
const root = document.querySelector<HTMLElement>('main');
const column = root?.querySelector<HTMLElement>('[data-content-column]');
if (!column) return () => {};
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, [
// `fixed`, so they stay while the page scrolls. A guide that scrolls away is a guide for the
// top of the page only.
'position:fixed',
'top:0',
'bottom:0',
`left:${Math.round(x)}px`,
'width:1px',
`background:color-mix(in srgb, ${ACCENT} 35%, transparent)`,
// 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();
// The frame's width changes when the device does, and a remount is not the only way that happens.
const frameWindow = document.defaultView;
frameWindow?.addEventListener('resize', place);
return () => {
frameWindow?.removeEventListener('resize', place);
for (const guide of guides) guide.remove();
};
}
+17 -1
View File
@@ -4,6 +4,7 @@ import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { useSaveTarget } from '../SaveTarget';
import { usePreviewable, useViewMode } from '../ViewMode';
import { paintMeasure } from '../canvas';
import { SitePreview, useDevPort } from '../SitePreview';
/**
@@ -175,6 +176,21 @@ export const LayoutPanel = () => {
usePreviewable();
const { mode } = useViewMode();
/*
The measure, and nothing else.
The page builder also draws sections, columns and a drop zone. None of those belong here: the
layout's bands are code and nothing in this panel can rearrange them. What it shares is the
question of where the content column ends, which is just as invisible on this page as on any other.
Nothing in View, for the same reason as the builder — chrome of any kind means you are not looking
at the page.
*/
const onDocument = useCallback(
(frameDocument: Document) => (mode === 'view' ? undefined : paintMeasure(frameDocument)),
[mode],
);
if (!draft) return <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
/*
@@ -202,7 +218,7 @@ export const LayoutPanel = () => {
that sits in the content column. Worse, it was a second implementation of the layout, so it
would have gone on being wrong differently every time either side changed.
*/}
<SitePreview port={devPort} path="layout" theme={themeProperties(draft)} />
<SitePreview port={devPort} path="layout" theme={themeProperties(draft)} onDocument={onDocument} />
{/*
The controls, and only in Edit.