281 Commits
Author SHA1 Message Date
pastilhas 732a584dfb a control that saved and changed nothing, and saves that failed silently
Two defects found by reviewing the panel rather than the site.

── The lead photograph had a cropper that did nothing ──

It wrote `content.crop` on a subject-cover block. The site stopped reading that
when the crop moved to the row - one rectangle framed against one photograph and
applied to every row a template draws goes through the face of the next one - and
the control stayed behind. It saved, the API allowlisted it, and nothing rendered
it, under help text promising it applied to every row.

Gone, with the lead fetch that fed it. Shape stays: one decision for the whole
section, and it is read. The per-row cropper in each section's settings is the
one that works.

── A failed save said nothing at all ──

SubjectFields drives Collections, Pieces and Collabs. Neither of its save paths
had a failure branch, so a rejected PATCH was an unhandled rejection and a person
watching their own change had no way to know it was not written. There is no Save
button here by design, which makes reporting the failure the only signal there
is.

It matters most for the cover, which does not go through the draft: typing
survives, because the draft is only cleared on success and the next keystroke
retries, but a crop that fails is simply gone while the picture goes on showing
the rectangle that was dragged.

Also: the shape is computed once instead of three times from a fabricated
`{ url: 'x.png' }`, and the SHAPES import moved to the top of the file with the
others rather than sitting after a top-level const.
2026-09-05 19:38:05 +01:00
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 05ad4f4d51 draft the crop instead of saving every pointer move
Panning went berserk. Wired straight to MediaField, `onCropChange` fired on every
move, each move saved, each save reloaded the row, and the reload handed the crop
back as the last SAVED value — yanking the picture out from under the drag.

BlockMedia exists for exactly this and I went around it. It keeps the crop in a
local draft, saves once when the drag stops, and resets on the url and the shape
rather than on the crop — its own note says why, and the failure it describes is
the one I reproduced.

Also: an empty picture slot looked the same whether the carousel was still being
read or genuinely had nothing in it. `undefined` now means loading and `null`
means empty, and the line underneath says which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:46:22 +01:00
pastilhasandClaude Opus 5 5968cb5d83 each row picks its own main image, and frames it
The shape is one decision for the whole section and stays on the template — every
collab is a 21:9 band or none of them is. Which part of THIS photograph survives
that shape is a decision about this photograph, and there is one per row. A single
rectangle chosen against one lead and applied to all of them is how a portrait
ends up cropped through somebody's head, which is what the block-level crop did
and why it is gone.

So a row's settings now carry Main image: a picture and a cropping frame in the
proportions the page asked for. Empty means the first item of its carousel, which
is the ordinary state and is not stored — reorder the carousel and the lead
follows. Choosing one is the deliberate act of saying "not that one", and it may
be a picture the carousel does not contain at all.

`cover_shape` rides along with each row so this panel can draw the right frame
without knowing how a template is assembled. SHAPES moved out of PageBuilderPanel
because two screens offer them now, and two lists is two places to add the next
ratio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:41:37 +01:00
pastilhasandClaude Opus 5 c6cebc5000 zoom and pan the lead photograph
The Shape control it got last commit did nothing, and the reason is the trap that
file already warns about: `contentFor` is a per-kind allowlist and everything it
does not name is dropped. `subject-cover` was not in it, so the panel wrote a
ratio, got a 200, and the API stored `{}`. Its own comment says that is "exactly
what silently threw `ratio` away when the admin first sent one".

Now it holds a ratio and a crop, and the block gets the same framing surface the
About Us hero has.

Two things it does NOT get, both deliberate:

  No Change and no Remove — `fixed` on MediaField. The picture is the subject's
  first carousel item and this block cannot store a different one. Framing is its
  decision; which photograph is not.

  No local knowledge of which picture that is. The site answers
  `/pages/:id/subject-lead`, because which template draws which section, which row
  is the example and which of its pictures leads are four facts that live in
  templates.ts — a second copy here is how the two drift when a fifth template
  arrives.

And it says out loud that the crop applies to every row the template draws. It is
framed against one photograph and used on all of them, which is the one
surprising thing about cropping a template rather than a page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:19:04 +01:00
pastilhasandClaude Opus 5 2aff4a130f the lead photograph gets Shape
The same framing a media block has had since it existed, so a template wanting a
letterbox lead can ask for one. It was in PLACES_ONLY — the list of blocks that
hold nothing — which was true until it held a ratio.

