Commit Graph
167 Commits
Author SHA1 Message Date
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
pastilhasandClaude Opus 5 2ba9d5f303 the builder draws the sections it finds
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>
2026-08-31 04:11:41 +01:00
pastilhasandClaude Opus 5 e2a74e3337 Add section asks what shape, and creates one
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>
2026-08-31 04:06:27 +01:00
pastilhasandClaude Opus 5 5af0d9d9d1 the builder's chrome is drawn inside the rendered page
A component that lives at the PLUGIN level and appears on the site's page: an empty drop zone with an
Add section button, injected into the frame at runtime. Nothing of it exists in the website's source,
so the site never carries code only an editor would see, and the builder can change without the site
being rebuilt.

It works because the preview frame is SAME ORIGIN — through the platform's dev proxy, on Officer's own
host — so contentDocument is readable and writable. That single fact is what the page builder rests on,
and it is why swapping this plugin's hand-rolled cross-origin proxy for the platform's mattered beyond
the bugs it fixed.

SitePreview grew one prop, `onDocument`, called with the frame's document after every load and
returning a cleanup that runs before the next one. So a reload never leaves two sets of controls
behind, and nothing injected outlives the document it went into.

Plain DOM rather than React. Portalling would mean running a reconciler against a document that reloads
underneath it whenever the dev server rebuilds; these are a handful of elements with a click handler,
and this way a reload is a document that no longer exists rather than a tree React still thinks it owns.

Inline styles, not classes. The frame carries the SITE's stylesheet, built by scanning the site's own
templates — a class invented in this file was never scanned and would silently do nothing. The button
takes its colours from --site-fg and --site-bg, so it belongs to whatever theme the site is wearing.

The page's existing content is HIDDEN rather than removed, so the same call will leave real blocks
visible once there are any.

The panel counts the presses for now. That is the only visible proof the wire runs both ways, and it
becomes the section picker when there are sections to pick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:57:35 +01:00
pastilhasandClaude Opus 5 90fa69d95a the address follows the menu item, and Home stays put
Three changes to the page list.

MENU ITEM, ADDRESS, PAGE TITLE — the order somebody fills them in. The menu item is what you type first
and the address falls out of it, so they sit together. The title is last because it is the one that
usually stays as it is: it is the browser tab and the search result, not anything on the screen.

THE ADDRESS DERIVES FROM THE MENU ITEM, while the two still AGREE.

"Unless it was changed after" needs to know whether it was changed, and there is no such flag in the
database. There does not need to be: an address that still equals what the menu item would produce has
plainly not been given one of its own, and one that differs plainly has.

That distinction is the whole value. Renaming "Collabs" to "Collaborations" moves /collabs, which is
what anybody would expect. Renaming "Exclusive Drops" does NOT move /drops — somebody chose that
address, and every link to it would break silently. The rule tells those apart without being told.

HOME HAS NO ARROWS, rather than two dead ones, and the first movable row cannot climb past it. A
disabled control invites somebody to work out why; there is nothing to work out.

The slug rule is a copy of the server's, and the kind worth having: the alternative is a request per
keystroke to be told what the address will be. It is only ever a suggestion — the server slugifies
again on the way in, so a disagreement produces a tidier address, never a wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:36:26 +01:00
pastilhasandClaude Opus 5 a4c77c642f a selected page opens the builder, not a highlighted row
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>
2026-08-31 03:30:44 +01:00
pastilhasandClaude Opus 5 a0f23d70bb Pages, with the pages under it in the nav
The section is real, and the nav draws the site's pages as rows beneath it. That is the one place this
admin has a hierarchy and it earns it: a page is a thing you go to, and the list of them is the site.

Drawn whether or not Pages is the open section — hiding the list until you click makes the nav a menu
of menus.

Three kinds of change, saved three different ways, and the split is deliberate. TEXT — a title, a menu
label, an address — is drafted and written by the one Save in the bar, because typing is something
people do halfway and change their minds about. ADDING, DELETING and REORDERING are immediate: each is
a single deliberate gesture with an obvious result, and holding them in a draft would put a list on
screen that does not exist yet, then leave Save to decide what order to apply three kinds of change in.

Only the rows that actually changed are sent, so a save touches what somebody edited and nothing else.
A failed save reloads from the server rather than leaving the typed values on screen, where they would
look saved.

Home's address and delete button are ABSENT, not disabled. A greyed control invites somebody to work
out why they cannot use it; there is nothing to work out, the front page is always `/`.

The two panels share the list through a channel rather than both fetching it. They are siblings and
neither can pass the other a prop, and two fetches means two lists that agree until somebody renames a
page. Selection stays in the URL — `?page=` — because that is addressable and the list is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:25:03 +01:00
pastilhasandClaude Opus 5 dfe99d5232 the preview restyles itself as you change things
No save, no request, no reload. The panel sets the CSS custom properties on the frame's own <html> and
the page restyles itself, because those are the variables it already reads.

