The same framing a media block has had since it existed, so a template wanting a
letterbox lead can ask for one. It was in PLACES_ONLY — the list of blocks that
hold nothing — which was true until it held a ratio.
Shape and only Shape. Not the media block's other controls: there is no file to
choose, because the picture is the carousel's first item, and no crop to set,
because the crop belongs to the carousel item. Two crops for one placement is two
answers able to disagree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same object as a collection and a gallery piece, so it is a config in
subjects.ts and a one-line panel — no new screen.
Two differences, both deliberate. It has no SPECS: a collab is a project rather
than an object, so there is no measurement or raw material and the form is a
name, an address and a description. And its title column is `project_name`,
which is the client's word and stays — the site's API publishes both that and
`title` so this panel reads one field like every other section, and the
translation lives in one router rather than as a branch in every screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The site serves nothing but derived files, so a block pointing at an original
the pipeline has not been through renders an empty box — on the page, in the
preview and in the editor. That is a normal thing to hit: drop a file in, choose
it in the same minute, and you are ahead of a conversion that only starts twenty
seconds after the folder goes quiet.
The action bar already said "Sync 3". What was missing was the link from THIS
blank block to that number, which is the half that costs an afternoon: an empty
box looks like a block saved wrong, so it gets looked for in the wrong place.
So the failed thumbnail is caught and named, using the state the bar already
polls: converting now, in a run that started before this file, outstanding for
the next one, or nothing running — with Sync now in place for the last case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every collection has a hero and every folder a _1, so a bare filename says which
file only to somebody who already knows. Not truncated either: truncate cuts the
end, which is the wrong half of a path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two of these were live: PageBuilderPanel still compared kind === 'gallery' in
two places, so a carousel block's editor and its Choose button were both dead
branches. The type checker could not see them — both compare against string.
The rest is copy somebody reads: "Change gallery", "Name a new gallery",
"Reading the gallery", "Add to this gallery", Home's Gallery heading, and the
"Create Gallery" file action, now "Carousel from folder".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It always rendered as one — data-carousel, a snap track, two arrows, driven by
carousel.client.js. The entity was the last thing still calling it something
else, and that collided with the site's own Gallery section: two nav rows
reading Gallery and Galleries, meaning a list of objects and a list of
photograph sets.
GalleriesPanel, GalleryPicker and GalleryItems become Carousels*, the endpoint
becomes /fofos-admin/carousels, the block kind becomes carousel and
subject-gallery becomes subject-carousel. The publish allowlist follows the
renamed tables.
What did NOT move: GALLERY_CONFIG at /fofos-admin/gallery, subjects.ts's
base '/gallery', and home_gallery_media. Those are the Gallery section and the
legacy rows, and both are correct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Gallery is a flat list of peças, each with a title, a gallery of mixed
media, and the specs from the client's sitemap — measurement, raw material,
colours, and NOTAS: production time and shipping conditions.
That is a collection one level flatter, so the panel is shared rather than
copied. SubjectPanel and SubjectFields take a section config (subjects.ts);
CollectionsPanel and PiecesPanel are one line each.
The five template blocks are renamed collection-* to subject-*. They never read
anything collection-specific, and copying them per section would have meant
twenty kinds by the time Drops and Collabs land.
Called Pieces in the nav, not Gallery — there is already a Galleries section
meaning a named set of media, and two rows called Gallery and Galleries is a
question somebody has to answer every time they look at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The builder loaded the template's own slug — `/collection` — which is not an address, so the frame
showed a 404 while the same arrangement rendered perfectly at /collections/70s in a tab. The page was
fine; the preview was asking for the wrong URL.
It previews an instance now: whichever the server names in `preview_slug`, the first of whatever the
template draws. Which row it is does not matter and deliberately is not a choice — a template IS the
arrangement, and the point of the preview is to see the arrangement with something real in it.
Null when there is nothing to draw yet, which shows as an empty preview rather than a broken one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`collection-cover` draws the FIRST photograph in the gallery, large, beside the words — the shape the
client's sitemap draws. Not a second way to choose one: a gallery is ordered and reordering it is how
the lead changes, which is the rule everywhere else here.
The five subject blocks are places-only, like the contact form and the partner row. They hold nothing
at all — what they draw is the collection the URL names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/collections/amadoras-mythology` and `/collections/70s` are the same page with a different subject.
Building each would be absurd; hard-coding the arrangement would put it out of reach of the admin. So
the arrangement is built once — a template page, composed exactly like any other — and every
collection is drawn through it.
Four blocks read the subject: its name, its description, its specs, its photographs. They hold nothing
at all, not even a reference. `collections` holds no content because the list is a query; these hold
none because the subject is the URL, and the same block draws a different collection on the next
request.
Offered only on a template, and HIDDEN rather than disabled elsewhere. A greyed-out row invites "why
not here", and the answer — this page has no subject — is not something a tooltip fixes. Available and
silently drawing nothing is the worse failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first block whose content is a QUERY rather than something somebody typed. It stores nothing at
all: a page holding "the collections" must not also hold a copy of which collections there were, or
adding one tomorrow would mean remembering to edit every page that lists them.
That makes it a places-only kind alongside the contact form and the partner row — one click to add, no
editor behind it. The hint says "every collection" rather than naming a picker, because there is
nothing to pick.
Gallery, Drops and Collabs all want the same shape. This is the one that decides it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The panel frames a 21:9 header in a 200-pixel thumbnail while the real one is on screen six inches
away, in the layout it belongs to. Dragging the picture itself is the obvious control, and until now
it was also the expensive one: every new rectangle made the site cut a fresh ladder — nine widths, two
formats, 1.54s measured — with the preview waiting for it.
Both halves are gone. The site draws the rectangle in CSS in development (previous commit, other
repo), so a framing costs nothing to show; the drag here is inline styles on the picture, and the only
write is one PATCH when the hand stops.
Inline styles rather than the site's custom properties, because the site renders a FRAMED picture one
way and an untouched one another — and an untouched photograph is exactly the one somebody is about to
drag. Inline beats both, and beats the object-fit the unframed path leaves behind, which would
otherwise crop the picture a second time inside a box that is already the crop.
Attached to every shaped photograph rather than to the selected one. Selection would put itself in the
paint's dependencies, so choosing a block would repaint the whole page's chrome; this way onDocument
still depends on the sections alone. The cost is that a click could become a nudge, so nothing moves
until the pointer has travelled 3px — below that it is a click, it selects the block, and no crop is
written.
The panel's zoom buttons now reach the real picture too, through `onDraft`. Otherwise the thumbnail
and the page would disagree for the 600ms until the debounce lands, which reads as a broken control
rather than a delayed one.
crop.ts is new and is the reason this is safe: parseCrop had already been copied once, and a crop that
means one thing in the panel and another on the page is a bug you can only feel. MediaField now draws
its own preview through the same `rectToBox`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting a band showed its name and title and nothing about what was in it — so
adding a block to the thing you were looking at meant going back out to the page.
BandBlocks is that list, used by the band's editor and nested under every band in
the page's structure. Same list, same actions, one component; the galleries panel
taught that lesson once already.
Blocks reorder within a column now. Across columns is the same call — the write
carries the column — but a list per column has no gesture for crossing between
them, and inventing one here would be guessing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding a block means choosing which column it goes in, so a flat list cannot
express it. A one-column band shows one button and a two-column band shows two —
and a band with one column never has to answer a question it has no answer to.
The blocks are links, like everywhere else: pressing one opens its editor.
That is the last of the four actions the painted-on bars used to carry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add, delete and reorder a band. Those three were reached from a bar painted onto
the page until the overlays went, and have had no door since.
Shown when nothing inside the page is selected — after the band and the block,
because those are things you opened deliberately and this is what is there when
you have not.
The nav lists the same bands and is for GOING somewhere; this is for changing what
the page is made of. Two verbs on the same nouns, and mixing them would mean a nav
row that sometimes navigates and sometimes deletes.
Same reorder gesture as pages, gallery items and partners.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MediaField is controlled: it reports every pointer move and draws what it is
handed back. Wired straight to the block, that was a PATCH and a full reload of
the preview per move, and the page went white while somebody dragged.
The drag runs against local state now — instant — and the write is debounced by
useAutosave, the same hook every other control here uses.
The reset effect keys on the url and the shape, not on the crop. Keyed on the
crop it would fight the drag: the parent's value is the last SAVED one, so every
move would snap back until the save landed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With a shape set, MediaField replaces the plain thumbnail: it draws the picture
in that shape and lets somebody drag and zoom to say which part shows. It already
did all of that — it needed the block's crop and the aspect.
Without a shape there is nothing to frame into, so the thumbnail stays and there
is no framing control that would do nothing.
Changing the shape clears the crop. A crop is a rectangle OF a shape, and keeping
it would frame the new one by numbers chosen for the old.
One SHAPES table, because three things have to agree: the options, the float
stored as content.ratio, and the Tailwind aspect MediaField frames into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Choose 16:9 and the band gets shorter than the picture — the ratio becomes a slot
shape and the site serves a rendition cut to it, rather than a whole photograph
scaled down by the browser.
`setContent` writes the whole content object, because the API replaces it rather
than merging: a PATCH with `{ ratio }` alone would drop the url.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scroll ran on `rendered` changing, which is the moment the reload STARTS —
frameDoc still pointed at the outgoing document, so it scrolled the page about to
be replaced and the new one arrived at the top. Every write from the editor did
it: reorder a gallery or a partner, lose your place.
It now happens in onDocument, against the document that has just loaded. The
effect stays for the other case — a different band chosen with no reload — and
keeps its smooth scroll, because there you are watching it move.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The block places the list; it does not hold one. So this edits the singleton and
every page showing them changes together — which the panel says out loud, because
the editor sits inside a block on a page and everything else in that column
belongs to the page.
Same gesture as the gallery items: a grip on the left, live reorder, one write at
the end carrying every id. Two ordered lists, one thing to learn.
A new partner is named from its filename and renamed in the field beside it — the
API refuses one with no name, and asking in a second dialog before the logo has
been seen is a form in front of a form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add, remove, reorder and caption, in the editor column. A gallery block on a page
IS a gallery, and being sent to another section to reorder it means losing the
page you were arranging it for.
It reads the gallery itself from its id, so it can be dropped anywhere something
knows one.
GalleriesPanel still draws its own copy of this list. That is a real duplicate
and it should adopt this component — its version has the same operations against
the same routes, plus creating and deleting whole galleries, which this does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The measure was drawn on Layout only, and this is the screen where things are
placed against it. It also answers the question a negative margin asks: a block
escaping the gutter has to be seen escaping it, against the line it crossed.
Horizontal padding now offers negatives, built from the same scale so there is
still one list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A strip of a photograph tells you it is a photograph and not which one. Full
width, its own aspect, under the button rather than beside it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Choose media…" and nothing else asks somebody to open a dialog to find out what
they already chose. The thumbnail and the filename answer it where they stand.
A video shows its poster rather than playing: the question is which file this is,
and a frame answers that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
editComponent means "edit what is IN this block", and for media and galleries
that is a dialog over the whole screen. Firing it on selection threw one at you
before you had asked for it — those kinds get a button in the settings instead.
Free text keeps opening inline, because its editor is in the same column and
opening it IS showing the settings.
The text editor now only renders when it belongs to the selected block. A stale
one showed the previous block's words under the new block's title.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spacing had a link in the chrome painted onto the page and lost its door with the
rest of it. It belongs beside the heading: both are properties of the selected
block, and one of the two is not worth a place of its own to go to.
The page's name and address now show only when the page row itself is selected.
With a band or a block open they sat above an editor about something else and
read as belonging to it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
canEditComponent answers "is there content to type", and a heading is not
content — so the blocks with no editor of their own are now selectable too, and
get the heading alone.
The live draft writes into [data-block] when there is one. A titled component
renders as a wrapper, and writing into the wrapper would replace the heading with
the paragraph as somebody typed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Name and Title, in the editor column — the band row in the nav had only scrolled
the preview until now.
Both, not the title alone: a form showing one of two names invites somebody to
change the wrong one, and `name` could only be set when the band was created.
Last in the editor's order, so a block or a spacing editor wins — those were
opened deliberately, and the band is merely still selected underneath them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fourth level under the open band only — a page of six bands with four blocks
each is thirty rows, and the nav stops being somewhere you can find anything.
Blocks are named by the same list the picker offers, so a thing is called the
same word wherever it appears.
Clicking a block in the PAGE now writes that address too, rather than calling the
handler directly. Both ways of picking a block are the same act, so neither can
disagree with the other about what is selected — and closing it is the band row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A third level, under the selected page only — every page has bands, and listing
all of them would make the nav longer than the site.
Announced by the builder rather than fetched again: it already reads them to know
what it is looking at in the document, and it mounts exactly when a page is open.
Cleared on unmount, or the nav keeps listing the last page's bands.
Clicking one writes a third URL segment and the preview scrolls to it, keyed on
the render count too so the position survives a save replacing the frame.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The admin declares its routes in the manifest, so /fofos-admin/layout/header and
/fofos-admin/pages/home are real URLs. One reader takes the two segments after
the mount; links carry no query string at all, so nothing leaks between
sections.
What stays in the query is how you are looking — device, tab, view — rather than
at what.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spacing and free-text editors rendered below the preview, so they took their height from the thing
being edited: choosing a spacing shrank the page you were choosing it for, and on a laptop two
dropdowns cost a third of the frame.
They are a third panel now, to the right of the preview. The workspace already knows how to size and
move panels; a drawer was a second, worse layout system living inside one of them.
The panel holds nothing. Whichever panel owns a selection BUILDS the editor and announces it through a
`useGlobal` slot — the two are siblings and neither may own the other, and a provider around both would
mean something above them knowing they exist.
The slot carries the ELEMENT, not a description of one. A descriptor plus a switch would move every
editor's loading, saving and cancelling into a panel that would have to learn what a section is — a
second place that understands the page builder, kept in step by hand. SaveTarget makes the same trade
for the same reason.
The hazard is real and worth naming: an element captures the props it was built with, so the announcing
effect must depend on everything the editor DISPLAYS. The save handlers sit in refs so they cannot drag
that list wider — depending on them would re-announce on every keystroke, and omitting them without
refs would announce a stale one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bar offered Edit and View, and View existed to see the page without the builder's outlines and
handles on top of it. It was the DEFAULT, which put a click in front of every arrival at the one panel
whose job is editing — and it put a branch in every painter that did nothing but wait for that click.
So the mode is gone rather than defaulted the other way: no toggle, no `?mode=`, no guard. The clean
page is still one click away and always was — it is the site, on its own URL. What the admin owes is
the editing surface, always.
The left group in the bar stays, empty. The middle one is centred against the BAR rather than against
its neighbours, so removing the left group would let Desktop/Tablet/Mobile drift as the right group
changes width.
The preview's caption stays too. It reads as a duplicate of the device buttons and is not: they say the
width, and it says which SERVER — development, not production — which nothing else on the screen does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two rows, one of them marked `soon`, for a distinction that was never the editor's to make. The path
names a real file and what that file IS is a fact about it — the site reads it off the extension the
way a gallery item always has. So the picker asks for a file and stops asking what kind.
`video` is gone from what an editor can create and still legal in the database. Every row was migrated;
one written by something older should still be editable rather than open nothing, which is why the
edit path answers to both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both hold nothing, so adding one opens no editor: a dialog with no field on it is a step that exists
only to be got past. The picker itself answers the opposite way for the kinds that DO have a choice
to make, which is the distinction worth keeping.
The canvas draws no Edit button for them either — an Edit that opens nothing is worse than no Edit.
It asks the panel rather than holding a list of kinds, because which kinds are editable is the same
knowledge that decides which editor opens, and that already lives there. Absent means editable, so a
caller that has never thought about it gets what it had.
The Spacing dialog's label became a map. It was two kinds and readable as a ternary, then three and
not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The picker listed it as `soon` beside Image. It is not soon any more — the set, its ordering and the
carousel that draws it were already built on the site, so the block is a reference to one and the
admin's whole job is choosing which.
`GalleryPicker` chooses a SET and nothing else. Editing what is IN the set stays under Galleries: a
block that could also rearrange its own pictures would be a second place to do one job, and the two
would disagree. No thumbnails — every stored path names an original a browser cannot display, and the
plugin has no way to address a rendition, so the name and the count identify it instead.
Saved on choose, like a video and for the same reason: picking one thing is one act with one outcome,
so there is nothing to draft and nothing to cancel out of.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Spacing handle on the section bar and in the component tools, opening an editor below the preview —
the same place the text editor goes, and for the same reason: a dialog covers the thing being spaced.
The device toggle picks the breakpoint. Two axes across three widths is six controls, five of them for a
width you are not looking at; this way you switch to Mobile, see the phone, and change the padding you
can watch changing. The site's GUTTER is the exception and shows all three at once, because it is one
axis and one value and it is a progression judged as a whole.
Inherit and 0 stay separate options, and the select names the value it is inheriting rather than leaving
it to be worked out.
Written IMMEDIATELY rather than drafted. A step is one choice from a short list and the effect is on
screen before the menu closes; a Save for it would be a second thing to remember on a panel whose other
verbs already act.
Spacing sits before Delete in the section bar, ordered by how often each is wanted rather than how they
read — the destructive one furthest from where the hand arrives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A modal covered the thing being edited, which for a page builder is the whole subject: you are choosing
how words sit in a column, and a dialog in front of the column answers a different question. It sits
under the preview now, the way the Layout section's controls already do.
AND THE TEXT LANDS IN THE PAGE AS IT IS TYPED. The panel writes each change straight into the
component's node in the frame, so the loop stops being "type, save, look" and becomes looking. That is
the same door everything else here goes through — the frame is same origin — and it is what the modal
made impossible rather than merely awkward.
The node's children are replaced and the injected tools put back. Those live inside the component and
belong to the admin, so an innerHTML that forgot them would take the Edit button away mid-edit.
Not sanitised while typing. The server cleans on write, so what is on screen can be marginally more
permissive than what is stored — and the difference shows the moment it is saved, which is the right
place to find out.
Cancel reloads the frame rather than restoring the markup by hand. The node has been written into on
every keystroke, and putting it back correctly means remembering what was there before the first one; a
reload is the same answer with nothing to get wrong.
The editor is capped and scrolls, so a long piece does not push the page off the top of the panel.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The picker offers it now, and choosing it opens the media picker rather than a dialog. A video is a
PATH, saved as soon as it is chosen: free text needs a draft because writing is something people do
halfway, and picking a file is one act with one outcome.
Edit means whatever editing that KIND means. The canvas draws one button; which door it opens is decided
in the panel, where the kinds are known — so Edit on a video reopens the picker and Edit on text opens
the editor.
Image and Gallery stay named and disabled. Both are things the site already has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It opened the text editor straight away, and I had argued for that: Free Text is the only kind that
works, so a picker offering it is a click asking to be got past.
That was wrong in the way a shortcut usually is. It optimised the state of the app today and hid its
shape — somebody adding a component could not see what a component IS, or that there will be others,
and the day the second kind lands the interaction changes underneath them.
It is also the pattern this admin already uses. The nav lists unbuilt sections as `soon` rather than
hiding them, because a list of only what is finished gives no sense of what the thing is for. The same
argument applied here and I had not noticed.
Image, Video and Gallery are named and disabled. Those three are things the site already HAS — media,
video and galleries all exist as concepts here — which is the difference between saying what is coming
and inventing a roadmap.
The list lives in the plugin rather than the API. The API decides what may be STORED; this decides what
may be OFFERED, and it has to be here because every kind needs a label, an icon and an editor that only
exists in this plugin. A kind the server would accept and this has never heard of is not creatable
anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`?page=about` instead of `?page=2`. A URL that names the page can be read, sent to somebody, and still
means the right thing after a database is restored from a dump with different ids — which happened
once in this project already, earlier today.
Home needs a word, because its slug is empty by design: that is the check constraint, and it is what
makes `/` fall out of joining a slug to the root. An empty string is no use in a query parameter that
has to name a page unambiguously, so the admin says `home` — and the site's API now REFUSES that slug,
which is what makes the mapping total in both directions rather than merely usual.
One reserved address costs nothing. Anybody who wanted /home wanted the front page, which they have.
One place maps a page to its URL value and back — pageParam and pageFromParam — so the nav, the detail
panel and the builder cannot disagree about what `?page=` means.
`record` is deleted on every navigation. It belonged to the per-page record panels, which are
unreachable now; nothing reads it, so it was not a bug, but every URL anybody copied carried a
parameter naming a row in a section that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every column gets Add component; every component in one gets Edit and Delete, drawn ON the rendered
thing rather than replacing it. An editor that swaps content for a placeholder is showing you the
editor, not the page — and free text with nothing in it renders as nothing, so the outline is the only
thing saying it is there.
Add component opens the editor STRAIGHT AWAY. Free Text is the only kind, and one option is not a
choice: a picker offering it would be a click asking to be got past. The picker arrives with the second
kind, which is when it starts meaning something.
The add button is quieter than Add section. This is the inner loop, and a control as loud as the one
that adds a whole band would make every column shout.
── The dialog, and the limitation worth naming ──
Editing happens in a modal, not in the page. In-place is the obvious idea and a much bigger one: the
toolbar would have to live in a document the site owns, the site's typography would fight it, and every
keystroke would cross into another document. The dialog is where a working editor already exists, and
it costs a modal instead of a rewrite.
So what you type is not laid out in the page until you save. The preview reloads immediately after, so
the loop is short — but it is a loop, and direct manipulation is what a page builder eventually wants.
The editor is keyed on the stored html, so opening a different component rebuilds it rather than
reusing one still holding the last component's document. Empty text saves: clearing a paragraph is
deleting its words, not abandoning the edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same bar Layout has, from the same opt-in: the section says it has a preview and the controls
appear. Pages — the list — still says nothing, because there is nothing there to view at tablet width.
VIEW paints nothing at all. Not dimmed chrome or hidden buttons: no injection, so the frame holds
exactly the document the site served. That is what View has to mean here — the outlines, the labels and
the drop zone are the editor's, and a preview that keeps any of them is not showing you the page.
Done by putting `mode` in the callback's dependencies rather than by branching on it somewhere else.
Switching hands SitePreview a new function; its effect runs the previous paint's cleanup and then does
nothing. The chrome goes without a reload and without a second code path that could disagree with the
first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>