the page builder draws nothing on the page

A bar on every band, Edit / Spacing / Delete above every block, dotted outlines
around blocks and columns, an add-zone at the end. Look at that and you are
looking at the editor, not the page — on the screen whose whole purpose is
judging the page.

A click selects a block and its editor opens in the panel beside the preview,
where every other control now lives. data-fofos-component stays: it is how the
panel writes a draft into the page as somebody types.

Adding a section, adding a block, deleting either and opening a spacing editor
have no door any more. The handlers are untouched and nothing calls them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 09:40:34 +01:00
co-authored by Claude Opus 5
parent dfc22d2d2c
commit 2eb9142484
2 changed files with 51 additions and 346 deletions
+50 -345
View File
@@ -112,369 +112,74 @@ const style = (element: HTMLElement, rules: string[]) => {
* Returns a cleanup. It runs before the next injection and on unmount — see `onDocument` in
* SitePreview — so a reload never leaves two sets of controls behind.
*/
export function paintCanvas(document: Document, canvas: Canvas): () => void {
const root = contentRoot(document);
if (!root) return () => {};
// 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.
// 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. */
/**
* Selection, and NOTHING drawn.
*
* ── What this replaced ──
*
* `paintCanvas` put a bar on every band with its name, its layout and a Delete; a row of Edit /
* Spacing / Delete above every block; a dotted outline around every block and column; and an add-zone
* at the end of the page. Look at that and you are looking at the editor, not the page — on a screen
* whose whole purpose is judging the page. Each was also a decision about type, spacing and colour
* that the site itself had not made.
*
* So this draws nothing at all: no elements, no stylesheet, no outlines. A click selects a block, and
* its editor opens in the panel beside the preview — where every other control now lives.
*
* ── The marker stays ──
*
* `data-fofos-component` is how the panel finds the block being edited so it can write a draft into it
* as somebody types. That is not chrome; it is the same kind of marker as `data-gutter`.
*
* ── What has no door any more ──
*
* Adding a section, adding a block, deleting either, and opening a spacing editor were all reached
* from the bars this removes. Their handlers still exist and still work; nothing calls them. They want
* a home in the editor column rather than painted onto the page.
*/
export function paintSelection(document: Document, canvas: Canvas): () => void {
const undo: (() => void)[] = [];
/*
A stylesheet, for the one thing inline styles cannot express: `:hover`.
The component tools sat permanently at the top-right of whatever they belonged to, on top of the
text. On a paragraph that fills its column there is nowhere for them to go that is not on top of
something — so they are hidden until the component is hovered, which is when they are wanted and
the only time they are.
The outline goes the same way. An editor should show the page, with the handles arriving under the
cursor; an editor that outlines everything all the time is showing you the editor.
*/
const sheet = document.createElement('style');
sheet.setAttribute(MARK, 'stylesheet');
sheet.textContent = `
/*
EVERYTHING belonging to a section appears when that section is hovered.
Its outline, its name, its shape, its Delete, the outlines of its columns and the button that adds
to them. A page with four bands was four labelled boxes at rest, and the page underneath was the
one thing you could not see — which is what an editor is for.
Opacity rather than display, so nothing moves when it appears: the controls hold their space
whether or not they are visible, and the page does not reflow under the cursor.
*/
[data-section-id] {
outline: 1px solid transparent;
outline-offset: -1px;
transition: outline-color .12s;
}
[data-section-id]:hover { outline-color: ${ACCENT}; }
[${MARK}="section-bar"] { opacity: 0; transition: opacity .12s; }
[data-section-id]:hover [${MARK}="section-bar"] { opacity: 1; }
/* Keyboard reaches them too: focus inside a band is the same intent as hovering it. */
[data-section-id]:focus-within [${MARK}="section-bar"] { opacity: 1; }
[data-column] {
outline: 1px dashed transparent;
outline-offset: -5px;
transition: outline-color .12s;
}
[data-section-id]:hover [data-column] {
outline-color: color-mix(in srgb, ${ACCENT} 45%, transparent);
}
[${MARK}="add-component"] { opacity: 0; transition: opacity .12s; }
[data-section-id]:hover [${MARK}="add-component"] { opacity: 1; }
[${MARK}="add-component"]:focus { opacity: 1; }
/*
A COMPONENT's own handles need their own hover, not the section's. A band with three paragraphs
would otherwise show three sets of Edit and Delete at once, and none of them would say which.
*/
[data-fofos-component] { outline: 1px dotted transparent; transition: outline-color .12s; }
[data-fofos-component]:hover { outline-color: color-mix(in srgb, ${ACCENT} 55%, transparent); }
[${MARK}="component-tools"] { opacity: 0; transition: opacity .12s; }
[data-fofos-component]:hover > [${MARK}="component-tools"] { opacity: 1; }
[${MARK}="component-tools"]:focus-within { opacity: 1; }
`;
document.head.append(sheet);
undo.push(() => sheet.remove());
/*
The site's empty-page placeholder, hidden while the builder draws its own controls in that space.
Found by its marker rather than by position. Hidden rather than removed, so the cleanup puts it
back — a page whose last section is deleted shows it again without a reload.
*/
const placeholder = root.querySelector<HTMLElement>('[data-page-empty]');
if (placeholder) {
placeholder.style.display = 'none';
undo.push(() => {
placeholder.style.display = '';
});
}
/*
Every section, outlined and named.
This is the whole reason the decoration exists: an EMPTY section renders as nothing, which is
correct on the public site and useless in an editor. Somebody who has just chosen "two columns"
needs to see two columns, not a blank page they have to take on trust.
*/
for (const section of canvas.sections) {
const element = root.querySelector<HTMLElement>(`[data-section-id="${section.id}"]`);
const element = document.querySelector<HTMLElement>(`[data-section-id="${section.id}"]`);
if (!element) continue;
const previousPosition = element.style.position;
// `relative` so the label and the controls can be placed against this band rather than the page.
element.style.position = 'relative';
/*
NO margin. There was `2.5rem 0` here, to make room for the label above each band.
That is 40 pixels above and below every section that the real page does not have, and it made the
preview overstate the spacing of every page in the site — on the one screen whose entire purpose
is to show what the page looks like. It was reported as "there is a lot more space between the
logo and the text in the admin", which is exactly what it was.
A preview may draw ON the page. It may not CHANGE it. Everything this file adds is now either
absolutely positioned or an outline, both of which take no layout space, and the label moved
inside the band it names — see the bar below.
*/
undo.push(() => {
element.style.position = previousPosition;
});
const bar = document.createElement('div');
bar.setAttribute(MARK, 'section-bar');
style(bar, [
'position:absolute',
// INSIDE the band, not above it. Above needed 40px of margin the page does not have; the bar is
// hover-only, so overlaying the first line of a band for as long as the pointer is on it costs
// nothing and lying about the layout costs the preview its whole point.
'top:0',
'left:0',
'right:0',
'display:flex',
'align-items:center',
'gap:0.5rem',
'font-size:0.6875rem',
`color:${ACCENT}`,
// The page's own font would make this look like content. It is not content.
'font-family:ui-sans-serif, system-ui, sans-serif',
]);
const label = document.createElement('span');
label.textContent = section.name;
style(label, ['font-weight:600']);
const shape = document.createElement('span');
shape.textContent = section.layout === 'two' ? 'two columns' : 'one column';
style(shape, ['opacity:0.7']);
/*
Spacing before Delete, and both on the right.
The order is how often they are wanted, not how they read: spacing is an ordinary adjustment and
deleting a band is not, so the destructive one sits furthest from where the hand arrives.
*/
const actions: HTMLElement[] = [];
for (const [text, run, first] of [
['Spacing', () => canvas.onSpaceSection(section), true],
['Delete', () => canvas.onDeleteSection(section), false],
] as const) {
const action = document.createElement('button');
action.type = 'button';
action.textContent = text;
style(action, [
first ? 'margin-left:auto' : '',
'cursor:pointer',
'background:none',
'border:0',
'padding:0',
'font:inherit',
'color:inherit',
'opacity:0.7',
'text-decoration:underline',
]);
action.addEventListener('click', run);
undo.push(() => action.removeEventListener('click', run));
actions.push(action);
}
bar.append(label, shape, ...actions);
element.append(bar);
undo.push(() => bar.remove());
/*
The columns, outlined too.
An empty column has height and nothing else, so a two-column band and a one-column band look
identical until something is in them — which is exactly when it is too late to notice you chose
the wrong one.
*/
element.querySelectorAll<HTMLElement>('[data-column]').forEach((columnElement, columnIndex) => {
/*
Every component in this column gets a handle.
Drawn ON the rendered component rather than replacing it — an editor that swaps content for a
placeholder is showing you the editor, not the page. Free text with nothing written in it
renders as nothing, so the outline is the only thing saying it is there at all.
*/
const mine = section.components.filter((component) => component.column === columnIndex);
for (const component of mine) {
// By position in the column, which is the order the API returns them in. The rendered node
// carries no id of its own.
const node = columnElement.children[mine.indexOf(component)] as HTMLElement | undefined;
if (!node) continue;
const wasPosition = node.style.position;
node.style.position = 'relative';
// The outline and the tools' visibility are the stylesheet's, so they can depend on :hover.
// The ID, not an empty marker: the panel writes a draft into this node as somebody types, and
// has to be able to find the one being edited.
node.setAttribute('data-fofos-component', String(component.id));
undo.push(() => node.removeAttribute('data-fofos-component'));
// A block the panel has no editor for is left alone: a click that opens nothing reads as a
// failure, which is worse than a click that does nothing. See canEditComponent.
if (canvas.canEditComponent && !canvas.canEditComponent(component)) continue;
const open = (event: MouseEvent) => {
// The page is real, so a block can hold a link and following it would leave the editor.
event.preventDefault();
event.stopPropagation();
canvas.onEditComponent(component);
};
node.addEventListener('click', open);
const wasCursor = node.style.cursor;
node.style.cursor = 'pointer';
undo.push(() => {
node.style.position = wasPosition;
node.removeAttribute('data-fofos-component');
});
const tools = document.createElement('div');
tools.setAttribute(MARK, 'component-tools');
style(tools, [
'position:absolute',
// Above its own top-right corner rather than inside it, so even while visible it sits in the
// gap between components instead of over the first line of this one.
'top:-1.25rem',
'right:0',
'display:flex',
'gap:0.5rem',
'padding:0 0.25rem',
'font-size:0.625rem',
'font-family:ui-sans-serif, system-ui, sans-serif',
`color:${ACCENT}`,
'background:var(--site-bg, #fff)',
// Above the page, below the section bar, and never a target when invisible.
'z-index:5',
]);
const actions: [string, () => void][] = [
['Spacing', () => canvas.onSpaceComponent(component)],
['Delete', () => canvas.onDeleteComponent(component)],
];
// First, so the tools read Edit / Spacing / Delete — the order they were in when Edit was
// unconditional, rather than the order the array happens to be built in.
if (canvas.canEditComponent?.(component) ?? true) {
actions.unshift(['Edit', () => canvas.onEditComponent(component)]);
}
for (const [label, run] of actions) {
const action = document.createElement('button');
action.type = 'button';
action.textContent = label;
style(action, [
'cursor:pointer',
'background:none',
'border:0',
'padding:0',
'font:inherit',
'color:inherit',
'text-decoration:underline',
]);
action.addEventListener('click', run);
undo.push(() => action.removeEventListener('click', run));
tools.append(action);
}
node.append(tools);
undo.push(() => tools.remove());
}
/*
And a way to put something in it.
One per column, always at the bottom, so a column that already has three paragraphs still has
somewhere obvious to add a fourth. Quieter than Add section — this is the inner loop, and a
button as loud as the one that adds a whole band would make every column shout.
*/
const add = document.createElement('button');
add.setAttribute(MARK, 'add-component');
add.type = 'button';
add.textContent = '+ Add component';
style(add, [
'display:block',
'width:100%',
'margin-top:0.75rem',
'padding:0.5rem',
'cursor:pointer',
'background:none',
`border:1px dashed color-mix(in srgb, ${ACCENT} 40%, transparent)`,
'border-radius:0.375rem',
`color:${ACCENT}`,
'font-family:ui-sans-serif, system-ui, sans-serif',
'font-size:0.75rem',
]);
const onAdd = () => canvas.onAddComponent(section, columnIndex);
add.addEventListener('click', onAdd);
columnElement.append(add);
undo.push(() => {
add.removeEventListener('click', onAdd);
add.remove();
});
node.removeEventListener('click', open);
node.style.cursor = wasCursor;
});
}
// The measure. Drawn for anything that shows a page — see paintMeasure.
undo.push(paintMeasure(document));
// ── Add section, always last ─────────────────────────────────────────────────────────────────
/*
Add section goes at the END OF THE PAGE, held to the measure by hand.
It used to be appended to the content column, which was one element wrapping every section. It is
not one element any more: a built page is full-bleed so each band can draw a separator the width of
the window, and each holds its OWN content to the measure. Appending to the first thing matching
`[data-content-column]` would now put "add a section" inside section one.
So it goes in `main`, and the width comes from a column rather than from a constant — the measure is
Tailwind's `max-w-7xl` and this file has no business knowing that. With no section on the page there
is no column to ask, and full width is right anyway: there is nothing to line up with.
*/
const zoneParent = root;
const measure = root.querySelector<HTMLElement>('[data-content-column]')?.getBoundingClientRect().width;
const zone = document.createElement('div');
zone.setAttribute(MARK, 'add-section');
style(zone, [
'display:flex',
'align-items:center',
'justify-content:center',
// Tall when the page is empty, modest once there is something to add to — the zone should not
// dwarf the content it sits under.
canvas.sections.length ? 'min-height:8rem' : 'min-height:40vh',
'margin:2.5rem 0',
'border:2px dashed color-mix(in srgb, currentColor 25%, transparent)',
'border-radius:0.75rem',
]);
const button = document.createElement('button');
button.type = 'button';
button.textContent = '+ Add section';
style(button, [
'cursor:pointer',
'padding:0.75rem 1.5rem',
'border-radius:0.5rem',
// The page's own colours, inverted, so the control belongs to whatever theme the site is wearing.
'background:var(--site-fg, #000)',
'color:var(--site-bg, #fff)',
'border:0',
'font:inherit',
'font-size:0.875rem',
]);
button.addEventListener('click', canvas.onAddSection);
zone.append(button);
// Aligned with the content rather than with the window, so the zone describes a place a section can
// actually go. `auto` margins do nothing until there is a width to centre.
if (measure) {
zone.style.maxWidth = `${Math.round(measure)}px`;
zone.style.marginInline = 'auto';
}
zoneParent.append(zone);
undo.push(() => {
button.removeEventListener('click', canvas.onAddSection);
zone.remove();
});
}
return () => {
// Reversed, so a style put back is put back over the value that replaced it rather than under.
for (const step of undo.reverse()) step();
};
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { SitePreview, useDevPort } from '../SitePreview';
import { usePreviewable, useViewMode } from '../ViewMode';
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
import { paintSelection, type CanvasComponent, type CanvasSection } from '../canvas';
/*
What the Spacing dialog calls a block, by kind.
@@ -163,7 +163,7 @@ export const PageBuilderPanel = () => {
const onDocument = useCallback(
(frameDocument: Document) => {
frameDoc.current = frameDocument;
return paintCanvas(frameDocument, {
return paintSelection(frameDocument, {
sections,
onAddSection: () => handlersRef.current.add(),
onDeleteSection: (section) => handlersRef.current.remove(section.id),