Commit Graph
239 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 59e1218060 a nav link carries only what it means
Pressing Layout while a band was open gave `?section=layout&band=footer`, so the row for the whole
section led to one part of it. Pressing a section carried whatever the last one had left behind.

Every link built a copy of the current query string and deleted the parameters known to be scoped —
`media`, then `page`, and `band` would have been the third. That is a list of exceptions that has to
grow every time a section gains a parameter, and it fails silently when somebody forgets: the link
still works, it just goes somewhere more specific than it says.

Starting from an empty query string inverts the default. A parameter survives a navigation only if the
link says so, which is one decision at each link rather than a list of everything that must not leak.

So a section row is `?section=x`, and a sub-item is that plus its own `band` or `page`. Nothing else
rides along, including the device and the open tab — both are restored to their defaults by the panel
that owns them, which is where a default belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:33:48 +01:00
pastilhasandClaude Opus 5 5cc4742119 Colors, Fonts, and two by two
The two headings take the site's own spelling and the shorter word. "Typefaces" is the more precise
term and "Fonts" is the one everybody reaches for; a section heading is a signpost, not a definition.

The fonts go two by two. Four across existed to keep this section the same height as Colours, because
as tabs they shared one grid cell and a taller panel moved everything below it. Stacked sections do not
have that constraint — each is as tall as it is and nothing above it moves — so the reason expired with
the tabs.

What four across cost was the labels: in a panel this narrow every select truncated to "Martia…", so
the row said four times that a font had been chosen and never which one. Two columns give each the room
to be read, which is the only thing the control is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:31:03 +01:00
pastilhasandClaude Opus 5 dd10a4df45 Layout's controls are stacked sections, not tabs
Three tabs became three collapsible sections in a column, because there are going to be more than
three. A tab strip works while the names fit on one line and degrades the moment they do not — it wraps,
or it scrolls, and either way the set stops being visible at a glance.

`type="multiple"`, so more than one can be open. Tabs enforced exactly one, which is right when the
panels are alternatives and wrong when they are parts of the same thing: the colours and the padding of
a layout are read together as often as separately.

The grid overlay went with them. Every panel shared one cell so the block stayed as tall as the tallest
and did not jump when the tab changed — a real problem that stacked sections do not have, because a
section that is closed occupies nothing and one that is open is as tall as it is.

`?tab=` still seeds which section starts open, so an old link keeps working and lands on the thing it
named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:28:15 +01:00
pastilhasandClaude Opus 5 ba2f98c7fb the width label goes outside the measure
It sat just inside the left guide, which is on top of the page — over the logo, on this site — and it
is not part of the page. The margin is the one strip on screen belonging to the editor rather than to
the site, so the number describing the measure lives there.

Right rather than left: the eye reads the guide and then the width, so the label is the answer to the
line beside it, and the right margin is the one a wide frame has room in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:25:43 +01:00
pastilhasandClaude Opus 5 cee2f5c8e2 Desktop is the panel's width, and the guides are the only marking
Two things, both asked for after looking at it.

The tint came out. Shading the margins was meant to say "the page does not use this", and against a
white page it read as a blue background — a colour the site does not have, on the screen that exists to
show the site's colours. The two hairlines say the same thing without claiming anything.

And Desktop is fluid. `width: null` means the frame takes whatever the panel gives it, which is the
honest answer for a device that has no width: a desktop is whatever window somebody opened. 1440 was a
guess that then had to be centred and scrolled inside the panel — a page rendered at one width and
looked at inside another, with dark room either side of it.

Tablet and mobile stay pinned, because 834 and 390 are claims about real devices and pinning them is
the only way the preview can answer "what does this look like on a phone".

Fluid, the frame IS a desktop window: the site's media queries answer the panel's real width, and the
measure guides mark where the content stops in the pixels you are actually looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:24:23 +01:00
pastilhasandClaude Opus 5 d185382d84 show the space the page is not using
Two hairlines said where the measure ends and left the reader to notice that everything outside them
was empty. The margins are tinted now, faintly, so the unused width reads as what it is — room the page
does not occupy, which is the question somebody widening a gutter or judging a desktop layout is
actually asking.

