An empty section renders as nothing. That is correct on the public site and useless in an editor:
somebody who has just chosen "two columns" sees a blank page and has to take it on trust.
So the canvas outlines every band it finds, names it, says its shape, and offers Delete — and outlines
the columns inside it, because a one-column band and a two-column band are identical until something is
in them, which is exactly when it is too late to notice the wrong choice.
The site provides three attributes and no behaviour: data-page-empty, data-section-id, data-column.
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. That is the whole contract — one attribute on
the public site buys the entire editor.
Section names and layouts are FETCHED, not read out of the DOM. The markup says which row a band came
from and nothing else; inferring the layout from its classes would be the admin reading the site's
stylesheet, which is the coupling all of this exists to avoid.
── The race, and why the fix is not another ref ──
Creating a section starts two things that do not finish in a fixed order: the frame fetches a new
document, and the panel fetches the sections to draw on it. Painting only on load meant painting with
whatever had arrived by then, which after a create was usually the data from before it existed.
So SitePreview re-runs the injection when EITHER moves — `loaded` is state, `onDocument` is a
dependency, and whichever arrives second triggers the paint that counts. The caller then controls
repainting through its own useCallback dependencies, which is what those are for.
The handlers still go through a ref, and the data does not. Callbacks are rebuilt every render and
would repaint on every one; the sections are the thing being drawn and should be exactly what decides
when to redraw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing the button drawn inside the page opens a dialog: a name, and a choice between one column and
two, then Create section. The row lands in the database and the frame re-renders to show it.
The layouts are DRAWN rather than named. "One column" is words for a shape, and a shape is faster to
recognise than to read; the words stay underneath for anybody the picture does not reach.
The name is asked for and not generated. It never appears on the site — it exists so a page of eight
bands is navigable, and "Section 4" tells nobody anything, which is exactly the state a generated name
would leave every page in. Asked at the only moment somebody knows what they are about to build.
The frame is remounted after a write rather than the admin drawing the new band itself. The site
renders from the database; the admin does not know how to render a page, and if it learnt it would be
a second implementation of the very thing this preview exists to avoid.
The canvas now finds the empty-page placeholder by its `data-page-empty` marker instead of taking
`firstElementChild`. That was right for exactly as long as an empty page was the only kind — the moment
a page has a section, the first child IS that section, and it would have hidden the thing being edited.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A component that lives at the PLUGIN level and appears on the site's page: an empty drop zone with an
Add section button, injected into the frame at runtime. Nothing of it exists in the website's source,
so the site never carries code only an editor would see, and the builder can change without the site
being rebuilt.
It works because the preview frame is SAME ORIGIN — through the platform's dev proxy, on Officer's own
host — so contentDocument is readable and writable. That single fact is what the page builder rests on,
and it is why swapping this plugin's hand-rolled cross-origin proxy for the platform's mattered beyond
the bugs it fixed.
SitePreview grew one prop, `onDocument`, called with the frame's document after every load and
returning a cleanup that runs before the next one. So a reload never leaves two sets of controls
behind, and nothing injected outlives the document it went into.
Plain DOM rather than React. Portalling would mean running a reconciler against a document that reloads
underneath it whenever the dev server rebuilds; these are a handful of elements with a click handler,
and this way a reload is a document that no longer exists rather than a tree React still thinks it owns.
Inline styles, not classes. The frame carries the SITE's stylesheet, built by scanning the site's own
templates — a class invented in this file was never scanned and would silently do nothing. The button
takes its colours from --site-fg and --site-bg, so it belongs to whatever theme the site is wearing.
The page's existing content is HIDDEN rather than removed, so the same call will leave real blocks
visible once there are any.
The panel counts the presses for now. That is the only visible proof the wire runs both ways, and it
becomes the section picker when there are sections to pick.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nav already said it: Pages is a section, and the pages are rows under it. Two things, two screens.
The detail panel was showing one screen with a row lit up, which made the list the answer to both
questions.
?section=pages the list — what exists, what it is called, where it lives, in what order
?section=pages&page=4 that page — what is on it
Both addressable, which is why the distinction is a query parameter rather than state.
Pressing PAGES itself now clears `page`. Without that the section row and the row for whichever page
was last open lead to the same screen and there is no way back to the list but the browser's back
button. Scoped exactly like `media`, and for the same reason: a parameter belonging to one section
should not ride along into the others.
The list stops having a selection — selecting a page leaves the screen entirely — so its rows are
plain rows again and the panel no longer reads or writes the URL at all.
Adding a page deliberately does NOT open it. The next thing anybody does is name it, and the name lives
on the list; jumping into the builder would mean coming straight back.
SitePreview is now shared, lifted out of LayoutPanel with the dev-port lookup beside it. Layout passes
the theme it is editing; a page passes nothing and just gets its own page. The builder shows the real
page at its real address today, which is what the blocks will be judged against when they exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>