Shape and only Shape. Not the media block's other controls: there is no file to
choose, because the picture is the carousel's first item, and no crop to set,
because the crop belongs to the carousel item. Two crops for one placement is two
answers able to disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:08:06 +01:00
pastilhasandClaude Opus 5 d6fdf936cf the description is rich text
It is the one piece of writing about a collection, a piece or a collab — the
paragraph the template draws under the name — so it wants the headings, emphasis,
links and alignment the page's own text blocks have, not a plain textarea.

Prose on the site already renders HTML when it finds any and falls back to
paragraphs when it does not, so everything stored as plain text goes on working
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:59:09 +01:00
pastilhasandClaude Opus 5 76a749d8f9 an Alignment control beside Width
Left, Centred, Right on the selected block. Left writes nothing, so a block that
has never been touched inherits the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:53:44 +01:00
pastilhasandClaude Opus 5 55be0a1860 a video in a carousel gets its poster, not a 404
A video has no width rungs — poster-448.webp and friends are cut from a still —
so asking for 320.webp returns a 404 and the grid drew a broken tile with no clue
why. Every grid in the panel was doing it, and a carousel is explicitly allowed
to hold both kinds.

posterUrl already existed for this and nothing called it. mediaThumbUrl decides
from the extension so a screen showing mixed media does not have to know the
difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:32:52 +01:00
pastilhasandClaude Opus 5 b7455c0470 contain the pickers and let their lists scroll
The carousel list ran off the bottom of the window with no way to reach the end
of it. There are twenty-odd carousels now — three sections' worth — where there
were six when the dialog was written.

`max-h-[80svh]` on the dialog and `min-h-0 overflow-y-auto` on the list. Both are
needed and the second is the one that is easy to miss: a flex child will not
shrink below its content without `min-h-0`, so the list pushes the dialog past
the screen and the overflow never engages. A max-height alone would have looked
like it did nothing.

`svh` rather than `vh`, because on a phone `vh` is the viewport with the browser
chrome hidden — a dialog sized in it is taller than what you can see.

AddComponentDialog gets the same treatment. Its list is a dozen kinds now and was
the same failure waiting for a shorter screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:25:48 +01:00
pastilhasandClaude Opus 5 9ca6077e2c Collabs, as a third configuration of one panel
The same object as a collection and a gallery piece, so it is a config in
subjects.ts and a one-line panel — no new screen.

Two differences, both deliberate. It has no SPECS: a collab is a project rather
than an object, so there is no measurement or raw material and the form is a
name, an address and a description. And its title column is `project_name`,
which is the client's word and stays — the site's API publishes both that and
`title` so this panel reads one field like every other section, and the
translation lives in one router rather than as a branch in every screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:42:26 +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 919504901a tell agents the site can be measured
The engine was there and nothing pointed at it, which is the same failure as a
comment outliving its code: an agent would go on asking the owner to open
DevTools and read numbers back, because that is the only loop it knew.

bin/lighthouse.ts is the callable surface — the panel's engine with a table
instead of JSON, exiting non-zero when anything is short of 100 so it can gate
something without being parsed.

affordances/ carries the short always-loaded note: you can measure this
yourself, here is the line, do not ask a person. skills/ carries what the
findings mean, why the built site and the dev server are different questions,
and the three defects this has already caught — each one a rename or a setting
that left something behind and produced no error.

Both are read in place from the plugin directory by capabilitySources, so they
arrive with the plugin and need no publish step.

The invocation globs ~/officer*/platform because the checkout is officer-dev on
one machine and officerdev on the other, and a doc that is right on one box and
wrong on the other is worse than no doc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:27:24 +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 52164a7fec say when a media block points at something not converted yet
The site serves nothing but derived files, so a block pointing at an original
the pipeline has not been through renders an empty box — on the page, in the
preview and in the editor. That is a normal thing to hit: drop a file in, choose
it in the same minute, and you are ahead of a conversion that only starts twenty
seconds after the folder goes quiet.

