Commit Graph
79 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 74c6776c75 a desktop audit was not a desktop audit
`formFactor: 'desktop'` changes which thresholds a metric is scored against and
NOTHING else. Throttling and the user agent keep their defaults, which are mobile
— so every desktop run was a desktop-sized window on a 4x-slowed CPU over slow 4G,
announced as a moto-g.

It read 86 where the same page reads 100. That gap was reported repeatedly as
network latency to the host. It was not latency; it was this. Measured after the
fix: desktop 100, LCP 0.4s against 2.2s.

Nine more from the same review:

  runtimeError was never checked, so a page that failed to load reported
  "nothing failing" — the most reassuring possible output for a site that is down

  --json exited 0 before the gate, so the documented non-zero exit was true for a
  person reading the table and false for the only caller that could act on it

  scoreDisplayMode 'error' was filtered out while still nulling its category, so
  a category could read 0 with an empty findings list

  a missing metric serialised as 0, which a script reads as a perfect measurement

  `source` is a string on some audits and an object on others; String() on the
  object printed [object Object]

  the watchdog's surrender was permanent — the health branch sat below the check,
  so once it stopped trying it also stopped noticing, and a server fixed by hand
  left it asleep until the platform restarted

  a failed restart leaves no process at all, because startServer deletes before
  it starts. status null then reads as "somebody stopped it", which the watchdog
  leaves alone — one bad restart would have turned it off and taken the server
  with it, silently

  the skill claimed the panel uses this engine. Nothing but the CLI does

  and gave `bun run build:site` with no cwd, which only works from the website
  checkout — api/static-site.ts documents that and the skill did not

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:42:45 +01:00
pastilhasandClaude Opus 5 8da02e1ef6 watch the dev server, because pm2 cannot tell if it is serving
fofos-dev sat for eighteen hours reporting `online`, holding its port open and
answering nothing. pm2 was not wrong — the process was alive and the socket was
bound. It was `bun --hot` wedged after a day of edits, sitting in ep_poll and
never completing a request. dev.fofosfluffybastards.com was down the whole time
and nothing said so.

servers.ts already knows those two answers disagree; that is why `reachable` is
asked separately from pm2's status, and its own comment says so. This asks the
same question on a timer.

Not an hourly restart. That bounces a healthy server fifty times to catch one
wedge, drops whatever anybody was doing each time, and still leaves the site
broken for up to an hour where a probe catches it in two minutes.

It acts only when pm2 says online AND the port does not answer, which is a lie
worth correcting. A server somebody STOPPED is a decision and is left alone.
Two strikes a minute apart, because --hot legitimately goes quiet while it
rebuilds. Five minutes of cooldown after acting. Three failed restarts and it
gives up and says to go read the logs, because a watchdog that restarts forever
is a crash loop with a nicer name.

State is on its own route rather than folded into /_config/servers, which answers
with a bare array that two screens read positionally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:21:47 +01:00
pastilhasandClaude Opus 5 7e8353a100 serve the page at the address that was asked for
try_files listed $uri/ before any index, so nginx answered /gallery/aldi/65 with
a 301 appending the slash. Every internal link on this site is the bare form —
the thumbnails, the lightbox hrefs, the chevrons — so every navigation paid a
redirect before anything on the page could start. Lighthouse measured it at 78 ms
on the critical path of the first request.

Naming index.html first serves the file directly. Both forms still work, neither
redirects, and $uri/ stays after it for a directory with no index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 20:32:27 +01:00
pastilhasandClaude Opus 5 db781701e3 measure the site instead of reading a report back
Every performance decision here was made from a Lighthouse run somebody did by
hand in DevTools and typed into a conversation. That loop is slow and it is
lossy: nothing measured after a build, so three real defects sat live for weeks
— a `sizes` that overstated a column by 48px, font preloads that had gone
missing, an LCP image left lazy by a rename.

api/lighthouse.ts runs it: both form factors in about 20 seconds, four
categories, the five metrics, and every failing audit with the resources it
names. A finding that says "Improve image delivery — 32 KiB" is not actionable;
the same finding with the URL and the size served is a one-line fix.

The browser is looked for in three places before anything is downloaded —
CHROME_PATH, chrome-launcher's own search, then the Chrome-family binaries by
name, which is what finds the Brave already on this machine. chrome-launcher
THROWS rather than returning null when it finds nothing, so an unguarded call
never reaches the fallback; that is caught. Only if all three miss does
@puppeteer/browsers fetch one, as a library call rather than a postinstall,
because bun does not run postinstall for an untrusted package and a silent no-op
there would surface as a failure at the first audit.

First result: Total Blocking Time is 0 ms on both form factors, down from 130.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:22:33 +01:00
pastilhasandClaude Opus 5 15042ccc76 a gallery is a carousel
It always rendered as one — data-carousel, a snap track, two arrows, driven by
carousel.client.js. The entity was the last thing still calling it something
else, and that collided with the site's own Gallery section: two nav rows
reading Gallery and Galleries, meaning a list of objects and a list of
photograph sets.

GalleriesPanel, GalleryPicker and GalleryItems become Carousels*, the endpoint
becomes /fofos-admin/carousels, the block kind becomes carousel and
subject-gallery becomes subject-carousel. The publish allowlist follows the
renamed tables.

