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
+28 -9
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { SitePreview, useDevPort } from '../SitePreview';
import { paintCanvas } from '../canvas';
import { usePages, type Page } from '../usePages';
/**
@@ -32,6 +33,19 @@ export const PageBuilderPanel = () => {
const { pages, announce } = usePages();
const [error, setError] = useState<string | null>(null);
/** Proof the frame is talking back. Replaced by the section picker once there are sections to pick. */
const [asked, setAsked] = useState(0);
/*
The builder's chrome, painted into the rendered page.
Stable, because SitePreview holds it and re-runs it on every load. A new function each render would
be a new injection each render.
*/
const onDocument = useCallback(
(frameDocument: Document) => paintCanvas(frameDocument, { onAddSection: () => setAsked((n) => n + 1) }),
[],
);
/*
The list, if nobody has announced it yet.
@@ -66,18 +80,23 @@ export const PageBuilderPanel = () => {
<span className="font-mono text-[10px] text-muted-foreground">{page.path}</span>
</div>
{/* The real page, at its own address. `path` has no leading slash — Home's is the empty string,
which the proxy resolves to the site's root. */}
<SitePreview port={devPort} path={page.slug} />
{/* The real page, at its own address, with the builder's own controls drawn inside it. `path`
has no leading slash — Home's is the empty string, which the proxy resolves to the root. */}
<SitePreview port={devPort} path={page.slug} onDocument={onDocument} />
{/*
Where the blocks go.
The other end of the wire.
Said rather than left blank, because an empty panel under a working preview reads as something
that failed to load. This is the next thing to build and the panel should say so.
Temporary, and it earns its place until the section picker exists: it is the only visible proof
that a button drawn inside the rendered page reaches React out here. When there are sections to
choose, this becomes the chooser.
*/}
<div className="shrink-0 rounded-lg border border-dashed border-border p-4 text-center">
<p className="text-xs text-muted-foreground">The blocks that make up this page go here.</p>
<div className="shrink-0 rounded-lg border border-dashed border-border p-3 text-center">
<p className="text-xs text-muted-foreground">
{asked === 0
? 'Press Add section on the page.'
: `Add section pressed ${asked} time${asked === 1 ? '' : 's'} — the page is talking to the panel.`}
</p>
</div>
</div>
);