The action bar already said "Sync 3". What was missing was the link from THIS
blank block to that number, which is the half that costs an afternoon: an empty
box looks like a block saved wrong, so it gets looked for in the wrong place.

So the failed thumbnail is caught and named, using the state the bar already
polls: converting now, in a run that started before this file, outstanding for
the next one, or nothing running — with Sync now in place for the last case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:29:28 +01:00
pastilhasandClaude Opus 5 c2620e23b4 show the whole path, not the filename
Every collection has a hero and every folder a _1, so a bare filename says which
file only to somebody who already knows. Not truncated either: truncate cuts the
end, which is the wrong half of a path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:26:37 +01:00
pastilhasandClaude Opus 5 a0720b5160 name the columns, not the buttons
A two-column band listed its blocks unlabelled and put the side on the add
buttons. A band whose only block was on the left therefore read as a band with
nothing on the left and two ways to fix it.

The side belongs on the group, which is the thing that has one. Left and Right
head their own lists behind a rule; the buttons say what they do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:18:51 +01:00
pastilhasandClaude Opus 5 62da0037c6 bigger thumbnails in the carousel editor
48px to 96px. At 48 a photograph is a colour swatch — you can tell two apart
but not what either is, which is the one thing the list is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:08:01 +01:00
pastilhasandClaude Opus 5 6e33e89e2c finish the carousel rename in the panels
Two of these were live: PageBuilderPanel still compared kind === 'gallery' in
two places, so a carousel block's editor and its Choose button were both dead
branches. The type checker could not see them — both compare against string.

The rest is copy somebody reads: "Change gallery", "Name a new gallery",
"Reading the gallery", "Add to this gallery", Home's Gallery heading, and the
"Create Gallery" file action, now "Carousel from folder".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:06:06 +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 5e5c06e76e Pieces: the Gallery, as a section like Collections
The Gallery is a flat list of peças, each with a title, a gallery of mixed
media, and the specs from the client's sitemap — measurement, raw material,
colours, and NOTAS: production time and shipping conditions.

That is a collection one level flatter, so the panel is shared rather than
copied. SubjectPanel and SubjectFields take a section config (subjects.ts);
CollectionsPanel and PiecesPanel are one line each.

The five template blocks are renamed collection-* to subject-*. They never read
anything collection-specific, and copying them per section would have meant
twenty kinds by the time Drops and Collabs land.

Called Pieces in the nav, not Gallery — there is already a Galleries section
meaning a named set of media, and two rows called Gallery and Galleries is a
question somebody has to answer every time they look at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 17:44:32 +01:00
pastilhasandClaude Opus 5 179507fb7b the rich text editor offers three heading levels
One button that toggled H2, so a document could hold exactly one weight of heading — and the levels the
site's sanitiser already accepted were unreachable from the editor that writes for it.

Three buttons rather than a dropdown: they are toggles like everything else on the bar, the active one
answers "what is this line", and a select would hide that behind a click.

A level is a rank in the outline and never a size. What each draws at is the site's — three of the four
type roles, in the prose rules — so the Layout section stays the one place type is sized.

`[open]` H1 is offered because it was asked for, and a page that already has a title now has two. That
is an outline a search engine reads as two documents, and it wants a rule about where H1 belongs before
this gets used much.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:55:48 +01:00
pastilhasandClaude Opus 5 44a907f3b1 a heading can say which type role it is
Every heading — a band's and a block's — was `type-title`, so the only way to make one bigger was to
resize Headings for the whole site.

A ROLE rather than a size. The four are named and sized under Layout, so a heading set to Titles
follows whatever Titles is set to and keeps following it. Offering point sizes here would be a second
place to change one, and the two would part company the first time somebody used the panel that exists
for it. Same `ROLES` list Fonts and Font Sizes read, so all three name the same four things in the same
order.

Choosing Headings stores nothing. A heading nobody has thought about and one deliberately set to the
default should not be two different rows.

Disabled with no heading, because the size of a heading that does not exist is not a question — and
written on its own rather than through the draft, because a select has one value and waiting 600ms to
watch a heading change size reads as a control that has not responded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:49:01 +01:00
pastilhasandClaude Opus 5 6781fd5875 a band chooses whether a rule follows it
The site separates bands with an edge-to-edge line, and it was `index < sections.length - 1` — not a
decision anybody could make. Two bands that belong together, a heading beside its picture and the
carousel that continues it, read as two pages with a rule between them.