Faint on purpose: it is absence, and anything stronger competes with the content for the eye. Nothing
is drawn at all when the frame is no wider than the measure, which is the existing early return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:21:26 +01:00
pastilhasandClaude Opus 5 f24d583e67 the preview and the editor are a workspace inside a panel
`1 | [1 | 1]`, and the brackets are a real container this time.

Nesting them as a GROUP was the first attempt and looked identical — a horizontal group inside a
horizontal group renders three columns, because that is what it is. All it bought was which divider
moved what, which is invisible until you drag one.

So the pair is a panel whose component hosts its own WorkspaceView, the way DashboardPreview does. It
has its own chrome, its own divider and its own persisted sizes, and the root layout has two children
rather than three: the shape on screen is now the shape in the file.

`useAuthoredLayout` rather than `useDashboardState`, and its own key beside the root's. The structure is
the author's and comes from source every render; only sizes are the user's. Sharing one key would have
the inner and outer trees persisting over each other's sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:02:38 +01:00
pastilhasandClaude Opus 5 269b6004e4 the preview and the editor are a pair
`1 | 1 | 1` became `1 | [1 | 1]`, and the nesting is the whole difference.

Three siblings means every divider sits between two of them: widening the editor took from the preview,
and dragging the nav re-proportioned all three. Three panels that could only be sized against each
other.

Nested, the outer divider moves the nav against THE WORK and the inner one splits the work between
looking and changing. Those are the two questions somebody actually has — "give me more room" and "I
need more of the controls right now" — and each is one drag that leaves the other answer alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:57:16 +01:00
pastilhasandClaude Opus 5 a0e05efda9 Layout's controls move to the editor panel too
Colours, Typefaces and Padding sat below the preview and took their height from it — the same trade the
page builder's editors made, and undone for the same reason: this screen exists to be looked at, and
the controls were eating the thing being judged.

Announced through the same slot, so the panel still holds nothing and still knows nothing about what a
layout is.

One thing that does not survive the move: a JSX comment cannot be an argument. The block carried its
own `{/* … */}` above the tabs and it had to become an ordinary comment on the effect, which is where
the reasoning belongs anyway now that the element is announced rather than rendered in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:55:07 +01:00
pastilhasandClaude Opus 5 9eb17c8d03 an editor opens beside the page, not under it
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>
2026-09-01 02:29:08 +01:00
pastilhasandClaude Opus 5 fa8917aa75 the editor may draw on the page, not change it
`element.style.margin = '2.5rem 0'` on every section — 40 pixels above and below every band that the
real page does not have. It was there to make room for the band's label, which sat above it.

So the preview overstated the spacing of every page in the site, on the screen whose entire purpose is
to show what a page looks like. It was reported as "there is a lot more space between the logo and the
text in the admin", and that is exactly what it was. I had looked at the component tools, found them
absolutely positioned, and said nothing the editor draws takes layout space. The section margin was two
lines above the thing I read.

The label moved inside the band it names. It is hover-only, so overlaying the first line for as long as
the pointer is there costs nothing — and lying about the layout costs the preview its whole point.

What remains is absolutely positioned or an outline, neither of which takes space. The add-section
zone keeps its margin: it is the editor's own element at the end of the page, spacing only itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:23:09 +01:00
pastilhasandClaude Opus 5 8f673792d1 there is no View, only Edit
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>
2026-08-31 19:23:37 +01:00
pastilhasandClaude Opus 5 8c9e4ecb85 Layout's three bands are nav rows
Top Menu, Content, Footer — the layout IS a page, and those are the only three things on it. They were
reachable by scrolling and by knowing which tab held what; as rows they take the shape the nav already
teaches with the pages under Pages, and the open one is `band` in the query string.

Fixed rather than fetched, unlike pages. A band cannot be added or removed from the admin — only the
code that draws it can — so reading them from the database would be a query that can only ever return
the same three rows in the same order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:19:53 +01:00
pastilhasandClaude Opus 5 10e5a625ff Media replaces Video, and the greyed-out Image
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>
2026-08-31 15:38:20 +01:00
pastilhasandClaude Opus 5 fd35b048f8 no backticks in the nginx config
The config is a JavaScript template literal, and the comment added with the /crops/ rename quoted the
paths in backticks. They closed the string, `crops` became a bare identifier, and the plugin failed to
build: "crops is not defined".

