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>
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>
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>
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>
?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>
?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>
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>
`?page=about` instead of `?page=2`. A URL that names the page can be read, sent to somebody, and still
means the right thing after a database is restored from a dump with different ids — which happened
once in this project already, earlier today.
Home needs a word, because its slug is empty by design: that is the check constraint, and it is what
makes `/` fall out of joining a slug to the root. An empty string is no use in a query parameter that
has to name a page unambiguously, so the admin says `home` — and the site's API now REFUSES that slug,
which is what makes the mapping total in both directions rather than merely usual.
One reserved address costs nothing. Anybody who wanted /home wanted the front page, which they have.
One place maps a page to its URL value and back — pageParam and pageFromParam — so the nav, the detail
panel and the builder cannot disagree about what `?page=` means.
`record` is deleted on every navigation. It belonged to the per-page record panels, which are
unreachable now; nothing reads it, so it was not a bug, but every URL anybody copied carried a
parameter naming a row in a section that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every column gets Add component; every component in one gets Edit and Delete, drawn ON the rendered
thing rather than replacing it. An editor that swaps content for a placeholder is showing you the
editor, not the page — and free text with nothing in it renders as nothing, so the outline is the only
thing saying it is there.
Add component opens the editor STRAIGHT AWAY. Free Text is the only kind, and one option is not a
choice: a picker offering it would be a click asking to be got past. The picker arrives with the second
kind, which is when it starts meaning something.
The add button is quieter than Add section. This is the inner loop, and a control as loud as the one
that adds a whole band would make every column shout.
── The dialog, and the limitation worth naming ──
Editing happens in a modal, not in the page. In-place is the obvious idea and a much bigger one: the
toolbar would have to live in a document the site owns, the site's typography would fight it, and every
keystroke would cross into another document. The dialog is where a working editor already exists, and
it costs a modal instead of a rewrite.
So what you type is not laid out in the page until you save. The preview reloads immediately after, so
the loop is short — but it is a loop, and direct manipulation is what a page builder eventually wants.
The editor is keyed on the stored html, so opening a different component rebuilds it rather than
reusing one still holding the last component's document. Empty text saves: clearing a paragraph is
deleting its words, not abandoning the edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same bar Layout has, from the same opt-in: the section says it has a preview and the controls
appear. Pages — the list — still says nothing, because there is nothing there to view at tablet width.
VIEW paints nothing at all. Not dimmed chrome or hidden buttons: no injection, so the frame holds
exactly the document the site served. That is what View has to mean here — the outlines, the labels and
the drop zone are the editor's, and a preview that keeps any of them is not showing you the page.
Done by putting `mode` in the callback's dependencies rather than by branching on it somewhere else.
Switching hands SitePreview a new function; its effect runs the previous paint's cleanup and then does
nothing. The chrome goes without a reload and without a second code path that could disagree with the
first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
An empty section renders as nothing. That is correct on the public site and useless in an editor:
somebody who has just chosen "two columns" sees a blank page and has to take it on trust.
So the canvas outlines every band it finds, names it, says its shape, and offers Delete — and outlines
the columns inside it, because a one-column band and a two-column band are identical until something is
in them, which is exactly when it is too late to notice the wrong choice.
The site provides three attributes and no behaviour: data-page-empty, data-section-id, data-column.
Markers rather than classes, so they cannot be mistaken for styling, cannot be purged by a build that
has never heard of this file, and read as what they are. That is the whole contract — one attribute on
the public site buys the entire editor.
Section names and layouts are FETCHED, not read out of the DOM. The markup says which row a band came
from and nothing else; inferring the layout from its classes would be the admin reading the site's
stylesheet, which is the coupling all of this exists to avoid.
── The race, and why the fix is not another ref ──
Creating a section starts two things that do not finish in a fixed order: the frame fetches a new
document, and the panel fetches the sections to draw on it. Painting only on load meant painting with
whatever had arrived by then, which after a create was usually the data from before it existed.
So SitePreview re-runs the injection when EITHER moves — `loaded` is state, `onDocument` is a
dependency, and whichever arrives second triggers the paint that counts. The caller then controls
repainting through its own useCallback dependencies, which is what those are for.
The handlers still go through a ref, and the data does not. Callbacks are rebuilt every render and
would repaint on every one; the sections are the thing being drawn and should be exactly what decides
when to redraw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing the button drawn inside the page opens a dialog: a name, and a choice between one column and
two, then Create section. The row lands in the database and the frame re-renders to show it.
The layouts are DRAWN rather than named. "One column" is words for a shape, and a shape is faster to
recognise than to read; the words stay underneath for anybody the picture does not reach.
The name is asked for and not generated. It never appears on the site — it exists so a page of eight
bands is navigable, and "Section 4" tells nobody anything, which is exactly the state a generated name
would leave every page in. Asked at the only moment somebody knows what they are about to build.
The frame is remounted after a write rather than the admin drawing the new band itself. The site
renders from the database; the admin does not know how to render a page, and if it learnt it would be
a second implementation of the very thing this preview exists to avoid.
The canvas now finds the empty-page placeholder by its `data-page-empty` marker instead of taking
`firstElementChild`. That was right for exactly as long as an empty page was the only kind — the moment
a page has a section, the first child IS that section, and it would have hidden the thing being edited.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A component that lives at the PLUGIN level and appears on the site's page: an empty drop zone with an
Add section button, injected into the frame at runtime. Nothing of it exists in the website's source,
so the site never carries code only an editor would see, and the builder can change without the site
being rebuilt.
It works because the preview frame is SAME ORIGIN — through the platform's dev proxy, on Officer's own
host — so contentDocument is readable and writable. That single fact is what the page builder rests on,
and it is why swapping this plugin's hand-rolled cross-origin proxy for the platform's mattered beyond
the bugs it fixed.
SitePreview grew one prop, `onDocument`, called with the frame's document after every load and
returning a cleanup that runs before the next one. So a reload never leaves two sets of controls
behind, and nothing injected outlives the document it went into.
Plain DOM rather than React. Portalling would mean running a reconciler against a document that reloads
underneath it whenever the dev server rebuilds; these are a handful of elements with a click handler,
and this way a reload is a document that no longer exists rather than a tree React still thinks it owns.
Inline styles, not classes. The frame carries the SITE's stylesheet, built by scanning the site's own
templates — a class invented in this file was never scanned and would silently do nothing. The button
takes its colours from --site-fg and --site-bg, so it belongs to whatever theme the site is wearing.
The page's existing content is HIDDEN rather than removed, so the same call will leave real blocks
visible once there are any.
The panel counts the presses for now. That is the only visible proof the wire runs both ways, and it
becomes the section picker when there are sections to pick.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
"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>
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>
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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>
The wireframe puts two blocks of text on the header picture. The page had one `phrase`, drawn under it.
`header_overlays` is a jsonb list — `[{ text, x, y, size, color, align }]` — rather than a pair of
`header_text_1_*` columns. The design asks for two today; a third is then data rather than a migration,
and nothing has to decide what an empty second block means. `headerPhrase` is left alone: folding them
together would silently move somebody's existing copy on top of a photograph.
Everything is relative and has to be. x and y are percentages of the picture, so a position means the
same thing on a phone and a desktop. `size` is a percentage of the picture's WIDTH, because a headline
set at 40px is a headline on a laptop and a caption on a large screen. The preview renders that with
container query units against the picture's own box, which is exactly what the percentage means — the
page will do the same, so the two cannot drift.
The editor is the picture with the text on it, at the shape the page uses, because where a line sits on
a photograph is not a decision anybody can make from two number fields.
The API clamps every field rather than rejecting: a position into the picture, a size into something
legible, the colour to a hex triple. A caller sending nonsense gets a usable overlay instead of a 400,
and the page can never be handed a position that puts text off the edge of its own header.
Pushed to both databases and verified by querying information_schema rather than by reading db:push's
exit code, which is documented to lie.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"The About page, in the order it is shown: a header, the text about you, the product, and the
manifesto." above four bands headed Header, About us, Product and Manifesto. "The photograph at the top,
and the line over it" above a photograph at the top and a line over it. "Who you are" above a box headed
About us.
Every one was a caption for something already visible, and together they were most of the vertical
space before any content — so the actual work started below the fold on a laptop.
The person using this is not a developer and is not a child. A heading names a thing; a sentence
underneath saying what the thing is says it twice and trusts them less each time.
Removed from About, Home, Galleries and the record sections. What stays is anything reporting STATE
rather than describing furniture — a count of photographs, a loading line, an error, a save
confirmation — because those say something the screen does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It multiplied by 1.25, which compounds: the same button was a 0.25x step at fit and a 1.75x step at 7x,
so the first press did almost nothing and the last threw the picture across the frame.
Added rather than multiplied, so every press is the same amount of change — which is what somebody
nudging a crop into place is asking for, and it makes the number beside the buttons predictable, which
a factor never is.
Rounded to a tenth, because floating point turns 1 + 0.1 + 0.1 into 1.2000000000000002 and the label
shows it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wheel was the obvious gesture and it cannot be made to behave. To zoom without also scrolling the
panel it has to call preventDefault, which means the page stops scrolling whenever the pointer happens
to be over a picture — and a panel of slots is mostly picture. Not calling it means both happen at
once. There is no third option.
So `+` and `−` on hover, top right, grouped and left of Change and Remove: they are the pair somebody
presses repeatedly, and a destructive Remove sitting between them would be pressed by accident
eventually.
Anchored to the centre of the current window rather than to a pointer, because a button has no pointer
position — and that is what somebody expects from a `+` anyway: the middle of what I am looking at
stays where it is.
The level is shown between them. A press that changes nothing at 1.0x or 8x is then explained rather
than read as a dead button.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preview asked `derived/` for a 640-wide rung and drew it about 1900 pixels wide — stretched three
times over, which reads as a poor photograph rather than as the wrong file. Raising the rung only moves
the problem: the box is most of a panel wide and can be zoomed on top of that, so any rung chosen in
advance is chosen wrong, and the widest one available is whatever the ladder happens to hold.
The original has every pixel there is. It is also, often, a HEIC that no browser can decode — which is
what av-club is for, and the platform already renders those for its own file browser through exactly
this path.
So there is a `/original/*` route: it resolves a path inside `originals/`, forwards to av-club's
`/v1/render` with a width, and streams back something a browser can draw. It decodes nothing itself.
That is the standing rule and the reason there is no image library in this plugin.
Not cached on disk, like the platform's own /render and for the same reason: a full-size render is
large, looked at once, and keyed on a width the panel chose. Caching it would fill a disk to make the
second look at one photograph faster.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A photograph is rarely the shape of the slot it goes in, so something decides which part survives. It
was a centred crop and no way to change it.
Dragging the picture slides the window; the wheel zooms, anchored to the pointer so the thing under the
cursor stays under it. Both write the site's own format — `x,y,w,h` in percentages of the ORIGINAL —
which is resolution-independent and survives the file being re-encoded.
Zooming in is the window getting smaller: w and h shrink together, keeping the slot's shape. Fit is the
floor, because you cannot show more of a photograph than there is, and at exactly fit the crop is
cleared rather than written as the centred value — a null crop and a crop that happens to be centred
should not be two states.
── The preview is not object-cover ──
It draws the rectangle: the picture scaled to `100/w` of the box and offset so the chosen corner lands
at the origin. object-cover shows the largest window of the slot's shape that fits and nothing else —
it can be panned and it cannot be zoomed, so a preview built on it would silently ignore half of what
this control produces.
── Also here ──
buildSite now hands the site an av-club URL and user. That is the first piece of publishing images
cropped rather than cropping them in the browser with CSS: the plugin knows the sidecar registry, the
site knows a URL. Inert until the site uses it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was a read-only text box holding a path and a Browse button beside it. A path is not what anybody is
choosing — somebody picking the header photograph is choosing a PHOTOGRAPH, and reading
`ABOUT US/HEADER.jpg` to find out whether you picked the right one is doing the computer's job for it.
MediaField is the slot: empty, it is a dashed box at the page's own aspect ratio that you click or drop
onto; filled, it is the picture, with Change and Remove appearing over it on hover. The panel also stops
changing height as things are chosen, so a page with three slots starts to look like the page rather
than like a form.
── The aspect ratio is a prop and has to be ──
The header is 21:9, the product video 16:9, a gallery slot square. The component cannot know that and
must not guess: the SITE decides what shape a slot is, and a component that guessed would be a second
opinion to keep in step with the stylesheet. Both callers name the ratio their partial draws.
── Dropping ──
It accepts a drag from Officer's own file browser, which carries paths under
`application/x-officer-files` — the same payload that moves files between windows. So dragging a
photograph out of Media onto a slot is one gesture and no upload.
It does not accept a file from the desktop. That is an upload into originals/, a different operation
with its own failure modes, and the Media section already does it properly.
── Also ──
`thumbUrl` was copied into GalleriesPanel and RecordsPanel and was about to be copied a third time. It
is a module now: three copies of a URL shape is three places to be wrong about the rung when the ladder
changes, which it did four times in a day.
A file that has gone since it was chosen falls back to the empty state rather than a browser's
broken-image glyph, and says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every section had its own Save buttons — four on About, two on Home, one per record — and on a page
long enough to scroll, the button you wanted was the one that had just left the screen.
Now there is one, beside Publish, pinned above the scroll. The section announces whether it has unsaved
changes, whether it is busy, and how to save; the bar renders that. Neither knows what the other is,
which is what keeps DetailPanel a map of keys to components rather than a place that has to know what
each section can do.
── What this replaces ──
The per-card saves existed because the API treats an absent key as "leave it alone", so a Save carrying
everything meant a half-written manifesto shipping with a photograph somebody meant to change. With one
Save that is the model rather than an accident: one press writes the page you are looking at.
The cost, stated plainly: there is no longer a way to commit one part of a page and leave the rest.
Nothing here needed that, and if something ever does it wants its own verb rather than four buttons all
called Save.
── Dirty is a comparison, not a flag ──
Against what the server last returned, field by field. A flag says "something was typed" and stays true
after you undo it by hand; a comparison says "this differs from what is stored", which is the question
a Save button is actually answering — and it is what lets the button be dead when there is nothing to
do, which is the whole value of having one.
The gallery picker on Home is deliberately outside it and still writes on selection: a <select> that
needs a second press to mean anything is a control people believe they have already used.
Sections with nothing to save render no button rather than a dead one. A permanently disabled control
invites a press and teaches that pressing does nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They were in the workspace's chrome header for an afternoon. Two things were wrong with that.
That header is shared furniture — every panel in the app draws it the same way — and these two buttons
belong to this plugin and to the site it administers, not to the frame around it.
And the panels scroll their own contents now. Anything drawn inside one leaves the screen as soon as
somebody reads to the bottom, which is exactly when they reach for Publish.
So the bar is a sibling of the scrolling region rather than the first thing inside it: `shrink-0` above
a `min-h-0 flex-1`, which is the same arrangement that makes the panels scroll at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four sections could not be scrolled: About, and Gallery, Drops and Collabs, which are three
configurations of one panel. Both of those panels were written yesterday with `overflow-auto` and no
height, so they grew to fit their content and ran off the bottom of the workspace instead of scrolling
inside it.
`overflow-auto` only does something on an element with a height to overflow. DetailPanel gives its
child `min-h-0 flex-1` inside a `size-full` column, so a panel that says `size-full` fills exactly that
box and scrolls within it — which is what Home, Galleries and Administration were already doing and
what these two were copied from without.
Messages had the same fault in a different shape. Its list is a flex child with `flex-1 overflow-auto`,
and a flex child defaults to `min-height: auto` — it refuses to shrink below its content, so the list
grew the panel rather than scrolling. `min-h-0` is what lets it shrink, and therefore what makes the
overflow mean anything.
Audited the rest rather than fixing the reported ones: every panel with an `overflow-auto` now pairs it
with a height, and the two that have none are centred single screens with nothing to scroll.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four bands in the order the page shows them, which is the sitemap's shape exactly: a HEADER of a
photograph and a phrase, then ABOUT US, then PRODUTO with a video beside its text, then MANIFESTO.
No schema change and no API change — `about_content` already carries every one of those columns and
`aboutRouter` already reads and writes them. This is the screen that was missing, not the data.
One save per band, the rule Home follows and for the same reason: `take()` treats an absent key as
"leave it alone", so a single Save carrying every field would let a half-finished manifesto ride along
with a photograph somebody meant to change.
Both media slots go through MediaPicker rather than a text field. A path somebody types is a path that
is wrong by one character and renders as a broken image. One picker serves both, with `picking` naming
which slot asked — two would be two copies of a modal that already knows how to browse originals/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It sits behind the proxy that terminates TLS, so it believes it is serving plain HTTP. Asked for
/home/gallery/16 it correctly redirects to the directory and wrote the Location as
http://host/home/gallery/16/ — downgrading an HTTPS visitor to HTTP for a round trip before the proxy
sent them back where they came from.
absolute_redirect off emits just the path, and the browser keeps the scheme it already had.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They answer different questions, and the difference is not obvious from the words:
Publish copies what you edited in development over production, then renders the site.
Build renders production as it already is. Nothing moves.
Publish is the one to press after editing anything. Build is for when the content is already promoted
and only the code changed — a template fix, a new rendition ladder, a failed build to retry. Offering
only Publish would mean every retry of a build also re-copied a database and took another backup,
which is a lot of machinery for "try that again".
Build reads first and quieter, Publish last and solid: the row runs towards the action somebody
actually came for.
One busy state and one failure panel between them, because only one can be running.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Publishing is the last step of every edit — write something on Home, choose a gallery, add a drop, then
make it public. It lived in one card at the bottom of one section, so finishing a change meant leaving
the thing you were working on to go and find it. Somebody who does not know the panel does not find it
at all, and concludes their edit is live when it is not.
Administration's card stays. Two entry points to one action is right here: that card belongs to a row
about the production server and reports what a build produced, while this is the verb at the end of a
sentence somebody has just written.
It takes minutes and says so, because a button that greys out and does nothing visible for three
minutes reads as broken and the second press is somebody assuming the first did not register.
Failure opens a panel under the header rather than replacing the label. The server's own words are
usually a build error, which is long and worth reading, and a header is one line. Positioned absolutely
so nothing shifts, and it stays until dismissed — a message that vanishes on a timer is one somebody
was still reading.
Disabled until `useReady`, the same answer the nav uses to lock the sections, so the button is
available exactly when the sections are.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It existed to make a bisect bearable — rendering all 48 routes to measure one of them was most of the
wait. The bisect is over and the switch is not something to leave lying in a build script, because it
also disables the prune at the swap, and a build that quietly does not clean up after itself is worse
than a slow one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A smooth kernel interpolates. On a photograph that is right; on a drawing it is destructive in a way
that shows up as SIZE rather than as blur — a logo with 174 flat colours downscaled with lanczos3
comes out with thousands of interpolated ones, and the lossless encoder that was winning a moment ago
then has to describe every one.
Measured on fofos-logo-saffron at 160 pixels, all lossless:
lanczos3 13,356 mitchell + nearLossless 7,982
mitchell 9,854 nearest 3,436
The whole 300-pixel original is 8,886. Every smooth option produced a 160-pixel file heavier than the
full-size one, which is what "the ladder made it worse" looks like from the inside.
The cost is real and accepted: nearest at a fractional scale drops rows rather than blending them, so
edges alias. These renditions are shown smaller than they are and the browser's own downscale smooths
what nearest left behind — a 160-pixel logo in an 80-pixel slot is where this lives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It probed at 96 pixels and got the answer wrong on exactly the file it was written for. Downscaling a
logo to 96 resamples its flat areas into gradients, so by the time the probe sees it, it already looks
like a photograph. Lossy wins that comparison, and the verdict is applied to every rung above — where
the flat areas are still flat and lossless would have won by a wide margin.
fofos-logo-saffron came out with a 13,074-byte 160-pixel rung and a 22,432-byte 256-pixel one, beside
a 9,144-byte 300-pixel original. Three files larger than the thing they were made from.
480 now, or the source's own width when it is smaller — which is the case that matters, because that
is what a logo is. Not the full size: a picture is still recognisably itself at 480, and probing a
4000x6000 photograph losslessly to learn something already obvious is minutes of encoding across a
library.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
160 was still not a floor. A logo renders at 80 CSS pixels, which is 140 device pixels on an ordinary
phone and 80 on a plain one, and the smallest rung being 160 meant the plain screen had nothing to
take but the retina file.
96 and 128 cost a few hundred bytes each and only exist for things this small — a logo, an icon, an
avatar. Every photograph in the library skips them, because nothing is ever upscaled and a rung wider
than the source is not written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lossy WebP is built for photographs — it discards detail the eye does not miss in something full of
texture. A logo is the opposite: flat areas, hard edges, a handful of colours. Encoding one lossily is
worse than useless twice over. It softens the edges, and it makes the file BIGGER, because flat
regions are exactly what lossless compression is good at and exactly what lossy encoding turns into
gradients it then has to describe.
Measured, not theorised: fofos-logo-saffron.png is 300x300 and produced a 22,432-byte 256x256 rung
beside a 9,144-byte full-size one. A smaller picture, two and a half times the file.
The decision is measured too. Every heuristic for this is wrong somewhere — a PNG can hold a
photograph, a JPEG can hold a diagram, plenty of logos are opaque — so it encodes the smallest rung
both ways and keeps the answer. One extra encode of a 160-pixel image per original, and what comes out
is not an opinion about the file: it is the file saying which encoder suits it. Applied to every rung,
because an image does not stop being a drawing at 640 pixels. Recorded in the manifest, because
somebody wondering why one directory is shaped differently should find the answer rather than
re-derive it.
Two other things the logos exposed:
The ladder had no floor. It started at 320 and a logo renders at 80, so fofos-logo-black got a single
12,350-byte rung against a 12,678-byte original — a ladder that saved 328 bytes. 160 and 256 are now
the bottom, and 128 and 192 on the square side.
And square crops of things already square. Both logos are 300x300, so their square rung cropped
nothing: the same image under a second name. The aspect ladder already produces squares for a square
source. 2% tolerance, because a 1000x1002 photograph is square for every purpose this serves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three sections, one panel, configured — the same split the site's API already makes. Each is a titled
record with an ordered list of media hanging off it, and the only real differences are what the title
is called and which extra fields the record carries.
The htmx admin had three screens and that was defensible there, because each carried its own markup as
well. There is no markup to differ here, so three panels would be three places to fix one ordering bug.
The `fields` list in records.ts is the FORM for what the server allows, not a second authority over it.
What may actually be written is decided by `valuesFrom` in the site's sections.ts, per section, as an
allowlist — a key added here that the server does not accept is an input that silently saves nothing.
Which record is open lives in `?record=<id>` beside `?section=`, per docs/navigation-audit.md, and the
detail is loaded from that id rather than carried over from the list. That is what makes a typed or
reloaded URL work, and it is guarded against a slow response for a record nobody is looking at any
more overwriting a fast one for the record they opened next.
Ordering sends every id in the order it should be, so two items cannot claim one position. Media is
added one path at a time because the server appends, and a parallel add would land in whatever order
the requests happened to finish.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hero declares a sizes well under its real width on phones so the browser stops multiplying by the
pixel ratio and asks for roughly one image pixel per screen pixel. 448 is the rung that lands on a
412-wide phone once that cap applies; without it the nearest was 512.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The still is the input for three things — the placeholder, poster.webp, and now the poster rungs — and
it is removed once they are done. I added the rung loop BELOW the removal, so sharp was handed a path
that no longer existed and every video failed to convert.
The comment warning that these lines are ordered deliberately was three lines above the mistake.
The removal now sits after everything that reads it, which is where it should have been all along
rather than in the middle of the sequence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A poster was one file at the video's full size — 1280x720 for a hero displayed 412 CSS pixels wide.
Three times the pixels a phone can show, on the element that IS the page's Largest Contentful Paint,
which is the most expensive place on the site to be careless.
384, 512, 768, 1024, plus poster.webp as the widest rung. Those are viewport widths times the density
behind them: 412 at 1.75 wants 768, a tablet wants 768, a desktop wants 1280.
Cut from the still rather than from poster.webp, so each rung is scaled once from the original frame
instead of being a re-encode of an encode. poster.webp keeps its name — the placeholder is cut from
it, the site falls back to it, and a directory built before this has only that one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ladder was 320, 480, 640, and the middle of that was the problem. A gallery slot measures 193 CSS
pixels on a phone, so at 1.75x it needs 338 device pixels — which did not exist, so the browser
correctly took 480 and downloaded 1.4x the pixels it could use.
Now 256, 384, 480, 640, 960, which is 193 and 475 CSS pixels times the densities people actually hold.
960 is the other end of the same mistake: the ladder stopped at 640, so a retina desktop wanting 950
took 640 and showed it soft.
A rung is a few kilobytes of square WebP. A missing rung is paid for on every visit by everyone whose
screen falls in the gap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A gallery grid is square slots and the ladder is the picture's own shape, so a 480x720 portrait
arrived, object-cover cropped it to 480x480 in the browser, and a third of every file was discarded
before anyone saw it. Measured here: 108,388 bytes shipped where a 480x480 crop is 70,070.
Cropping in the browser is the right default when the shape is unknown. It is the wrong one when the
shape is known at build time, and for a carousel of square thumbnails it always is.
Thumbnail widths only — 320, 480, 640. Anything larger is a picture being looked at, and a square crop
is a loss of information that only makes sense when the slot demands it.
Centre, never sharp's `attention`. Smart cropping decides differently for every picture, so a row of
thumbnails framed by an algorithm has no common logic to it and the one it gets wrong cannot be
predicted or explained. Centre is what a photographer framing for a square already assumes.
It is a default: a placement carrying its own crop keeps the aspect file and frames it in CSS, because
that crop is a person saying which part matters. Nothing here knows about placements, and should not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Quality 82 went on every rung, so a picture 173 CSS pixels wide in a gallery grid carried the same
fidelity as the copy somebody opens full screen. Compression artefacts scale with display size: at
thumbnail size 74 is indistinguishable from 82 and 15% smaller — 108,388 bytes to 92,568 on the worst
image in this gallery, measured rather than assumed.
640 is the line because it is the widest rung a phone picks for a grid slot. Above it something is
showing the picture large, and 82 is the right answer again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment I added used backticks around ?v= and no-cache, inside a template literal. That closed the
string early and made the file unparseable — committed and deployed before the typecheck was read.
Caught by tsgo immediately afterwards; officer stayed online because pm2 kept the previous module
loaded, but the next restart would not have come back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven days was correct while the URLs were bare: a stable name with a long life means somebody running
last week's script against this week's markup.
The site's layout now stamps every asset URL with the build's id, so a build that changes a file
changes its URL, and the HTML carrying those URLs is no-cache and revalidated every visit. The two
cannot disagree, which is what earns `immutable` and a year.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ladder went 320 then 640, and a phone lands between them. Lighthouse measured the home gallery's
slots at 454 device pixels; with only those two rungs the browser correctly took 640, which is a
184 KB file for a 454-pixel slot — and the same on every image in that carousel.
A rung is one more WebP beside files that already exist, chosen only when it fits better than its
neighbours. Not having it is paid for on every visit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was reachable only when the SOURCE was smaller than 720, which is backwards: the rung a phone wants
is the one a 720p master never produces. The hero here is 1280x720 and the only file that existed for
it was 7.8 MB of 720p, served to every device.
`b:a` overrides the preset's 96k with 64k. The audio on a hero loop is ambience, and 64k AAC carries
it — the same argument as the preset's crf 26 for the picture. The rung exists for a phone on a mobile
connection and every kilobit is one it is paying for.
The override goes through av-club's documented contract: a preset supplies the options and anything
passed explicitly wins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TEMPORARY, and inert unless set. Bisecting a Performance score means a build between every
measurement, and rendering all 48 routes to change one of them is most of the wait.
FOFOS_BUILD_ONLY is passed through to the site's build script, which renders only the listed paths and
— the part that is not optional — skips the prune at the swap. The swap deletes anything in dist/ that
the run did not stage, so a one-page build would otherwise take every other page of the live site with
it, 404 until the next full build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything sat at seven days, which Lighthouse counts as short for a file that never changes.
/fonts/ now gets a year and `immutable`. A typeface is not edited — it is replaced, and a replacement
arrives under a new name. It is the one directory here where a long life cannot pin something stale.
Everything else stays at a week deliberately. Those names are stable across builds and the HTML that
references them revalidates every time, so a longer life means somebody running last week's stylesheet
against this week's markup. Fingerprinted filenames are what would earn a year, and the build does not
write them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A photograph here is 189 KB of tufted yarn — high-frequency detail in every pixel, which is the worst
case for any lossy codec and not something a quality setting can fix. Re-encoding the same original
with ImageMagick at the same quality 82 gives 199 KB, so av-club is already slightly ahead of a
reference encoder. The 858 KB Lighthouse wants back is not available without softening the texture
the site exists to sell.
What was available was the wait. Each ladder now carries the picture at 16 pixels wide as a `data:`
URI inside its manifest — 300 to 500 bytes — and the site paints it as the image's own background. It
costs no request, which is the point: a placeholder you have to fetch is a smaller version of the
problem. When the real photograph arrives it paints over its own background and the placeholder is
gone, with no load handler, no class to remove and nothing to clean up if the script never ran. The
blur is the browser stretching 16 pixels across the box.
Videos take theirs from the poster still, before it is deleted — the same frame, so the two agree, and
sharp can read a JPEG where it cannot read an MP4.
── Existing directories are topped up, not rebuilt ──
The obvious move is to make `upToDate` insist on a placeholder, which turns all 62 ladders stale and
re-derives the library: minutes of CPU and 67 MB rewritten to add 400 bytes to each. Every rung in
them is already correct.
So `topUp` makes only the missing piece, and reads `320.webp` rather than the original — already
small, already browser-readable, already rotated. Going back to a 4000×6000 HEIC to produce a
16-pixel picture is the expensive half of a rebuild and precisely what this avoids. A directory with
no small rung is skipped rather than forced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`http2_support: true` is the default now and is set when a host is secured, but both hosts here were
created and secured before either change, so nothing re-runs and they stay on HTTP/1.1 forever.
Lighthouse costs that 670 ms.
An explicit action rather than a fix applied on load: a panel that silently rewrites the proxy while
somebody is reading it is not a thing to build, and the change reloads nginx for every site behind it.
Only offered where it can do something — a host with a certificate that lacks it. HTTP/2 is negotiated
during the TLS handshake, so on a plain host the field is stored and never used, and the button would
do nothing visible. A host created since the default never shows it: this is a repair, not a setting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Created with `http2_support: false`, so the live site runs HTTP/1.1 — six connections and everything
else queued behind them. Lighthouse costs it 900 ms, most of it the gallery images waiting on each
other.
Set in the defaults and again in `attach`, because HTTP/2 only means anything once there is a
certificate. A host created before this — both of them here — is secured but still on 1.1, and the
attach path is the one that can fix it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`nginx:alpine` ships a config that serves the directory and does nothing else: no compression, and no
cache headers at all. Every asset on the live site carried a TTL of zero, so a returning visitor
re-downloaded all of it — 1.1 MB by Lighthouse's count.
Written out on every start rather than kept as a file somebody edits, so what nginx does is decided in
this file and cannot drift from it.
Three lifetimes, because there are three kinds of file. `derived/` renditions are rebuilt IN PLACE —
av-club replaces the contents of `1280.webp` rather than writing a new name — so they cannot be
`immutable` however much they look like they could be, or a re-crop stays invisible for a year to
everyone who saw the old one. Bundled assets change only when a build runs, and a build changes the
HTML that names them: a week. HTML revalidates every time, because anything longer makes a publish
invisible to whoever already visited, which is the one failure that makes the whole panel look broken.
`^~` on the prefix locations. A regex location beats a prefix location in nginx, so the extension rule
would otherwise capture /derived/…/1280.webp and give it the wrong lifetime.
Validated with `nginx -t` in the real image rather than by reading it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pg_dump warns that `service_nodes` carries a circular foreign key and that a --data-only dump of it
may not restore. Today the rows come out parents-first and it loads either way, but the previous shape
made that a load-bearing accident: truncate in one psql call, load in the next, so the day the
ordering changes the live site is left EMPTY until somebody notices and restores by hand.
Both statements now run as one transaction — TRUNCATE is transactional in Postgres — with foreign-key
checks off for the session. A failed load rolls the truncate back with it.
Verified against a scratch copy of production rather than reasoned about: a `create database ...
template fofos_website`, a deliberately failing load, and all nine rows still there afterwards. The
same copy confirmed what the real run will do — gallery and its nine items promoted, hero pointing at
the new file, and `users` and `contact_submissions` untouched at 2 and 8 rows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The admin edits development and the build renders production, so nothing written in the panel could
reach the live site. Build was only half of publishing, and the missing half was invisible: it
succeeded, against the content somebody last promoted, and a successful build of yesterday's site
reads as a broken renderer.
Publish copies the site's content tables across and then builds. Two things it will not copy: `users`,
because production's logins are not development's and nobody should be locked out of the live site to
publish a photograph; and `contact_submissions`, the legacy inbox, because development's is empty and
production's is not — copying it would destroy real messages in order to publish nothing.
The table list is written out rather than derived. A table added later should be considered rather
than swept along, and an empty development table quietly overwriting production-only data is
discovered far too late to fix.
Order is load-bearing in two places. Production is dumped before anything is destroyed, and the
publish refuses to start if that dump fails. Development is read BEFORE production is cleared, so a
failure to read leaves production untouched rather than empty for a reason that had nothing to do with
it. `TRUNCATE ... RESTART IDENTITY`, so the sequences the copy sets are not merely ahead of the rows
it inserts — otherwise the collision arrives on the client's next save, hours later, somewhere else.
`backupAfterPublish` has been sitting in backups.ts without a caller since the day "On publish" became
a backup schedule. This is the caller.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Managing items on the page would be a second editor for something that already
has one, and the two would drift — a caption fixed in one place and not the
other. The page chooses; the Galleries section arranges; every page using that
gallery changes with it.
The list of galleries is read from the section that owns them rather than
embedded in the home payload, because a copy there would be stale the moment
somebody edited one.
While no gallery is chosen the old per-page rows still render, and the panel says
so — a carousel that appears to come from nowhere is worse than a sentence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`readdir` returns entries in the filesystem's own order — inode order on ext4 —
which is arbitrary and stable only until somebody moves a file.
Harmless for the sync, which does the same work whatever the order. Not harmless
for "Create Gallery": that list becomes the gallery's initial order, and nine
photographs arriving as 05, 08, 01, 07 reads as a bug even though nothing is
wrong.
`numeric` collation, so galeria2 sorts before galeria10 — which is what anybody
naming files that way means.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An <img> cannot send an Authorization header, which is why user-middleware and
the permission gate both already accept `?token=` — it is exactly what the file
browser's own thumbnails do.
Built without it, every picture 401'd while the layout looked perfectly correct:
the space was reserved, the rows were right, and nothing appeared. The route was
fine and had been tested with a bearer token, which is precisely the case the
browser cannot reproduce.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The thumbnail comes from the DERIVED ladder, which is the point of the whole
pipeline: most of these are HEIC and a browser cannot display the original at
all. 320 is the smallest rung and still sharp at this size.
Served by this plugin from disk rather than by the site. The panel talks to the
plugin and the catch-all forwards everything to the site's /api/admin, which is a
different route — and more importantly, looking at a gallery should not require
the development server to be running. Editing the site is not the same as
reading it.
Not under /_config, because that prefix is owner-only and a thumbnail is not a
credential: a member who can open the section can see the pictures in it, which
is what the section is. Registered before the catch-all, or the catch-all would
forward it to fofos.
Path traversal is refused by resolving first and comparing against the directory
plus a separator — a bare startsWith accepts `derived-old` for a root of
`derived`.
An item whose conversion has not run has no rung, and the thumbnail hides rather
than showing a broken-image glyph, which reads as corrupt rather than pending.
Captions save on blur. A caption is a sentence somebody is in the middle of, and
a request per keystroke would be wasteful and a race with itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`listOriginals` already includes the prefix in what it yields — it builds
`prefix/name` as it descends — so prefixing again produced
Home_Page_Gallery/Home_Page_Gallery/…, a real-looking path to nothing.
Found by calling the route rather than reading it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bare fetch was returning 401: Officer's API authenticates with a bearer token,
not a cookie, and only the hook holds it. The browser now supplies an
authenticated client in the action's context.
It stored the folder. What somebody right-clicks is a directory; what a gallery
holds is pictures — obvious the moment you look at the result, and not before.
Expanded by the server, because that side already knows which extensions are
media and how to walk the tree. It is the same `listOriginals` the sync uses, so
a gallery cannot end up holding a file the pipeline would never convert.
Recursive, deliberately: a folder of folders is still "these pictures", and
refusing to descend would silently miss most of what was pointed at.
An expansion that returns nothing creates nothing. Falling back to the folder
itself is exactly the bug being fixed, and doing it quietly is worse than doing
nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The section reads the site's own admin API, so it requires a CONNECTION where
Media beside it requires only a root. The difference is real rather than
copy-paste: Media is a file browser over a folder on this disk, while a gallery
is a row in the site's database — opening it without the site answering would be
a screen where every action fails.
The context action hands its selection over in the URL and sends you to the
panel rather than opening a dialog of its own. Naming a gallery, seeing the slug
it will get and watching the items land belongs on the screen that owns
galleries, not in a modal over a file browser that would then have to explain
where the result went.
It offers itself only inside originals/, because a gallery item is a path
relative to that folder and an entry that appears everywhere while working in one
place is worse than one that appears where it works. The test is on the PATH, not
on which panel is open — the same browser is mounted in several places and only
the path says where it is pointed.
A mixed selection is refused. A folder among files is either a mistake or a
request to recurse, and neither is something to guess from a right-click.
The handoff is consumed once and stripped from the URL. Left there, a refresh
would create the gallery again, which somebody discovers as four copies.
Nothing is written until Create: the folder's name is a suggestion in the field,
not a decision already taken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
You should not have had to press Sync. Nine files moved into a folder and
nothing noticed.
Two gaps, and neither would have caught it. The platform's event bus emits
`files:uploaded` and `archive:extracted` — things Officer's own file browser
DID — so a move emits nothing at all, and neither would an rsync, a drop over
SMB, or anything done in a terminal. And no plugin subscribes to hooks anyway.
That is not a bug in the bus: it reports actions, and this needs to know about a
directory. So a filesystem watcher, the same shape as the mailbox one already
here.
The debounce is twenty seconds and long on purpose. A move of nine files is
dozens of events and an upload of two hundred is thousands, while a sync walks
the whole tree — so waiting for quiet means one run after the dust settles rather
than one per file. Being slightly late costs nothing, because the sync skips
everything already current; being eager costs a full walk per event.
It cannot feed itself: only originals/ is watched and the sync writes only to
derived/. Worth stating, because the opposite arrangement is a loop that looks
like a busy machine and nothing else.
A run already in flight is left alone rather than queued — it walks the tree when
it starts, and whatever lands after that is what the next quiet stretch is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving a file into a folder made the sync look at a new path, find nothing, and
convert it again — minutes of work to produce bytes that already existed a
directory away — while the old folder stayed behind forever, because nothing ever
deleted one.
A move preserves size and mtime, and both are already in the manifest and in the
stat. So the sync indexes what is already derived on that pair, and a file that
has moved has its ladder RENAMED across.
An ambiguous match is refused rather than guessed. Two originals can share a size
and an mtime — a camera dump can write a whole card in one second — and
rebuilding is merely slow where attaching the wrong photographs to a name is
wrong.
Orphans are removed afterwards, and only on a WHOLE-TREE sync. A scoped run knows
about one folder, so everything outside it looks abandoned; deleting on that
basis would take out the library the first time somebody re-synced a subfolder.
Safe otherwise because derived/ is reproducible by definition, which is what its
name has meant since these two folders stopped being called `user` and `treated`.
Both are counted, recorded in the ledger and shown only when they happened, so an
ordinary run stays one short line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Galleries become an entity rather than a list hanging off a page: a set of media
somebody has chosen and put in an order is a decision worth keeping once, not
remaking on every page that wants it.
Pinned with Media and above it, because the two are the same kind of thing — the
raw material the site is built from, held on this machine. Media is the files; a
gallery is a chosen, ordered subset of them.
`requires: 'root'`, not `'connection'`. A gallery is made of originals on this
disk, so it is reachable as soon as there is a root location. Gating it behind a
site connection it does not use is the bug that made that distinction necessary
in the first place, and Media already learned it.
Not built yet, so it renders the Planned card — the entity is the next piece and
this is where it will live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nobody knows the path of a file they uploaded from a phone, and a text box
invites a typo that fails silently as a broken image on the live site. Browsing
`originals/` means the only values that can be stored are ones that exist.
The modal is the platform's file browser in pick mode — the same listing,
breadcrumb, sorting and thumbnails as everywhere else, with one branch changed.
It browses ORIGINALS and the site renders DERIVED, and that indirection is the
point of the pipeline: what somebody picks is the .mov or the 12-megapixel HEIC
that came off the camera, and what a browser can display is the ladder av-club
built beside it. Storing a derived file directly would pin the site to one
rendition, so the day the ladder changes every stored reference names a file that
is no longer the right one to serve.
The path is stored relative to `originals/`. The root is a setting and moves
between machines, so an absolute path would be a fact about one installation
written into content that outlives it.
No pathParam and no panelId on the modal's browser: its folder is not something
to put in a URL or remember, and both would leak a transient choice into shared
state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page reads hero-then-intro and this deliberately does not. The intro is the
thing that gets edited; the hero is a path that changes once a season. Ordering
by what somebody came to do beats mirroring the layout of the page they are
describing.
One PATCH per card, carrying only that card's field. `take()` on the server
treats an absent key as leave-it-alone, so sending the whole object from either
card would have each one saving the other's unsaved edits — a half-finished
intro riding along with a hero somebody meant to change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things.
`?media=` is Media's own folder and now goes when you leave it. It survived
every navigation, so `?section=home&media=GALLERY/CALAVERA+1` was an ordinary
URL — a parameter naming a folder in a section that is not open, carried into
every link and shared or bookmarked that way.
And the intro is a tiptap editor rather than a textarea of raw HTML. HTML in and
HTML out, because that is already what the site's database holds and renders —
introducing a second format would buy a migration and a round-tripping bug class
for nothing.
Declared as the PLUGIN's npmDependencies, not added to the platform's
package.json. That lockfile is frozen and shared, and npm-deps.ts exists for
exactly this: everything lands in plugins/node_modules/, so a plugin's editor
cannot move a version the platform depends on.
The toolbar is only what the site's stylesheet can render. Anything more would
produce copy that looks right while editing and wrong when published, which the
person only discovers after saying it was done.
The content effect compares against getHTML() before writing back, and that
comparison is the whole thing: while somebody types the two already agree, so
nothing is pushed in and the caret stays put. Without it this is the classic
controlled-contentEditable bug where the cursor jumps to the end on every
keystroke.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hero, intro and an ordered gallery — the shape the old htmx admin had, as a
panel here.
Every write returns the WHOLE payload and this never patches its own state from
what it sent. That matters more than it looks: the server clears the hero crop
when the hero changes, because a rectangle describing a region of a different
picture would crop the new one somewhere arbitrary. A client keeping its own
copy would go on showing a framing the server had already discarded.
Ordering sends every id rather than one item's position, which is what makes it
impossible to end up with two things claiming the same place.
Thumbnails load through this plugin's proxy rather than straight from the dev
server, so they arrive with the session instead of requiring the browser to
reach a port on the host.
It edits DEVELOPMENT, because production is a directory of static files with no
API to write to. An edit lands on dev immediately and becomes public when
somebody presses Build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ready` was hardcoded false for as long as the sections proxied to an address
nobody could set — the connection form was removed and nothing replaced it, so
nine doors led to the same "not connected" message.
Both halves exist now and neither is typed by a person: the development server
runs on a port this plugin chose, holding a FOFOS_API_TOKEN this plugin
generated and handed it at start. So the credentials are DERIVED rather than
stored. Asking somebody to copy a value out of one part of this plugin into
another was never a setting, it was a chore.
Development and not production, and that is not a preference. Production is a
directory of files behind nginx — no API, no database, nothing listening — so
editing against it is impossible rather than merely wrong. Sections read and
write dev; Build is what publishes. An edit is visible immediately and public
only when somebody decides it should be.
The gate asks whether the server ANSWERS, not whether pm2 says online. `bun
--hot` survives a crash at startup: the process stays up, pm2 reports online,
and nothing is listening. Opening nine sections onto that would make one failed
boot look like nine broken screens.
And when /status itself cannot be reached, `configured` stays true while `ready`
goes false. Telling somebody they have set nothing up is alarming and wrong;
opening nine sections onto a server we could not confirm is answering is nine
screens that each fail separately. Cannot-tell keeps them shut and lets the next
poll open them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NPM runs in a container and crosses the docker bridge to reach host services;
ufw allows that subnet to named ports only. A development server on a port
nobody opened answers `curl 127.0.0.1` perfectly and is invisible to the proxy.
What that looks like is a browser timing out against a valid certificate, a
correct proxy host and a healthy process — with the cause written down in
exactly one place nobody thinks to read: nginx's own error log, "upstream timed
out while connecting to upstream: http://172.20.0.1:3011/". It cost an evening,
which is why this is code rather than a line in a README.
The panel is what chooses the port, so the panel is what opens it. A setting that
silently requires a second undocumented step elsewhere will be wrong most times
it is changed.
Opened BEFORE the process starts, so the port is reachable the moment it binds,
and idempotent by ufw's own behaviour — it runs on every start rather than only
the first, so a firewall reset repairs itself next time somebody presses Start.
Never fatal: a rule that could not be added is a line in the output and the
server still starts.
Closed when the port CHANGES, in savePorts, which is the only place that knows
both values. Not on stop — a stopped dev server is usually about to start again,
and a rule for a port nothing listens on grants nothing, while leaving it on
every change is how a firewall accumulates holes nobody remembers opening.
`sudo -n`, so a host without passwordless sudo fails rather than hanging on a
prompt nothing will answer. The port is an integer validated before it arrives
and every command is an argv array, so there is no shell and nothing to inject —
that is the whole boundary, and it is why the parameter is typed number rather
than read from a request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bun run build:site` resolves the script from the package.json in the current
directory. The helper had no cwd parameter, so it ran in the platform's own
directory, which has no such script — and the error, "Script not found
build:site", reads as the site missing a script rather than as it being asked in
the wrong place.
Second time today a spawn helper quietly lacked something the caller needed: the
first was the environment for pm2. Both had the same shape — assembled correctly
and then not passed.
Verified by running the real build with the same environment the plugin builds:
29 entries in dist and an index.html with the site in it.
The site's own build script says it outright: "The public site is not served by
Bun in production — it is a directory of files behind nginx. Output: dist/."
So production has no port, nothing to restart, and nothing that can crash at
three in the morning. It is built or stale, and served or not served.
Showing it as a pm2 process was showing something that does not exist, and its
Start button could only ever have failed. It now has Build, Serve and Stop, and
the two states are reported separately — a build can succeed while nothing
serves it, and a container can serve a directory built weeks ago. They fail
independently and are fixed differently.
The container exists because nginx could not see the files. NPM mounts only its
own /data and /etc/letsencrypt, and there is no other nginx on the box. The
alternatives were putting site files inside NPM's own volume, or editing NPM's
compose and restarting it — which takes every site on this machine down for a
moment. So: one nginx:alpine, dist mounted READ-ONLY, on NPM's own network with
NO published port. NPM reaches it by name, and nothing about the site is exposed
except through the proxy.
Recreated rather than restarted, for the same reason pm2 is: a container keeps
the mounts it was created with, so a root location changed here would be saved,
displayed, and still serving the old directory.
Domains follows. Production's forward target is a container NAME, not the docker
gateway that reaches a host process — and matching what already points at it
uses the full target rather than the port, since every other site behind this
NPM on port 80 would otherwise be listed as fofos's.
The compiler found the dead branches this left in startServer once production
could no longer reach it. Removed rather than left to read as though the case
were still possible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without it, pm2 resurrects the list as it was the last time somebody happened to
run `pm2 save` — so a machine that reboots comes back without the site, and with
nothing to indicate anything was meant to be there. The systemd unit is enabled
on this host, so the resurrect definitely happens; the only question was what it
would restore.
After stopping too, not only after starting. The dump has to match reality in
both directions: a server stopped here and left in the dump comes back on its
own at the next reboot, which is the same bug pointing the other way.
A failed save does not fail the action — the process is already started or
stopped by then, and reporting "could not start" about something that did start
is worse than a line saying the list was not persisted. It is in the console
either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A form offering work nobody came to do. Both servers were pointed at, both had
certificates, and the block still showed two empty fields and two buttons — the
second address for one site is the rare case, and it lives in Proxy Manager with
the rest of the picture.
So the field appears only while that server has nothing, and the forward address
goes with them: once both are pointed somewhere it configures nothing.
`min-w-0` on the input, because a flex child refuses to shrink below its content
by default, which is what was pushing "Point here" off the edge of the card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issuing waits for a DNS record to propagate — two minutes before certbot even
looks — and the proxy in front of Officer gives up at sixty seconds with a 504
while certbot carries on and succeeds. So the only thing anybody sees is a
timeout, and underneath it the certificate exists and is attached to nothing.
That is exactly what happened: npm-7 was issued for
dev.fofosfluffybastards.com and the host still had ssl:0, because the request
died between issuing and attaching.
So a timeout is no longer a failure. The request is fired, and afterwards —
returned, failed or cut off — this asks what certificates exist and attaches the
one covering these names, polling for up to five minutes.
And it looks for an existing certificate BEFORE asking for anything. A retry
would otherwise spend a Let's Encrypt rate limit to obtain a duplicate of one
already on the disk, and those limits are per-week: getting it wrong means not
being able to fix it today. It also means the stuck host above is repaired by
pressing the same button.
Matching requires the certificate to cover EVERY name the host serves. One that
covers a single domain of two is not this host's certificate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The HTTP challenge proves a domain by being visited on port 80, which requires
the name to already resolve to this machine — which is exactly what is not true
at the moment somebody is setting one up. certbot then fails with "Some
challenges have failed" and a 500 that says nothing about why.
The DNS challenge has no such requirement: it proves ownership by writing a
record. That needs the registrar's API key, and this must not go looking for
one — Officer stores DNS provider keys encrypted and never returns them, to
anybody. /domains/certificate exists for precisely this: it works out which
provider owns the name from the Domains list, decrypts the key itself, and hands
back NPM's answer. The key is used and never seen.
Its refusals are translated rather than shown raw. "Not in the domain list" is a
true sentence that tells nobody what to do; which list, and who can add to it,
is the part that matters.
And the button says it is issuing, because a DNS record has to propagate before
Let's Encrypt will look for it — the wait is minutes, and a spinner sitting for
three of them with no explanation is indistinguishable from one that has hung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It talks to the proxy-manager PLUGIN, from the browser, with the user's own
session. Not to NPM, and not from this plugin's server: NPM's credential belongs
to proxy-manager and its skill is explicit that the pass-through route is the
sanctioned path. Calling it this way means this plugin never holds that
credential, and two plugins do not acquire a server-to-server dependency that
each would then have to know the installed state of.
forward_host is copied from what already works here. NPM runs in a container, so
127.0.0.1 there is NPM itself rather than this machine — a host pointed at it
resolves, serves nothing, and looks exactly like the site being down. The
correct value is the Docker gateway, which is a fact about this machine's
networking that neither this plugin nor a person should have to know. So the
default is taken by majority from the hosts NPM already serves, since those are
by construction addresses that work. It is shown in an editable field, because a
guess made from other people's configuration should be visible before it is used.
Creating is offered and deleting is not. A new host is additive and reversible;
removing or repointing one is a live domain changing where it goes, and this
panel would be offering that with a fraction of the context the Proxy Manager
screen has.
Certificates are two calls, because NPM issues one as its own object and a host
merely references it. The body is exactly {provider, domain_names} —
letsencrypt_email and letsencrypt_agree are what every tutorial shows and NPM
2.15 rejects the request for including them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It is the same KIND of thing as the root location and the databases — this
machine, not the site's content — and a nav row leading to one card was a whole
screen to reach three lines.
Last of the three, because it needs both of the others: a server has nowhere to
run without the checkout and nothing to read without a database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, and the second is why the panel was confidently wrong.
The site refuses to start without FOFOS_API_TOKEN, ADMIN_SESSION_SECRET,
PUBLIC_URL, API_URL and FOFOS_MEDIA_DIR, and the checkout has no .env at all.
Every one of those is either already known here — the port, the media directory
— or a random secret nobody should be asked to invent. So they are generated
once, kept encrypted under this plugin's own purpose key, and supplied. Once,
not per start: regenerating would invalidate every session and token anybody had
noted down.
pm2 saying `online` is NOT the site being up. `bun --hot` survives a crash at
startup — the process stays alive, pm2 reports online, and nothing is listening.
That is precisely what development did: reported up, served nothing, and this
panel repeated it. The status now also asks whether the port answers, any
response counting, and says "not answering" out loud when the two disagree.
And the buttons are a fixed width, because Start and Restart are different
lengths and rows that size to their own label leave the inputs on different
vertical lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pm2 for BOTH, development included. A `bun dev` started in a terminal dies with
the terminal, and nobody can tell whether it is running without finding the
window it was started in. Under pm2 the two are the same kind of thing — a named
process with a status, an uptime and a log, startable from here and inspectable
from anywhere. The cost is that development stops printing to a console somebody
is watching, which is what `pm2 logs fofos-dev` is for; the process names are in
the panel so nobody has to guess them.
This plugin owns exactly two names, `fofos-dev` and `fofos-prod`, and every
command is built from that pair and from nothing a caller supplies. No request
can stop, restart or delete another process on the machine. That matters more
than usual here: Officer's own processes share this pm2 daemon, and a plugin
able to name any of them would be a way to take the host down through a web
form.
Start is really a replace — delete, then start. Not `restart`: it fails on a
process that has never existed, and on one that has it reuses the environment it
was started with, so a port or database changed here would be saved, displayed,
and not in effect. That is an hour of somebody's debugging.
The environment is assembled here because this plugin is what knows the ports
and the database names, and it is handed to pm2 through the invoking command's
own environment — pm2 has no flag for it; `--env` names a block in an ecosystem
file. The site reads .env files first and the environment last, so this fills
what is absent rather than overriding what somebody wrote down.
Ports have no default. It is the one setting where a plausible guess is worst:
it binds, the site appears to work, and it is quietly answering on a port
another service wanted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>