Putting both in ONE band does not solve it: a band is one row, so a full-width strip inside a
two-column band overlaps the other column. What was wanted was never one section; it was no line.

A checkbox rather than a field in the draft, because it is a switch — there is nothing to debounce, and
waiting 600ms to watch a line disappear reads as the control not working. Labelled by what it does
rather than as `divider`, which is the column name and says nothing about what you will see.

Still never on the last band: a line above the footer is the footer's own border, twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:20:06 +01:00
pastilhasandClaude Opus 5 7081e1139d the frontend has been failing to build since the cover was removed
Removing `chooseCover` cut the wrong span and left an orphan `});` behind. Bun's parser refused the
file, `rebuildFrontend()` failed at every boot, and the server went on serving the last bundle that
built — 14:23. Every plugin change after that reached the disk, was merged, was restarted, and never
reached a browser.

Which is why the template preview looked unfixed twice: the fix was live on the server and absent from
the page. The symptom of a failed frontend build is not an error anywhere a person looks — it is the
previous build, working perfectly, being wrong.

Also fixes a choice with `kind: null` — one that is not built yet — being filtered out by a list that
only knows about the kinds that are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:11:06 +01:00
pastilhasandClaude Opus 5 cb6026059a a template previews against something real
The builder loaded the template's own slug — `/collection` — which is not an address, so the frame
showed a 404 while the same arrangement rendered perfectly at /collections/70s in a tab. The page was
fine; the preview was asking for the wrong URL.

It previews an instance now: whichever the server names in `preview_slug`, the first of whatever the
template draws. Which row it is does not matter and deliberately is not a choice — a template IS the
arrangement, and the point of the preview is to see the arrangement with something real in it.

Null when there is nothing to draw yet, which shows as an empty preview rather than a broken one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:03:32 +01:00
pastilhasandClaude Opus 5 671f049fb3 the lead photograph, and names for the subject blocks
`collection-cover` draws the FIRST photograph in the gallery, large, beside the words — the shape the
client's sitemap draws. Not a second way to choose one: a gallery is ordered and reordering it is how
the lead changes, which is the rule everywhere else here.

The five subject blocks are places-only, like the contact form and the partner row. They hold nothing
at all — what they draw is the collection the URL names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 14:58:54 +01:00
pastilhasandClaude Opus 5 331e615c7c blocks that draw whatever the page is about
`/collections/amadoras-mythology` and `/collections/70s` are the same page with a different subject.
Building each would be absurd; hard-coding the arrangement would put it out of reach of the admin. So
the arrangement is built once — a template page, composed exactly like any other — and every
collection is drawn through it.

Four blocks read the subject: its name, its description, its specs, its photographs. They hold nothing
at all, not even a reference. `collections` holds no content because the list is a query; these hold
none because the subject is the URL, and the same block draws a different collection on the next
request.

Offered only on a template, and HIDDEN rather than disabled elsewhere. A greyed-out row invites "why
not here", and the answer — this page has no subject — is not something a tooltip fixes. Available and
silently drawing nothing is the worse failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 14:53:48 +01:00
pastilhasandClaude Opus 5 ad988e4557 the first photograph leads, and nothing says so twice
A collection named its own main picture. A gallery is already ORDERED — somebody drags it into the
sequence they want — so "which one leads" was being answered twice, in two screens, by two mechanisms
that could disagree. Reordering a gallery and then wondering why the card had not changed was a bug
waiting to be filed.

So the grid here decides nothing at all. It is what the collection SHOWS, in the gallery's order, and
the pictures are no longer buttons: making them look pressable would promise a control that lives in
another screen. Adding, reordering and framing all belong to Galleries, and now so does this.

The `cover` column is gone from both databases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 14:34:27 +01:00
pastilhasandClaude Opus 5 bc494fb57a rename a gallery, and leave its address alone
A gallery could be created and deleted and not renamed, so fixing "Collections - 70´S" meant deleting
it and choosing its photographs again — which would also have detached the collection pointing at it.

