`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 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>
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>
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>
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>
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>
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>
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>
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>