What that looked like from the outside is the part worth recording. The plugin did not mount, so every
/fofos-admin call 404'd, so useReady's catch — which deliberately reports "cannot tell" as not-ready —
locked all nine sections behind "Set a root location below to begin." A build error wearing the
costume of a setup problem, and the honest suspicion was that the gate had gone wrong rather than the
thing behind it.

The gate is right. It is one line of a log away from being diagnosable, and the log said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:29:29 +01:00
pastilhasandClaude Opus 5 3650a3c2b9 nginx serves /crops/, not /images/
The site's crop store was renamed — see its own commit for why the name was wrong. This is the fourth
place that had to move with it: the constant, the development route, the build's copy target, and this
cache rule.

The rule itself is unchanged. A year and immutable is still EARNED here rather than assumed: the
filename is a hash of the original, its stamp, the framing, the slot and the width, so the bytes at a
given URL can never legitimately change.

A site published before the rename carries /images/ in its HTML and no longer has the files behind it.
The fix is a publish, which rewrites every one of those URLs anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:23:01 +01:00
pastilhasandClaude Opus 5 3b18615bbb a clock on the run
Measured from the RUN, not from the press. `startedAt` is stamped by the server when the pass begins,
so the number is right for somebody who opened the modal halfway through, reloaded the page, or was
not the one who started it — the watcher starts runs too. A timer anchored to the click would read
zero in all three cases and be wrong by however long the run had already been going.

It freezes at `finishedAt` rather than counting up beside a finished run, because the total is the
useful number: it is what tells you the next full sync will also take forty minutes.

`tabular-nums`, so a digit changing every second does not shift the words beside it.

Clock skew is the one thing that can make this wrong, by exactly its error — both times are absolute,
so a timezone cannot. The alternative, anchoring locally the first time a run is seen, is exact for
whoever pressed the button and silently zero for everyone else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:19:03 +01:00
pastilhasandClaude Opus 5 ddcba1c95d Sync says what it is doing, file by file
The counters said how MUCH and never which file, so a run that was working normally and one that had
wedged produced the same screen: `done` stops moving either way. A photograph is around thirty encodes
and a video is three plus a poster ladder, and none of that was visible from outside.

So the run keeps a transcript and the button opens it.

Every rung reports itself by CONSTRUCTION. Ten `variants.push` call sites became one `add`, which is
the point — a rung added later is described without anybody remembering to describe it. The slow
stages that produce nothing until they finish say so BEFORE they start instead: the HEIC decode, each
video rung, the poster frame. Those are the pauses that read as a hang.

`?since=<n>` is what keeps it cheap. The transcript is thousands of lines and polls once a second, so
sending all of it every tick would be the same megabyte over and over; the client keeps what it has
and asks for the rest. No `since` means everything, which is what a modal opened mid-run wants.

The transcript lives in the shared slot, not the modal, so closing it does not throw away the run —
somebody who looks, closes, and looks again is watching one run rather than two. `startedAt` is what
identifies a run, because `n` restarts at 1 and appending across a boundary would interleave two.

Polling, not a socket. Each line transfers once and the events are seconds apart, so a second
transport to open, authenticate and reconnect would buy nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:16:52 +01:00
pastilhasandClaude Opus 5 78db209ea1 Sync moves to the bar, beside Build and Publish
It sat under the two folders it acts on, which was right while it was a detail of setting fofos up. It
is not one: a stale `derived/` is why a picture is missing from a page somebody is looking at, and the
fix was three sections away in a card about where the site lives. Beside Build and Publish it reads as
what it is — the third thing you do to a site, and the one that has to happen first.

The card keeps what needs room: the progress bar, the failures, the run history.

Shared through `useGlobal`, keyed `['fofos-admin','media-sync']` — a slot in the app-wide query cache,
so no provider has to wrap the bar and the panel together and a third reader is one line. Not a
context: this codebase has one for Save because a SECTION owns that state and announces it upward.
Nobody owns this one; it is a fact about a folder, which is what a keyed slot is for.