Renaming writes the NAME and nothing else. Everything that references a gallery references its id —
`collections.gallery_id`, and a gallery block's `content.galleryId` — and nothing renders through the
slug, so a rename cannot break a reference today. The slug is left alone anyway, because it is an
ADDRESS: it can end up in a link somebody sent, and on the day something does resolve by it, a label
having quietly moved it would be a very hard bug to find.

The row says so while you are typing rather than afterwards, because somebody about to change a name
is exactly who wonders whether the address moves with it.

A gallery whose slug is genuinely wrong is a different act with different consequences, and it should
look like one. The API already takes them separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 14:23:34 +01:00
pastilhasandClaude Opus 5 d459c53c3f a selection that no longer exists is dropped, not reported
The collection id lives in the URL, so it outlives the row. Delete one with it open — or have somebody
else delete it — and the address still asks for it, which made the media fetch 404 and put "Not found"
across a panel that was otherwise working perfectly. An error about the URL, phrased as an error about
the server.

Now a selection naming nothing is quietly removed from the URL. There is nothing anybody could do
about it and the correct state is simply "nothing selected".

The error is also cleared when the selection moves. It was only cleared on the next failure, so a
message from the last collection stayed up over the new one and read as this one having failed too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 14:14:42 +01:00
pastilhasandClaude Opus 5 12f9774a4b a collection is made, and points at a gallery
The version before this made a collection out of every folder under originals/COLLECTIONS/. It kept
the disk and the database in step for free, and it also let whoever last tidied a directory decide the
website's structure. A collection is editorial: it exists because somebody made it, named it and put
photographs in it.

Its photographs are a GALLERY — the feature that already means "a named, ordered set of media", with a
picker and an order somebody dragged into place. A collection points at one rather than growing a
private copy, so ordering stays maintained in the one screen that owns ordering, and the same set can
appear in a collection and on a page without being chosen twice.

That is why the grid here is not draggable and has no Add: both belong to Galleries. What is decided
HERE is which photograph leads, because that is a fact about the collection rather than about the set.

Creating is a name and a button under the list it adds to, and it selects what it made — a dialog for
one field is a dialog nobody thanks you for. Deleting removes the collection and never the
photographs, which the button says out loud rather than leaving somebody to find out.

Changing the gallery clears the cover. The new set is different photographs, so a cover chosen from
the old one is a path that may not be there, and falling back silently would be a choice quietly
reverting itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 14:10:56 +01:00
pastilhasandClaude Opus 5 60eb6ecfc9 a place to manage each collection
Collections, above Galleries and Media, where the other things made of media already are.

There is no Create button and no Delete, and that is the design rather than an omission. Which
collections exist is a question about `originals/COLLECTIONS/`, so making one means making a folder —
in Media or in Finder, and both work. A Delete here would either drop the row and leave the
photographs, so the collection reappears on the next read, or delete somebody's pictures. Neither is
what Delete should mean in an admin.

The grid is for choosing the MAIN photo and nothing else. The photographs are already in filename
order and the gallery is the folder, so there is nothing to reorder and nothing to add — the one
decision is which picture leads. Pressing the chosen one again clears it, which is how "no choice, use
the first" stays reachable without a second control that only does that.

The badge says Main rather than Cover because it has to be true of the first photograph, which leads
by default, as well as of one somebody picked.

The fields are the other half — everything a directory cannot tell you: the name it goes by, the
address, and the specs from the client's sitemap. `folder` is shown and not editable, and the API
refuses to write it: it is an address, and renaming one moves files. Shown anyway, because it is the
only thing that says WHICH folder a collection is.

One draft, one save. Per-field writes would be eight requests for somebody filling in a form, and the
last to land would win in an order nobody controls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 14:03:54 +01:00
pastilhasandClaude Opus 5 dde7de67aa a block that lists every collection
The first block whose content is a QUERY rather than something somebody typed. It stores nothing at
all: a page holding "the collections" must not also hold a copy of which collections there were, or
adding one tomorrow would mean remembering to edit every page that lists them.

That makes it a places-only kind alongside the contact form and the partner row — one click to add, no
editor behind it. The hint says "every collection" rather than naming a picker, because there is
nothing to pick.

Gallery, Drops and Collabs all want the same shape. This is the one that decides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 13:27:49 +01:00
pastilhasandClaude Opus 5 5da573b219 the sync button exists somewhere again
It moved out to the panel's bar beside Build and Publish, and then the bar was removed — so for the
last few days there was no way to start a media sync from anywhere in this admin. The API route was
fine the whole time; nothing pressed it.