This is only possible because the frame is SAME ORIGIN — it comes through the platform's dev proxy on
Officer's own host. It is the payoff from dropping this plugin's hand-rolled cross-origin proxy, and
the reason that swap was worth making beyond the bugs it fixed.

`removeProperty` where a value is null, not an empty string. Setting the variable to nothing is not the
same as unsetting it: the page's `var(--site-bg, #fff)` fallback would never come back, so choosing
"default" would fail to restore the default — the one case somebody would test.

Applied on load as well as on change, because a reload replaces the document and the properties go with
it. Keyed on the serialised theme rather than the object, which is a new identity every render — the
same trap that hung this component on "Connecting…", one level down.

The property names mirror themeStyle() in the site's layout.tsx and cannot share code across the two
repositories, so this is a copy that can drift. Said so in the file, with the check: the saved page is
rendered by the site's own function, so if live and post-save disagree, this copy is the wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:49:31 +01:00
pastilhasandClaude Opus 5 9c1b58af27 the preview takes the full height
It was capped at the device's own height, on the reasoning that a 390x844 phone drawn 1200 pixels tall
is not a phone. That was wrong, and the empty band under the preview in View was the tell.

Media queries answer WIDTH. The layout at 390 is the phone's layout whatever the height, so a taller
frame shows more of the page rather than a different device — a scroll position, not a viewport class.
Width stays the device; height is now simply the room there is.

DEVICES carries widths only. The heights had exactly one consumer, and it was the cap.

One consequence, and it is the right one: the footer is min-h-dvh, so it sits at the bottom of the
FRAME. A taller frame puts it lower, exactly as a taller browser window does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:46:14 +01:00
pastilhasandClaude Opus 5 7d43517be0 the preview takes the room, the controls take what they need
Edit put a fixed 420px band above a scrolling column, so the preview was a letterbox in the mode where
changes are actually being made — and there was empty panel below the controls while the thing being
judged was squeezed.

One column now, for both modes, and it never scrolls as a whole. The preview is flex-1, so it is always
as tall as whatever is left. The controls are shrink-0, so they are exactly as tall as their content
and no taller. Edit takes height from the preview and only as much as it needs.

Capped at 55% with their own scroll, because shrink-0 alone would let a short window hand the controls
everything and leave the preview at nothing — the same failure in the other direction.

The frame is also capped at the device's real height. A 390x844 phone in a 1200px panel would otherwise
be drawn as a phone 1200 pixels tall, which is not a phone. Past that it stops growing and the space
stays empty, which is honest: there is no device that shape.

Two modes were two return paths; now switching modes hides a block rather than rendering a different
component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:43:42 +01:00
pastilhasandClaude Opus 5 97aa78204b the typeface picker names the current face
"Default" told somebody nothing. The question they are asking of that dropdown is "what is it now, and
what would I be changing it from" — so the empty option now reads "Martian Title (site default)", from
the stylesheet that actually decides it.

A label that refuses to answer makes people change the setting to find out, which is the one action
that loses the answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:33:46 +01:00
pastilhasandClaude Opus 5 3669d2a405 open in View, not Edit
Opening a section is nearly always to LOOK at something — to check what a change did, or to see where
the site stands before deciding anything. Editing is the deliberate act, so it is the one that should
cost a click. Defaulting the other way puts a wall of controls in front of the thing they act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:30:06 +01:00
pastilhasandClaude Opus 5 143b34d2d2 Edit/View on the left, three widths in the middle
The bar is three groups now: how you are looking at it, at what width, and what to do with it. The
middle is centred against the BAR rather than against its neighbours, because the outer two change
width as sections come and go and a middle that drifted with them would never be in the same place
twice.

A section OPTS IN, the same way it opts into Save. Most have no preview — Media is a file browser,
Administration is a connection form, Messages is an inbox — and offering to view any of them at tablet
width offers something that means nothing. A control that does nothing teaches people that controls do
nothing.

The widths are REAL viewport sizes and the frame is SCALED to fit, never squeezed into whatever the
panel has. That distinction is the whole point: an iframe given the panel's width has the site's own
media queries answering the PANEL's size, so "Desktop" in a narrow panel renders the tablet layout and
labels it desktop — the one thing a preview must not do. Never scaled up either: a 390px phone blown
up to fill a wide panel is a phone nobody has, and every judgement made against it is wrong in the
direction of "looks fine".

390 is the iPhone width slots.ts already assumes. 834 is iPad portrait, deliberately above the site's
md: breakpoint at 768 so it shows the desktop nav rather than the drawer — 768 exactly sits on the
boundary and is the least informative width available. 1440 is past the 1280 the content column caps
at, so the margins either side are visible rather than implied.