`useGlobal` shares the value and not the work, so the fetching is a separate hook with exactly one
caller — the button. The bar is mounted whenever the plugin is open and the Administration card is
mounted only sometimes, so the thing that is always there is the thing that asks. Two callers would
poll the same endpoint twice a second.

Gated on `rootSet`, not `ready`: sync reads a folder on this machine and never speaks to fofos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 11:58:28 +01:00
pastilhasandClaude Opus 5 307a7d665a AVIF beside every WebP rung
publish-crop has emitted AVIF for cropped placements since it was written, and `render()` has returned
<picture> sources for them. The older derived ladder — which serves every UNCROPPED photograph, every
square thumbnail and every video poster — was WebP only. This closes that, so the site has one answer
about what formats it ships rather than two.

Measured on a 256px gallery thumbnail, from the master, no generation loss: 18,186 bytes as WebP at
quality 74, 14,381 as AVIF at matched quality. Around 20% on every photograph, which is the reason to
build a second file per rung — not the seven kilobytes it happens to save on one page.

The two quality numbers are publish-crop's own, so the site encodes one format pair rather than two
opinions about what AVIF is worth.

Not for a drawing. `lossless` is chosen by prefersLossless for line art, where WebP's lossless mode
beats any lossy encoder — the note there records a logo whose lossy rung came out two and a half times
the size of the original. AVIF is a second lossy answer to a question already settled, so it is not
asked, and those ladders carry no sources and render exactly as they did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:52:25 +01:00
pastilhasandClaude Opus 5 f8ecbe371c a poster is a first paint, not a photograph
`WEBP_QUALITY` (82) is what a rung is worth when somebody opens it and looks at it. Nobody opens a
poster: it fills the frame for about a second and is then covered by the video it belongs to. Paying
photograph quality for that is paying for a second that ends.

Measured on the same pixels with the same encoder, at 768 wide: 22,906 bytes at 82, 17,738 at 74. That
is 23% off the Largest Contentful Paint for a difference nothing is on screen long enough to show.

Not lower than the small rungs use. It IS the LCP, so its first impression is the page's, and 74 is
what every thumbnail on the site already ships at — measured further down, 68 and 62 buy 1.2 and 2.7
KB more and start costing something a still frame will show.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:33:39 +01:00
pastilhasandClaude Opus 5 a67e3d741d publish the pages, the sections and the components
They were never in CONTENT_TABLES, so publish copied every table it knew about and none of the page
builder's. The symptom is the exact failure this list's own comment warns about: nothing failed. The
copy succeeded, the build rendered production faithfully, and the home page came out as the
empty-page placeholder — because production held the seeded layout and no content.

Parents before children, and first: a section belongs to a page, a component to a section. `galleries`
stays below them because a gallery block names a set by id carried in JSON rather than as a foreign
key — nothing enforces that order, which is why it is written down.

`site_layout` too. Production had no row at all, which reads as "the defaults" and is
indistinguishable from a setting nobody has changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:16:38 +01:00
pastilhasandClaude Opus 5 a4dfb3c81a Contact form and Partners, placed in one click
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>
2026-08-31 07:08:30 +01:00
pastilhasandClaude Opus 5 48c744372b Add section goes at the end of the page, not inside the first one
A built page is full-bleed now, so each band can draw a separator the width of the window, and each
holds its own content to the measure. `[data-content-column]` is therefore no longer one element
wrapping every section — it is one per section, and appending the drop zone to the first match put
"add a section" inside section one.

So it goes in `main`, with its width taken from a column rather than from a constant: the measure is
Tailwind's `max-w-7xl` and this file has no business knowing that. No sections, no column to ask, and
full width is right anyway — there is nothing to line up with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:02:52 +01:00
pastilhasandClaude Opus 5 2ab9e78790 Gallery is a component you can add
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>
2026-08-31 06:56:36 +01:00
pastilhasandClaude Opus 5 18c01de6f2 Spacing, on a section and on a component
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>
2026-08-31 06:29:38 +01:00
pastilhasandClaude Opus 5 5e351aa19f the editor reads back the alignment it wrote
Editing one paragraph silently un-aligned the others. A right-aligned line lost its alignment because
somebody justified something else — which reads as the alignment buttons applying to the whole document
rather than the selection, and is not that at all.