What did NOT move: GALLERY_CONFIG at /fofos-admin/gallery, subjects.ts's
base '/gallery', and home_gallery_media. Those are the Gallery section and the
legacy rows, and both are correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:01:15 +01:00
pastilhasandClaude Opus 5 b165e10f53 every proxied request carries who is asking
Officer's session is the only place the acting user is known, and fofos writes it
into its own change history. An email in X-Actor, because that database has no
users to point at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:36:34 +01:00
pastilhasandClaude Opus 5 fd35b048f8 no backticks in the nginx config
The config is a JavaScript template literal, and the comment added with the /crops/ rename quoted the
paths in backticks. They closed the string, `crops` became a bare identifier, and the plugin failed to
build: "crops is not defined".

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 01:16:42 +01:00
pastilhasandClaude Opus 5 84336b8af5 a framing slot renders the original, not a rung
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>
2026-08-31 00:02:24 +01:00
pastilhasandClaude Opus 5 fe51c70f70 a media slot can be framed by dragging and zooming
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>
2026-08-30 23:57:33 +01:00
pastilhasandClaude Opus 5 40a6a5b8d2 nginx redirects stay relative
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>
2026-08-30 10:34:36 +01:00
pastilhasandClaude Opus 5 f019f7ec86 the one-page build switch comes out
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>
2026-08-30 10:12:42 +01:00
pastilhasandClaude Opus 5 7241848d36 a drawing is resampled with nearest, not lanczos3
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>
2026-08-30 09:57:31 +01:00
pastilhasandClaude Opus 5 193f8cb03d the drawing probe measures near the source, not at the floor
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>
2026-08-30 09:54:17 +01:00
pastilhasandClaude Opus 5 5481005ccf the ladder reaches down to 96
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>
2026-08-30 09:51:38 +01:00
pastilhasandClaude Opus 5 53b834a7a6 a drawing and a photograph are encoded differently
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>
2026-08-30 09:46:56 +01:00
pastilhasandClaude Opus 5 fbf45a12fd poster rung at 448, for the density-capped hero
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>
2026-08-30 00:30:21 +01:00
pastilhasandClaude Opus 5 70dc19500a fix: the poster ladder read a file already deleted
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>
2026-08-30 00:22:47 +01:00
pastilhasandClaude Opus 5 931807f3a5 video posters get a ladder
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>
2026-08-30 00:17:53 +01:00
pastilhasandClaude Opus 5 2b910bc5b8 square rungs chosen against real slots and real screens
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>
2026-08-30 00:13:59 +01:00
pastilhasandClaude Opus 5 dc41cdbf09 square crops for square slots
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>
2026-08-30 00:06:18 +01:00
pastilhasandClaude Opus 5 f3dda78ed0 thumbnails are encoded as thumbnails
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>
2026-08-30 00:01:57 +01:00
pastilhasandClaude Opus 5 f98f8f0771 fix: backticks inside the nginx template literal
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>
2026-08-29 23:55:56 +01:00
pastilhasandClaude Opus 5 cd4cb18ccc a year on assets, now that their URLs change
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>
2026-08-29 23:55:19 +01:00
pastilhasandClaude Opus 5 94eb308e78 480 is an image rung too
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>
2026-08-29 23:46:41 +01:00
pastilhasandClaude Opus 5 b97c87fb5e 480 is a real video rung, with lighter audio
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>
2026-08-29 23:38:11 +01:00
pastilhasandClaude Opus 5 c5fe7c0f86 the build can be pointed at one page
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>
2026-08-29 23:12:50 +01:00
pastilhasandClaude Opus 5 3b962fe3ca the typefaces get a year, the build output does not
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>
2026-08-29 22:32:33 +01:00
pastilhasandClaude Opus 5 cd0d3fe09e every ladder carries a 16-pixel placeholder
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>
2026-08-29 22:27:49 +01:00
pastilhasandClaude Opus 5 a3e8f3fbc4 nginx gets a configuration instead of the stock one
`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>
2026-08-29 22:07:50 +01:00
pastilhasandClaude Opus 5 1d9a621bd2 publish clears and loads in one transaction
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>
2026-08-29 21:55:02 +01:00
pastilhasandClaude Opus 5 472fd9cb88 publish promotes development's content, then builds
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>
2026-08-29 21:52:08 +01:00
pastilhasandClaude Opus 5 300edfb023 sort the walk, because readdir does not
`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>
2026-08-29 21:32:32 +01:00
pastilhasandClaude Opus 5 9fd5f1b3ab gallery items get a thumbnail and a caption
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>
2026-08-29 21:23:40 +01:00
pastilhasandClaude Opus 5 5cfe571db0 the expansion route was doubling the folder into every path
`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>
2026-08-29 21:20:27 +01:00
pastilhasandClaude Opus 5 a610f93189 Create Gallery on a folder means the files inside it
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>
2026-08-29 21:16:05 +01:00
pastilhasandClaude Opus 5 063d2c4a88 watch originals, because a move is not an event
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>
2026-08-29 20:50:07 +01:00
pastilhasandClaude Opus 5 01652f3480 a moved file relocates its ladder instead of rebuilding it, and orphans go
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>
2026-08-29 20:42:20 +01:00
pastilhasandClaude Opus 5 f0d29c8000 the site sections open, because there is finally something behind them
`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>
2026-08-29 20:00:18 +01:00
pastilhasandClaude Opus 5 2153d4875b open the firewall to the proxy when the dev server starts
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>
2026-08-29 19:51:07 +01:00
pastilhas 89adbe37ce the build needs a working directory, and its absence was silent
`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.
2026-08-29 19:42:09 +01:00
pastilhasandClaude Opus 5 27f93d6e14 production is a static build behind nginx, not a process
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>
2026-08-29 19:36:29 +01:00