Quieter and worse: `SyncButton` is the only caller of `useMediaSyncFeed`, so with it unmounted the
progress card in Administration was not being polled either. It would have sat there through a running
sync showing nothing.

Back beside the two folders it operates on, which is where it was before and where somebody looking
after the media already is. Under the status line rather than beside it — that line runs to two clauses
and a button sharing the row would move as the counts changed.

Sync, Build and Publish still want a home of their own where all three are to hand without opening
Administration. This is the one that could not wait for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:04:32 +01:00
pastilhas 4587ce2d39 Revert "frame a photograph on the page, at the size it is printed"
This reverts commit 812371b64a.
2026-09-03 20:47:23 +01:00
pastilhasandClaude Opus 5 812371b64a frame a photograph on the page, at the size it is printed
The panel frames a 21:9 header in a 200-pixel thumbnail while the real one is on screen six inches
away, in the layout it belongs to. Dragging the picture itself is the obvious control, and until now
it was also the expensive one: every new rectangle made the site cut a fresh ladder — nine widths, two
formats, 1.54s measured — with the preview waiting for it.

Both halves are gone. The site draws the rectangle in CSS in development (previous commit, other
repo), so a framing costs nothing to show; the drag here is inline styles on the picture, and the only
write is one PATCH when the hand stops.

Inline styles rather than the site's custom properties, because the site renders a FRAMED picture one
way and an untouched one another — and an untouched photograph is exactly the one somebody is about to
drag. Inline beats both, and beats the object-fit the unframed path leaves behind, which would
otherwise crop the picture a second time inside a box that is already the crop.

Attached to every shaped photograph rather than to the selected one. Selection would put itself in the
paint's dependencies, so choosing a block would repaint the whole page's chrome; this way onDocument
still depends on the sections alone. The cost is that a click could become a nudge, so nothing moves
until the pointer has travelled 3px — below that it is a click, it selects the block, and no crop is
written.

The panel's zoom buttons now reach the real picture too, through `onDraft`. Otherwise the thumbnail
and the page would disagree for the 600ms until the debounce lands, which reads as a broken control
rather than a delayed one.

crop.ts is new and is the reason this is safe: parseCrop had already been copied once, and a crop that
means one thing in the panel and another on the page is a bug you can only feel. MediaField now draws
its own preview through the same `rectToBox`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 20:36:03 +01:00
pastilhasandClaude Opus 5 6098ae2285 the preview measures the page with its minimum height off
The site's shell is min-h-dvh so a short page puts its footer at the bottom of the
window. Measuring it then asks the frame how tall the frame is, and the answer is
whatever it already was.

So the minimum is neutralised by a stylesheet injected into the frame, and what is
left is the height of what is on the page.

The cost, stated: the preview does not show a footer pushed to the bottom of a
window, because it is not a window. It shows the page ending where it ends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:23:05 +01:00
pastilhasandClaude Opus 5 ab049f5ddc no border under the last pinned row
Whichever it is: Administration is the one row a member never sees, so the row
that ends the group depends on who is looking. Naming it would put the line back
for everybody else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 06:57:29 +01:00
pastilhasandClaude Opus 5 6a86c69890 a band shows its blocks, from one shared list
Selecting a band showed its name and title and nothing about what was in it — so
adding a block to the thing you were looking at meant going back out to the page.

BandBlocks is that list, used by the band's editor and nested under every band in
the page's structure. Same list, same actions, one component; the galleries panel
taught that lesson once already.

Blocks reorder within a column now. Across columns is the same call — the write
carries the column — but a list per column has no gesture for crossing between
them, and inventing one here would be guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:49:46 +01:00
pastilhasandClaude Opus 5 a922c6fda2 blocks nest under their band, with a button per column
Adding a block means choosing which column it goes in, so a flat list cannot
express it. A one-column band shows one button and a two-column band shows two —
and a band with one column never has to answer a question it has no answer to.

The blocks are links, like everywhere else: pressing one opens its editor.