THE ROUND TRIP WAS LOSSY IN ONE DIRECTION. The editor emits `style="text-align: right"`; the site's
sanitiser refuses style attributes — one is a way to position something over the rest of the page — and
converts alignment to `class="rt-right"`. That half always worked.

Coming back out, tiptap parses styles and knows nothing about those classes. So a document loaded from
the database arrived with every alignment already gone, and the next save wrote the loss back. Whatever
you set in the current session survived; everything set before it did not.

Converting classes to styles as the editor is seeded makes the two ends symmetric, and the conversion is
the sanitiser's own table read backwards. The comparison in the re-seed effect had to move into the
editor's dialect too, or every load looks like a change: the stored html and getHTML() never match.

`rt-font-*` is not converted, and that is a known gap rather than an oversight — this editor has no font
control, so a stored font class has nothing to become and would be dropped either way. It wants a font
button before it wants a parser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 06:14:05 +01:00
pastilhasandClaude Opus 5 45104c1f98 the text editor renders below the page, and types into it
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>
2026-08-31 06:06:45 +01:00
pastilhasandClaude Opus 5 466ad26f11 Video, chosen from the media store
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>
2026-08-31 06:03:07 +01:00
pastilhasandClaude Opus 5 56c0f05056 a section's controls appear when that section is hovered
All of them: the outline, the name, the shape, Delete, the column outlines and the button that adds to
them. A page with four bands was four labelled boxes at rest, and the page underneath was the one thing
you could not see — which is what an editor is for.

A COMPONENT keeps its own hover rather than inheriting the section's. A band with three paragraphs would
otherwise show three sets of Edit and Delete at once, and none of them would say which one it belonged
to.

Opacity, not display, so nothing moves when it appears. The controls hold their space whether visible or
not, and the page does not reflow under the cursor — which in an editor is worse than the noise it would
be fixing.

The outlines moved from inline styles into the stylesheet, because that is the only place `:hover` can
reach them. The section's MARGIN stays inline: it is layout and has to hold whether or not anything is
hovered.

