the builder's chrome is drawn inside the rendered page

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>
This commit is contained in:
2026-08-31 03:57:35 +01:00
co-authored by Claude Opus 5
parent 90fa69d95a
commit 5af0d9d9d1
3 changed files with 175 additions and 10 deletions
+38 -1
View File
@@ -44,6 +44,7 @@ export const SitePreview = ({
port,
path,
theme,
onDocument,
}: {
port: number | null;
/** The site path to show — `layout`, `about`, or `` for the front page. No leading slash. */
@@ -55,6 +56,17 @@ export const SitePreview = ({
* is editing the variables the page reads, so it can show a change without a save or a reload.
*/
theme?: Record<string, string | null>;
/**
* Called with the frame's own document once it has loaded, and again after every reload.
*
* This is the door the page builder goes through. The frame is same-origin, so a caller can read the
* rendered page's DOM and put its own elements inside it — chrome that belongs to the ADMIN, drawn on
* the real page, without a line of it existing in the website's source.
*
* Return a cleanup and it runs before the next load and on unmount, so nothing injected outlives the
* document it was injected into.
*/
onDocument?: (document: Document) => void | (() => void);
}) => {
const client = useClient();
/*
@@ -142,6 +154,28 @@ export const SitePreview = ({
applyTheme();
}, [applyTheme, ready]);
/*
Hand the loaded document to whoever asked for it.
Through a ref so a caller's inline arrow — which is a new function every render — does not re-run
the injection on every render. Same trap as `useClient`, one level along.
*/
const onDocumentRef = useRef(onDocument);
onDocumentRef.current = onDocument;
const cleanupRef = useRef<(() => void) | void>(undefined);
const handOverDocument = useCallback(() => {
// Whatever was injected into the PREVIOUS document goes first. A reload replaces the document, so
// a cleanup that runs after the fact is cleaning something that no longer exists.
cleanupRef.current?.();
cleanupRef.current = undefined;
const frameDocument = frame.current?.contentDocument;
if (frameDocument) cleanupRef.current = onDocumentRef.current?.(frameDocument);
}, []);
useEffect(() => () => cleanupRef.current?.(), []);
const message =
!port ? 'No development server is configured — set its port under Administration.'
: failed ? `Could not authorise the preview: ${failed}`
@@ -190,7 +224,10 @@ export const SitePreview = ({
ref={frame}
// Applied on load as well as on change: the document is replaced on a reload, and the
// properties go with it.
onLoad={applyTheme}
onLoad={() => {
applyTheme();
handOverDocument();
}}
src={`/api/dev-proxy/${port}/${path}`}
title={`The site at /${path}`}
className="border-0"