That is the last of the four actions the painted-on bars used to carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:44:27 +01:00
pastilhasandClaude Opus 5 eb8da779a7 the page's structure, in the editor
Add, delete and reorder a band. Those three were reached from a bar painted onto
the page until the overlays went, and have had no door since.

Shown when nothing inside the page is selected — after the band and the block,
because those are things you opened deliberately and this is what is there when
you have not.

The nav lists the same bands and is for GOING somewhere; this is for changing what
the page is made of. Two verbs on the same nouns, and mixing them would mean a nav
row that sometimes navigates and sometimes deletes.

Same reorder gesture as pages, gallery items and partners.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:00:35 +01:00
pastilhasandClaude Opus 5 26e87df6e5 reload by changing the src
Three versions of this now, and the reasons are worth keeping.

key={reloadToken} replaced the ELEMENT, so the panel went white between
documents. contentWindow.location.reload() kept the element but did not reliably
re-fetch. A changing src does both: the browser navigates the same element — so
it keeps painting the current document until the next commits — and the URL
differs, so nothing is served from cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:47:13 +01:00
pastilhasandClaude Opus 5 2b2aacca14 reload the preview without blanking it
The frame carried key={reloadToken}, so a reload was a NEW element: React
unmounted the old one and the panel went white until the next document arrived.
After a crop change that is not instant — the site is asking av-club for a fresh
ladder — so every framing adjustment flashed an empty panel, which reads as the
preview breaking.

A browser keeps painting the current document while the next is being fetched. So
the element stays and the frame is navigated instead. Same origin through the dev
proxy, which is what makes contentWindow reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:44:02 +01:00
pastilhasandClaude Opus 5 3053765494 debounce the framing, or the preview reloads sixty times a drag
MediaField is controlled: it reports every pointer move and draws what it is
handed back. Wired straight to the block, that was a PATCH and a full reload of
the preview per move, and the page went white while somebody dragged.

The drag runs against local state now — instant — and the write is debounced by
useAutosave, the same hook every other control here uses.

The reset effect keys on the url and the shape, not on the crop. Keyed on the
crop it would fight the drag: the parent's value is the last SAVED one, so every
move would snap back until the save landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:40:35 +01:00
pastilhasandClaude Opus 5 53d1937430 frame the photograph in the shape you chose
With a shape set, MediaField replaces the plain thumbnail: it draws the picture
in that shape and lets somebody drag and zoom to say which part shows. It already
did all of that — it needed the block's crop and the aspect.

Without a shape there is nothing to frame into, so the thumbnail stays and there
is no framing control that would do nothing.

Changing the shape clears the crop. A crop is a rectangle OF a shape, and keeping
it would frame the new one by numbers chosen for the old.

One SHAPES table, because three things have to agree: the options, the float
stored as content.ratio, and the Tailwind aspect MediaField frames into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:38:02 +01:00
pastilhasandClaude Opus 5 16295eeea1 Shape, on a photograph block
Choose 16:9 and the band gets shorter than the picture — the ratio becomes a slot
shape and the site serves a rendition cut to it, rather than a whole photograph
scaled down by the browser.

`setContent` writes the whole content object, because the API replaces it rather
than merging: a PATCH with `{ ratio }` alone would drop the url.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:29:36 +01:00
pastilhasandClaude Opus 5 2ec48a1a76 a reload lands where you were, not at the top
The scroll ran on `rendered` changing, which is the moment the reload STARTS —
frameDoc still pointed at the outgoing document, so it scrolled the page about to
be replaced and the new one arrived at the top. Every write from the editor did
it: reorder a gallery or a partner, lose your place.

It now happens in onDocument, against the document that has just loaded. The
effect stays for the other case — a different band chosen with no reload — and
keeps its smooth scroll, because there you are watching it move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 20:58:23 +01:00
pastilhasandClaude Opus 5 4435537ae2 a partners block edits the site's partners
The block places the list; it does not hold one. So this edits the singleton and
every page showing them changes together — which the panel says out loud, because
the editor sits inside a block on a page and everything else in that column
belongs to the page.

Same gesture as the gallery items: a grip on the left, live reorder, one write at
the end carrying every id. Two ordered lists, one thing to learn.

A new partner is named from its filename and renamed in the field beside it — the
API refuses one with no name, and asking in a second dialog before the logo has
been seen is a form in front of a form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 20:55:07 +01:00