`:focus-within` on both, so a keyboard reaches everything a cursor does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:57:07 +01:00
pastilhasandClaude Opus 5 5c2d3baac0 justify, which the site could always render
`rt-justify` has been in the sanitiser's allowlist all along, and styles.css gives it `text-align:
justify` with `hyphens: auto` — hyphenation being what stops justified text opening rivers of white
space between words. Only the toolbar was missing it.

The four are now a list beside the toolbar rather than a ternary chain, and the comment says what it has
to match: ALIGN_CLASSES in the site's rich-text.ts. A fifth offered here would be stripped on save,
silently, leaving somebody pressing a button that appears to do nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:54:44 +01:00
pastilhasandClaude Opus 5 43a447801d the handles arrive under the cursor
Edit and Delete sat permanently at the top-right of whatever they belonged to, on top of the text — and
on a paragraph that fills its column there is nowhere for them to go that is not on top of something.

So they are hidden until the component is hovered, which is when they are wanted and the only time they
are. The outline goes the same way: an editor should show the PAGE, with the handles arriving under the
cursor. One that outlines everything permanently is showing you the editor.

The tools also moved out of the component's corner into the gap above it, so even while visible they are
beside the content rather than over its first line.

The section's Delete gets the same treatment; its NAME stays, because that is what tells you which band
you are looking at, and it already sits above the section rather than on it. A destructive verb does not
need to be on screen at all times.

Done with one injected stylesheet rather than listeners on every node — `:hover` is the one thing inline
styles cannot express, and it is the only reason this file now has a <style> in it. `:focus-within` and
`:focus` keep both reachable by keyboard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:51:15 +01:00
pastilhasandClaude Opus 5 8ab0a4a516 Add component asks what kind, even with one to choose
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>
2026-08-31 05:45:21 +01:00
pastilhasandClaude Opus 5 79b879f3f4 the gutter shows as you choose it, and the tab is in the URL
CHANGES APPEAR IMMEDIATELY. Colours and typefaces go into the frame as CSS custom properties because
that is what the page reads them as. A gutter cannot: only a class can carry a breakpoint, and there is
no variable to set instead. So the classes are swapped on the three elements marked `data-gutter`.

That works for one reason worth stating — the classes are in the SITE's stylesheet, which the frame
already has, and the site safelists every step. A class invented in the admin would silently do
nothing, which is the failure this whole arrangement keeps circling.

Only gutter classes are touched. Those three elements carry the gutter and nothing that looks like it —
a section's own padding lands on the <section> and the band padding on <header> — so stripping `px-N`
from them is safe where doing it to an arbitrary element would not be.

Driven by the dependency list rather than a second code path: `gutter` is a dependency of onDocument, so
changing it hands SitePreview a new function, whose effect runs the previous application's cleanup —
putting the original classes back — and then applies the new ones.

THE TAB IS IN THE URL. `?tab=padding`, same rule as the mode and the device: it is addressable. "Look at
the typefaces" is a link somebody sends, and a reload that drops you back on Colours loses the place you
were working in. Absent means Colours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:36:19 +01:00
pastilhasandClaude Opus 5 2b2d60f45c the gutter shows all three widths at once
One control, three values, horizontal only — Mobile, Tablet, Desktop on one row, smallest first because
that is the direction they cascade.

Not tied to the device toggle, and the difference from the per-band control is the reason. That one has
two axes and three bands, where showing every width would be eighteen inputs, so it writes to whichever
width the preview is showing. Here there is one axis and one value, so all three fit — and seeing them
together is the point. A gutter is a progression, 4 then 8 then 12, judged as a whole; reading it off
three separate visits to the same control is how a site ends up tighter on a tablet than on a phone.

Inherit still says what it is inheriting, and Mobile still says "None", because there is nothing below
it to inherit from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:32:01 +01:00
pastilhasandClaude Opus 5 08d1a3089f Layout's Padding tab sets the site's gutter
Not a per-band control. The header, the content column and the footer share one measure and should
share one number at its edges — three sections' worth of controls would be three ways to disagree about
the same decision.

PaddingTab is untouched and stays. It is the per-band control and it is right for the pages, where a
band genuinely does want its own spacing. This panel just is not where that decision belongs.

Same interaction as everything else here: the device toggle picks the breakpoint, Inherit and 0 are
separate options because they are separate instructions, and the select says which value it is
inheriting rather than leaving it to be worked out.

Saved with the colours and the typefaces, because it is a field of the same row — so the per-band write
path this panel had briefly is gone with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:29:01 +01:00
pastilhasandClaude Opus 5 2e8eabbc6b a Padding tab, where the device toggle picks the breakpoint
Choose a band — Header, Page, Footer — and set its horizontal and vertical padding. Which WIDTH you are
setting is decided by the Desktop / Tablet / Mobile buttons already in the bar:

  Mobile   390   base   every width
  Tablet   834   md     768 and up
  Desktop  1440  lg     1024 and up

That mapping is Tailwind's own and it lines up with the three preview widths exactly. Editing all three
at once would be six controls per section, 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 see changing.

INHERIT AND ZERO ARE DIFFERENT OPTIONS, because they are different instructions. Unset takes the
smaller breakpoint's value — which is what makes "one padding everywhere" a single choice rather than
three — and zero means no padding HERE even though the smaller width has some. The select says which
value is being inherited rather than leaving it to be worked out, and at the smallest width it says
"None", since there is nothing below to inherit from.

An emptied breakpoint is REMOVED rather than stored as `{}`, so "inherit everything" stays an absence
instead of becoming a thing that has to be told apart from one later.

One Save for both endpoints. The alternative was a second Save for spacing, which means somebody
changing a colour and a padding presses two buttons and is told about unsaved work by one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:24:30 +01:00
pastilhasandClaude Opus 5 495320f555 the tab panels share one grid cell, so nothing jumps
Making Typefaces one row equalised the two tabs by luck, not by construction — the labels and the hints
under the selects still differed, and any tab that grows a row would put the jump straight back.

So every panel now sits in the SAME grid cell. The block is as tall as the tallest of them and stops
moving entirely, and it stays right when a tab is added or one of them grows.

Measuring instead would have meant a ResizeObserver, a re-measure after every edit, and a wrong answer
for one frame after each. `col-start-1 row-start-1` gets the same number out of the layout engine for
nothing.

`invisible`, not `hidden`. An invisible element still takes its space, which IS the mechanism — and it
is out of the tab order, so the controls behind the front panel cannot be reached by keyboard. The Tabs
root is controlled now, because visibility is decided here rather than by Radix unmounting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:20:53 +01:00
pastilhasandClaude Opus 5 7e29953c07 four typefaces across from sm, not lg
The one-row change was committed and never deployed, so it had not been seen at all. And the breakpoint
was lg — 1024 — which four short selects had no reason to wait for. The breakpoint here is the browser
window's rather than the panel's, and the panel is wide in every window the admin runs in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:17:57 +01:00
pastilhasandClaude Opus 5 5c3bb83bab the typefaces fit on one row, so the tabs stop jumping
Two columns made Typefaces twice the height of Colours, so switching tabs moved everything below it and
resized the preview above. A tab strip whose contents jump is one that has to be re-read every time it
is used — which is the opposite of what tabs were for here.

Four across on a wide panel, two on a narrow one, and the hints truncate rather than wrapping into the
second row of height they were really costing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:12:51 +01:00
pastilhasandClaude Opus 5 c9e9465711 the preview's scrollbars stop taking width from the frame
A classic scrollbar — Linux, Windows — is laid out INSIDE the element, so it takes width from the
frame. The preview then shows less than the device width it claims to be, and at tablet the last menu
item ran off the edge of a page that fits perfectly well at 834.

It cascades, too: a horizontal bar costs height, which can force a vertical bar, which costs more
width, which is how a page that fits ends up not fitting.

Hidden with the same trick the site uses in its own stylesheet, for the same reason. The cost is the
same and worth stating: wheel, trackpad and touch still scroll, and what goes is the visual position
indicator on the platforms where scrollbars are always classic.

Found by looking for a fixed gap between the menu items that turned out not to exist — the nav has no
horizontal gap at all, and justify-between was already doing exactly what it should.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:07:02 +01:00
pastilhasandClaude Opus 5 a1e51b0b97 the device is in the URL too
?mode=edit&device=mobile

I had argued this one should stay in React state — a way of LOOKING rather than a thing being looked at
— and the distinction did not survive contact with the obvious use. "Here is what it does on a phone"
is exactly the link somebody wants to send, and a URL that drops the width sends them the desktop.

docs/navigation-audit.md already said addressable state goes in the URL. Both of these are addressable.
The rule was right; my exception was not.

Absent means desktop, as absent means view: an ordinary link carries neither parameter, and each
appears once somebody has chosen it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:02:36 +01:00
pastilhasandClaude Opus 5 8d5c4797ad the mode is in the URL, and the controls are tabs
?mode=edit

docs/navigation-audit.md draws the line: anything ADDRESSABLE goes in the URL, and whether you are
editing a page or looking at it is exactly that. It survives a reload, it can be sent to somebody, and
a link to a page being edited is a different thing from a link to the page.

`mode`, not `view`. The parameter names the AXIS and the value names the state, so the pair reads
`mode=edit` and `mode=view` rather than the tautological `view=view`. Absent means View — the default
and the common case — so an ordinary link carries nothing and the parameter appears once somebody has
chosen.

The DEVICE deliberately stays in React state. It is a way of looking rather than a thing being looked
at, and nobody has wanted to send one. Worth keeping that distinction deliberate rather than letting
both drift into whichever place was convenient.

COLOURS AND TYPEFACES ARE TABS. They were two stacked cards taking as much height as both together, and
every pixel of it came off the preview — which is the thing being judged. One at a time costs a click
and gives the page back most of the panel.

The 55% cap on the controls is gone with them: one tab is short enough not to need capping, which is
the other thing this bought.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 05:00:21 +01:00
pastilhasandClaude Opus 5 dc45fb7f11 Layout shows the measure too
The content column's edges were drawn only by the page builder, and they answer a question the Layout
section has just as much: the page is held to max-w-7xl and centred, so on a wide screen the content
stops well short of the window and nothing marks where.

`paintMeasure` is its own function now, called by both. The builder also draws sections, columns and a
drop zone; none of those belong in Layout, whose bands are code and cannot be rearranged from there.
What the two share is exactly the measure and nothing else.

Nothing in View, for the same reason as the builder: chrome of any kind means you are not looking at
the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 04:56:26 +01:00
pastilhasandClaude Opus 5 5b1c5a2590 the URL says which page, not which row
`?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>
2026-08-31 04:42:16 +01:00
pastilhasandClaude Opus 5 b7c887a9e8 Free Text, written from inside the page
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>
2026-08-31 04:38:27 +01:00
pastilhasandClaude Opus 5 a79dc2b1af the page builder gets Edit/View and the three widths
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>
2026-08-31 04:28:51 +01:00
pastilhasandClaude Opus 5 fb651f06e2 the chrome stays inside the measure, and the view stays centred
Two things the guides made visible the moment they existed.

