the preview is the real page, proxied

The panel drew its own header and footer in React. It looked plausible and it was wrong — the site's
header has a logo hanging past a rule and a nav in the content column — and worse, it was a second
implementation of the layout, so it would have gone on being wrong differently every time either side
moved.

So the admin embeds the site's own `/layout` route instead: the real shell with no page in it. A
designer changing the header in code changes what the admin shows, with no admin change at all. That
is the bridge.

Proxied through Officer rather than linked, because everything this plugin knows about where the site
lives is loopback — 127.0.0.1:<devPort> and nothing else. There is no public hostname stored anywhere
and adding one would be a second fact about the same thing, kept in step by hand. The request goes the
way every other request here goes, server-side, and the browser talks only to Officer.

Same origin, which is also what keeps the door open: the day the page builder wants to draw handles on
top of the real page, it can. A cross-origin iframe never could.

The rewrite is narrow and its limits are the interesting part. The site writes root-relative URLs and
those resolve against Officer's root inside the iframe; a <base> tag does not help, because it moves
relative URLs and leaves root-relative ones exactly where they were wrong. So href, src, srcset and
CSS url() are moved under the prefix — and `//` is excluded deliberately, because that names another
host and prefixing one would turn it into a path on this server.

Only HTML is rewritten. Stylesheets, fonts and images are returned as they came: rewriting bytes we do
not understand is how a proxy corrupts a font.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 01:55:45 +01:00
co-authored by Claude Opus 5
parent 09b914c430
commit b56c8375c3
2 changed files with 112 additions and 53 deletions
+22 -53
View File
@@ -150,61 +150,30 @@ export const LayoutPanel = () => {
{saved && <p className="text-xs text-success">Saved</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
{/* ── The sketch ───────────────────────────────────────────────────────────────────────── */}
{/*
The REAL page, embedded — not a drawing of it.
It was a React sketch: a centred row of labels, a body, a band at the bottom. It looked
plausible and it was wrong, because the site's header has a logo hanging past a rule and a nav
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.
This is `/layout` on the development server — the site's own shell with no page in it — proxied
through Officer so it is same origin. See the preview route in api/router.ts.
*/}
<section className="flex flex-col gap-2">
<div
className="overflow-hidden rounded-lg border border-border"
style={{ backgroundColor: bg, color: fg }}
>
{/* The menu, in the order it will appear, in the face chosen for it. */}
<div
className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 border-b px-4 py-3 text-[11px]"
style={{ borderColor: fg, fontFamily: draft.font_sub ?? undefined }}
>
{/* The menu when there is one, the site's own pages when there is not — the same fallback
the page itself applies, so the sketch never shows a menu the site would not draw. */}
{(menu.length ? menu : draft.pages.map((page) => ({ label: page.label, href: page.path }))).map(
(item, index) => (
<span key={index}>{item.label}</span>
),
)}
{!menu.length && !draft.pages.length && <span className="opacity-40">No menu yet</span>}
</div>
{/* The body. Deliberately empty: what goes here is the page, and the page is what this
section is NOT about. Its only job is to give the two bands something between them. */}
<div className="px-4 py-10 text-center" style={{ fontFamily: draft.font_title ?? undefined }}>
<p className="text-lg">The page</p>
<p className="mt-1 text-[11px] opacity-50" style={{ fontFamily: draft.font_body ?? undefined }}>
Whatever is built for it goes here
</p>
</div>
{/* The footer, with the columns as they will fall. */}
<div className="border-t px-4 py-4" style={{ borderColor: fg }}>
{columns.length ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{columns.map((column, index) => (
<div key={index}>
{column.title && (
<p className="mb-1 text-[11px]" style={{ fontFamily: draft.font_sub ?? undefined }}>
{column.title}
</p>
)}
{column.links.map((link, i) => (
<p key={i} className="text-[11px] opacity-70">
{link.label}
</p>
))}
</div>
))}
</div>
) : (
<p className="text-[11px] opacity-40">No footer columns yet</p>
)}
{draft.footer_note && <p className="mt-3 text-[10px] opacity-60">{draft.footer_note}</p>}
</div>
<div className="overflow-hidden rounded-lg border border-border bg-white">
<iframe
src="/fofos-admin/preview/layout"
title="The site's layout"
className="h-[420px] w-full border-0"
/* Nothing is typed into it and nothing is submitted from it. It is a window. */
sandbox="allow-same-origin allow-scripts"
/>
</div>
<p className="text-[10px] text-muted-foreground">
The development server, live. Save to see a change here.
</p>
</section>
{/* ── Menu ─────────────────────────────────────────────────────────────────────────────── */}