View gives the preview the whole panel. That is what makes it worth having: at edit height a phone is
a letterbox, and a phone is the width somebody most wants to look at properly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:26:06 +01:00
pastilhasandClaude Opus 5 9565d24536 the Menu and Footer editors are gone
The header and the footer are designed — they belong to whoever writes the site's code, not to whoever
edits its content. A panel offering to rearrange them offers a power that should not exist, and every
use of it would be a decision made in the wrong place.

What is left in Layout is the part that really is a content decision: the colours and the four typeface
roles. The preview above them is still the real page, so what the code decides is visible here even
though nothing here can change it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:19:02 +01:00
pastilhasandClaude Opus 5 8a152f5b63 the preview hung on "Connecting…"
`useClient()` rebuilds its verbs on every render, so `get` has a new identity each time. It was in the
effect's dependency list, so the effect re-ran on every render — and its first act is setReady(false),
which renders again, which builds another `get`, which re-runs the effect. The frame never mounted.

Through a ref instead, with the effect depending only on the port.

This trap is documented in CLAUDE.md and it is the same one that silently disabled every playback
report in the Jellyfin player. The platform's own Preview panel sidesteps it by depending on neither
the client nor the URL, which is what this now does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:08:54 +01:00
pastilhasandClaude Opus 5 82862ed340 use the platform's dev proxy instead of a worse copy of it
The preview went through a proxy written into this plugin. The platform already had one —
/api/dev-proxy/:port/* — and it is better in exactly the ways the copy was worst.

Authentication. An iframe navigation cannot carry an Authorization header, and the copy answered that
by putting a token on the URL and on every asset the page then requested. The platform's answer is a
short-lived cookie obtained by an ordinary authenticated request, and its own comments say why: a
token on an iframe URL is a guess about which key the browser stored it under. It had been tried
there and rejected before this plugin repeated it.

Coverage. The copy rewrote URLs with a regex over the markup, which catches only what is written into
the document. The platform injects a script that patches fetch, XMLHttpRequest and WebSocket, so URLs
the page BUILDS at runtime are moved as well — which is most of them in a modern dev server. It also
normalises `..`, forwards HMR, and has nineteen tests behind it.

So the plugin's proxy is gone, previewUrl with it, and the panel does what the platform's Preview
panel does: ask for the session, then mount the frame at the dev port.

No sandbox attribute, for the reason that panel gives. Without allow-same-origin a frame has an opaque
origin and no storage; with allow-same-origin and allow-scripts it can reach up and remove its own
sandbox attribute. Claiming a protection that does not hold is worse than not claiming it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:07:18 +01:00
pastilhasandClaude Opus 5 6f8dc83984 the preview iframe was rendering Officer inside Officer
Two mistakes, and the second would have bitten immediately after the first was fixed.

The iframe pointed at /fofos-admin/preview/layout. But /fofos-admin is a FRONTEND route — the
single-page app owns it, and anything under it no router claims is answered with the app's own
index.html. So the iframe was served Officer. The plugin's API is mounted at /api/fofos-admin, which
every other call already knew: useClient defaults its base to /api, and thumbnail.ts spells the full
path out for <img> tags that cannot use it.

And an iframe cannot send an Authorization header. Neither can the stylesheet, the fonts or the images
the page goes on to request — those are all Officer requests now, so all of them need ?token=, the
same reason every <img> in this plugin carries one. The token comes off the iframe's own query string
and is propagated into every URL the rewrite touches, with the separator decided per URL because many
already carry the site's ?v= stamp.

It is stripped before the request reaches the site. The token is Officer's and the site has never
heard of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:01:18 +01:00
pastilhasandClaude Opus 5 b56c8375c3 the preview is the real page, proxied
The panel drew its own header and footer in React. It looked plausible and it was wrong — the site's
header has a logo hanging past a rule and a nav in the content column — and worse, it was a second
implementation of the layout, so it would have gone on being wrong differently every time either side
moved.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 01:55:45 +01:00
pastilhasandClaude Opus 5 09b914c430 Layout replaces one section per page
The admin was a section each for Home, About Us, Collections, Gallery, Collabs, Drops and Services,
because the site's pages were a constant in site-map.ts and a developer was the only one who could add
one. A page builder is going to replace that, so the nav stops enumerating pages.

What is left is the two things a page is NOT: Layout — the shell every page renders inside — and Pages,
listed but not built. Layout is first because it is first in the work: building pages before the frame
they sit in means building them twice.

The panel is a sketch of the page with the controls under it. Menu across the top, a body, the footer
at the bottom, in the chosen colours and typefaces — the same principle the header editor already
follows, where the admin renders the structure as the page will show it. It is honest about being a
sketch: drawing the logo and the rules would be a second implementation of the layout, and the two
would drift the first time either changed.

Fonts are a mapping, not free text. styles.css already declares four ROLES — body, sub, title,
display — and every template asks for a role rather than a typeface, so choosing a font is choosing
which of the four faces the build subsets plays each part. The list travels with the payload rather
than being copied here, because a picker offering a face the build never subsets renders as a fallback
nobody chose.

The per-page panels are NOT deleted. AboutPanel, HomePanel and RecordsPanel are still in the tree and
still work; they are unreachable, which is a line in sections.ts rather than a rewrite. The page
builder is not finished, and removing the only way to edit the live site's content before its
replacement exists would leave a gap with nothing in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 01:38:05 +01:00
pastilhasandClaude Opus 5 72edabdc8f the development server gets av-club too
The cropper needs an encoder, and only the build was told where one is. That made development the one
place the old behaviour survived: the framing approved in the admin, the picture dev showed, and the
file that shipped would have been three different things — which is the class of bug this whole change
exists to remove.

The port is ephemeral by design, so it is read at start rather than written to a file. av-club moving
leaves dev pointing at a port that has gone; restarting dev from the panel fixes it, and startServer
already deletes and re-creates precisely so a changed port is picked up.

/images/ gets a year and immutable in the nginx config. Earned rather than assumed: the filename is a
hash of the original, the framing, the slot and the width, so the bytes at a URL cannot legitimately
change. That is the difference from /derived/, which is rewritten in place and therefore capped at
thirty days.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 01:16:42 +01:00
pastilhasandClaude Opus 5 671097877e the controls live on the block, and the picker is native again
The palette was not enough colour, and it was never going to be. What it replaced — the browser's own
picker — is the full colour space with an eyedropper in it, and it only ever failed because of WHERE it
was: at the right-hand edge of a narrow panel, where its popup opened off the side of the window.

So the controls move to the block instead of the picker being rebuilt. A toolbar pinned to the selection
frame, in the middle of the picture, where the native popup has room. Above the frame, or below it when
the block sits near the top edge — a toolbar drawn off the top is the same unreachable control again.

Everything is on it now: size, alignment as three buttons rather than a select, colour, and delete. The
row per overlay under the canvas is gone. It was a second place to look — the block up here, the thing
that changes it down there — and every adjustment cost a glance each way.

The text is typed where it appears. Double-click, or the pencil. The contentEditable is uncontrolled on
purpose: re-rendering its children from state replaces the text node the caret stands in, so the caret
jumps to the end on every keystroke. The DOM owns the text while it is being typed and the effect only
writes back when the two have genuinely diverged.

Three things that follow from editing in place. A press on a block being edited places the caret and
does not start a drag. Enter is prevented, because a <br> does not survive textContent and the break
would vanish on save — width decides the breaks, as it already did. Paste is forced to plain text,
because a contentEditable takes markup whole and none of it can be stored or drawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 00:56:27 +01:00
pastilhasandClaude Opus 5 e0a5f1b653 the colour picker stays inside the panel
It was `<input type="color">`, and the browser's own picker is a window-level popup positioned by the
browser. In a panel near the right edge it opened off the side of the window and could not be reached —
and nothing in CSS can move it, because it is not in the document.

Swatches and a hex field instead, in a panel anchored to the RIGHT of its trigger so it grows inward
and cannot leave the screen the same way.

Deliberately not a gradient area and a hue strip. That is what the native control does well, and
rebuilding it badly to fix a positioning problem would be a worse control than the one being replaced.
Text over a photograph is white or black almost every time; the hex field is there for the times it is
not.

Closes on `pointerdown` anywhere else rather than `click`, so it closes on the way down and a press
meant for something behind it does not land twice. Captured, because a control below may stop
propagation for its own reasons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 00:39:12 +01:00
pastilhasandClaude Opus 5 b3032093fe a selected overlay gets a frame and a resize handle
Clicking a block selects it: an outline appears, its row below highlights with it, and a handle on the
edge it grows from resizes the box. Clicking the picture deselects, which is how somebody says "none of
them" without hunting for a button.

Only the selected one is outlined. Every block framed at once turns the picture into a wireframe and
hides the thing actually being judged, which is how the text looks ON the photograph.

The block now has a width, and that is what fixes the dragging. Without one it was as wide as its
longest line, so it changed shape as it was typed and there was nothing stable to take hold of. Text
wraps inside the box now, so a line break is a decision rather than an accident of the current text.

The handle follows the alignment — on the left for a right-aligned block — because it belongs on the
edge the box grows from, and dragging it inwards would otherwise shrink from the wrong side. Its grab
area is four times the visible bar: a 3px target is a target nobody hits.

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