THE SKELETON CROSSED THE LINES. Add section was appended to `main`, which is the full width of the
page — the content column is what holds it to its measure. So the drop zone ran edge to edge and
straight past the guides, describing a place a section could go that no section will ever occupy. It
goes in the column now, falling back to main for a page that opts out of the measure, where the column
genuinely is the page.

The section outline had the same fault in miniature: `outline-offset: 4px` puts it OUTSIDE the element,
so a band filling the column drew its edges past the guides. Inset now. Editor chrome that overflows
the thing it describes is chrome that lies about it.

THE VIEW WAS LEFT-ANCHORED. Narrowing the panel showed the left margin and the start of the content —
the least informative part of a centred layout and never the part anybody is looking at. The scroller
now starts in the middle, because the page is centred on a real screen and the preview should be too.

On the scroller, not a transform. A transform is the scaling this just stopped doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 04:26:02 +01:00
pastilhasandClaude Opus 5 01fd0807c6 the preview is 1:1, and the measure is visible
TWO CHANGES, and the first is an argument I got wrong.

The frame was scaled to fit the panel with a transform. That made the preview change apparent size as
the panel was dragged, so the same page looked different at every panel width and nothing could be
judged against anything. A preview is 1:1 or it is not a preview.

So it stays at the device's real width and OVERFLOWS when there is not room — a scrollbar rather than a
lie. The width was never negotiable anyway: the site's media queries answer it, so a frame given the
panel's width renders the tablet layout and calls it desktop. What changes is only what happens when it
does not fit, and scrolling is the honest answer.

THE MEASURE, as two fixed lines and a width in pixels.

The page is held to max-w-7xl and centred, so on a wide screen the content stops well short of the
window and nothing says where. That is invisible until something is placed against the edge and turns
out to be in the wrong place — which is the moment it becomes expensive.

Fixed rather than absolute, so they stay while the page scrolls; a guide that scrolls away is a guide
for the top of the page only. Not drawn at all when the column IS the full width, because two lines
against the window edges are noise claiming to be information.

One more site marker: data-content-column. Four attributes now, still no behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 04:23:14 +01:00
pastilhasandClaude Opus 5 66812587fd the preview painted nothing, because its ref was rebuilt every render
The iframe's ref was an inline callback that reset `loaded` to 0 when called with null. React treats an
inline ref as a DIFFERENT ref on every render, so it detaches and reattaches each time — calling the
callback with null, then the node. So `loaded` went back to 0 on every render, which re-rendered, which
reset it again. The injection never survived long enough to run and the page came up with no controls
at all.

An object ref again, and the reset moved into an effect keyed on `reloadToken`, which is the thing that
actually means "a new document is coming".

Same lesson as `useClient` two files along, and the third time this session: a value rebuilt every
render must not be the thing something else keys on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 04:19:14 +01:00