Commit Graph
43 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 2e56260a88 a Servers section: development and production, both under pm2
pm2 for BOTH, development included. A `bun dev` started in a terminal dies with
the terminal, and nobody can tell whether it is running without finding the
window it was started in. Under pm2 the two are the same kind of thing — a named
process with a status, an uptime and a log, startable from here and inspectable
from anywhere. The cost is that development stops printing to a console somebody
is watching, which is what `pm2 logs fofos-dev` is for; the process names are in
the panel so nobody has to guess them.

This plugin owns exactly two names, `fofos-dev` and `fofos-prod`, and every
command is built from that pair and from nothing a caller supplies. No request
can stop, restart or delete another process on the machine. That matters more
than usual here: Officer's own processes share this pm2 daemon, and a plugin
able to name any of them would be a way to take the host down through a web
form.

Start is really a replace — delete, then start. Not `restart`: it fails on a
process that has never existed, and on one that has it reuses the environment it
was started with, so a port or database changed here would be saved, displayed,
and not in effect. That is an hour of somebody's debugging.

The environment is assembled here because this plugin is what knows the ports
and the database names, and it is handed to pm2 through the invoking command's
own environment — pm2 has no flag for it; `--env` names a block in an ecosystem
file. The site reads .env files first and the environment last, so this fills
what is absent rather than overriding what somebody wrote down.

Ports have no default. It is the one setting where a plausible guess is worst:
it binds, the site appears to work, and it is quietly answering on a port
another service wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:43:48 +01:00
pastilhasandClaude Opus 5 b564282609 the naming form is about naming, and it had been wired to the wrong question
Two conditions with similar names got conflated by a search-and-replace.
`settled` means the databases are named and exist, which is when the form has
done its job. `settledNow` means everything including BACKUPS is configured,
which is when the whole block can fold away. The form/rows branch was using the
second.

That deadlocked it. The component reporting whether backups are configured lives
inside the rows branch — so showing the form meant it never mounted, never
reported, and the form could never be replaced. The panel went back to asking
for names it already had, with no way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:39:15 +01:00
pastilhasandClaude Opus 5 51ccf55a3c stop the Database block flickering, properly this time
It switched to the live answer as soon as `status` arrived — while
`backupsReady` was still its initial `false`, which is a REPORT of not-ready
rather than the absence of one. So it decided the section was unfinished,
painted open, and shut again a moment later when the real answer came.

`backupsReady` is now null until BackupsRow has actually answered, and the live
value is used only once every local part has. Until then the summary decides,
which is what it was added for.

And nothing but the header renders until the summary lands. The title and hint
are constants and can paint immediately; only the check and the body wait on
data — the same shape Root location settled on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:35:02 +01:00
pastilhasandClaude Opus 5 af31b57cdf the databases say what is in them, and the block folds away when it is done
Two databases with identical structure look identical until you count what is in
them — one is the live site and one is a copy. So a row now reads "18 tables ·
155 rows" with the time anything was last written, rather than only reporting
that a schema exists.

Counted with query_to_xml over information_schema, which is exact. reltuples
from the planner would be free and is an ESTIMATE: it reads as zero on a table
nobody has analysed, which is precisely the freshly restored database this is
meant to reassure somebody about. That is not a hypothetical — it is why
pg_stat_user_tables reported an empty database earlier today that had 155 rows
in it.

And the block collapses with a check once everything is in order, on the same
terms as Root location: both databases existing with a schema, and backups
configured. Not backups having RUN — a site set up an hour ago has taken none,
and calling that incomplete is telling somebody to fix what is not broken.

Seeded from /summary so the first paint is already right, then kept live by what
the block and BackupsRow know. The `null` sentinel in `collapsed` is left alone
while unsettled, which is the bug that stopped Root location folding at all.

BackupsRow reports its bit from an EFFECT, not during render: calling the
parent's setter mid-render is an update to a different component, which React
refuses outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:32:42 +01:00
pastilhasandClaude Opus 5 9ff25256c1 back up on publish, which is when this site actually changes
A nightly backup of a site that only changes when somebody publishes spends most
of its life copying a database identical to yesterday's, and misses the one
moment worth capturing. So "On publish" joins the schedules, and goes first.

Stored in the same column as a cron expression because it answers the same
question — WHEN — and a second column would mean every read had to decide which
of the two was authoritative.

It creates no cron job and no catch-up tick. A missed run is a run whose TIME
passed while the machine was down; an event has no time, so there is nothing to
have missed, and a catch-up here would take a backup every fifteen minutes of a
site nobody had touched.

`backupAfterPublish()` does nothing unless this is the configured trigger, so
the publish path can call it unconditionally and never has to know what the
backup settings say. It swallows failures for the same reason the timer does: a
backup that could not be taken must not present as a publish that failed, when
the thing the person asked for worked.

Nothing calls it yet — there is no publish in this plugin, because the sections
that would do the publishing are not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:19:22 +01:00
pastilhasandClaude Opus 5 89c9dd6f63 restore: pick a dump, pick a database, say yes
A pg_dump carries CREATE TABLE, so it cannot be layered onto a database that
already has the schema — it fails on every object. The only honest restore
empties the target first, and that is not a detail to bury behind a button. So
choosing a destination and confirming are two separate clicks, and the second
names what it is about to destroy.

`confirm` is required by the function, not merely by the panel, so no code path
can reach a restore by accident.

The file is matched against what listDumps actually found in the backup
directory rather than taken as given. There is no path to traverse out of and no
way to point psql at a file this module did not write: an unknown name is
refused before anything is opened, rather than sanitised and hoped about.

Emptied with DROP SCHEMA public CASCADE through psql, not by dropping the
DATABASE — that would need a connection to another one, fail while anything is
attached, and lose whatever was granted on it. Replacing the schema does the
same job to the part that matters.

psql rather than pg_restore, because these are plain-SQL dumps, which is what
pg_dump writes without -F c.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:11:57 +01:00
pastilhasandClaude Opus 5 c429dc3455 a remote path without a leading slash is relative to the remote home
Requiring an absolute one was simply wrong. `scp file host:db-backups/fofos/`
lands under the remote account's home — that is what every ssh tool does, and
what anybody typing a destination expects, since the home directory is the one
place they are certain to be able to write.

What is still refused is unchanged and is the part that matters: the characters
a shell would act on. This value IS interpreted remotely, since `ssh host
mkdir -p <path>` runs through a shell, so they are rejected rather than escaped
— refusing is checkable by reading the function, escaping is checkable only by
being right about every shell.

The summary shows a relative destination as ~/… because that is where it lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:07:50 +01:00
pastilhasandClaude Opus 5 5fc710f072 backups go to another machine, and old ones are pruned
Three things.

The local directory defaults to <officerdev root>/dockers/postgres/dumps,
derived from OFFICER_ROOT rather than written down — the same reasoning as
rootPath: a path fixed at install freezes the layout of the machine it was
installed on, and a restore elsewhere then points somewhere that is not there
while looking deliberately chosen.

The destination is SSH, and it is now what "set up" MEANS: a dump sitting on the
machine it came from is a second copy on one disk, lost to the same failure that
would make anybody want it. So local is staging and the copy off the box is the
backup, and a schedule with nowhere to send it does not count as configured.

Testable, in two steps reported separately, because they fail for different
reasons and the fixes differ: the connection is keys and networking, the
directory is permissions on the far side. It tests what is in the FORM rather
than what is stored — testing a saved destination while somebody looks at a
different one is worse than not testing.

Everything goes out with BatchMode=yes. Nobody is at a terminal when this runs,
so a key that has lost access must FAIL rather than block forever on a prompt
nothing will answer. It is also why no credential is stored: there is none to
store and none to leak.

The remote path is the one value here a shell interprets — `ssh host mkdir -p
<path>` runs through it — so shell characters are refused rather than escaped.
Refusing is checkable by reading it; escaping is checkable by being right about
every shell.

And rotation keeps the newest N per database at both ends. Per database, because
two sharing one budget means a busy one evicts the other's history. Sorted by
NAME rather than mtime: the timestamp is in the filename and is sortable, while
mtime stops meaning "when this was taken" the moment a file is copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:03:30 +01:00
pastilhasandClaude Opus 5 ac3d8b76d9 every six hours, between hourly and daily
Anchored to the clock — midnight, six, noon, six — rather than to whenever the
schedule was saved, which is the whole reason these are cron expressions.

Verified the catch-up check reads its period as six hours, since that is what
decides whether a run was missed while the machine was down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:57:09 +01:00
pastilhasandClaude Opus 5 1b398121b7 backups: a schedule, a location, and a timer that survives a restart
Restore goes. What it would read from was never decided, and a disabled button
with a tooltip explaining it is not built is worse than no button.

In its place, backups. `cron` was already a dependency and used nowhere, so the
schedule is a real cron expression — a backup wants to happen at an HOUR, and an
interval counted from whenever somebody pressed save drifts across the clock
until it runs mid-afternoon.

Cron alone is not enough, and this is the part worth writing down: it does not
run a job it was down for. A daily backup at three on a machine that restarts
overnight is skipped silently — the schedule is still there, the next fire is
still in the future, and a month later there is one file. So `lastBackupAt` is
in the database, and both the startup and a fifteen-minute tick compare it
against the schedule's own period, computed from two successive fires. A backup
that should already have happened happens now.

The job handle is module-level and stopped before a new one is made. Two saves
leaving two jobs is a bug that surfaces as backups arriving in pairs, weeks
later, with nothing connecting it to a settings form.

Back up now exists because a scheduled job nobody has ever run by hand is a
guess — it is how you learn the location is writable and pg_dump can reach the
databases without waiting until three to find out it cannot. The list underneath
is a readdir of the location rather than a record of what should be there: a
schedule that has been failing quietly looks exactly like a working one until
you count the files.

pg_dump per database, not pg_dumpall — a cluster dump would also carry Officer's
own, which is not this plugin's to copy around. And a failed run still records
the timestamp, or one undumpable database would have the catch-up tick retrying
every fifteen minutes for the rest of the week.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:54:54 +01:00
pastilhasandClaude Opus 5 bda59bdf03 one spinner per action, one button for both, and the shared console
Four things, all of them the panel telling a small lie.

A single `busy` boolean put BOTH rows into a spinner while only the pressed one
was doing anything — the other looked started, then finished without having done
a thing. It is now which action is running, so a row spins when it is the one
running or when both are.

Pushing both is the common case and was two clicks. It is one, running
production then development sequentially: they run the same drizzle against one
Postgres, and two at once buys nothing but an interleaved transcript.

The status is re-read after every push regardless of outcome. It was taken from
the route's reply, which only exists on the path where the push SUCCEEDED — a
failure answers 400 and throws, and a push that failed halfway can still have
changed the table count. That is the difference between the panel being right
and the panel being right when nothing went wrong.

And the output is the same console the checkout uses, extracted rather than
copied: black, copyable, gone a few seconds after a success and never after a
failure. WebsiteCheckout keeps its behaviour and loses the state that used to
implement it.

Restore stays disabled. What a backup IS here is still undecided, and a button
that guesses at the source of a restore does not belong near a client's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:47:58 +01:00
pastilhasandClaude Opus 5 0971764fca once both databases are there, the form becomes two rows of actions
A text box invites an edit nobody is here to make. Named and created, the two
things somebody wants are per-database actions, so the inputs and the button go
and each database becomes a label, a table count, and what can be done to it.

Push schema runs the WEBSITE's own `db:push`. Not drizzle-kit driven from here
with a config this plugin assembles — the schema, the drizzle version and the
push command all belong to that repository, and reimplementing any of them would
be a second definition of the site's database going stale on its own schedule.
This supplies the connection and runs their script.

The connection goes in the environment, which works because src/env-file.ts
consults .env files first and process.env last. So an ambient value is used only
where no file in the checkout sets that key, and this cannot quietly override a
connection somebody wrote down.

Their name guard still applies and should: env.ts refuses to connect unless the
database name matches what FOFOS_ENV expects. A plugin that worked around that
would be defeating the one check standing between development and live data.

"Schema pushed" is a COUNT of tables in public, not a comparison against the
site's expected tables — that list would be a second copy of the site's schema,
stale the first time it gains a table, and the question being asked is only
whether anything is in there yet.

Restore is present and disabled, gated on the schema being pushed. It is not
wired: which backup it reads from is not decided, and guessing at the source of
a restore is not a thing to do with a client's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:14:39 +01:00
pastilhasandClaude Opus 5 30c52e6449 one button, and it appears when there is something to press
Two buttons was the wrong model, and it failed in the worst way: Create keyed
off the SAVED names, so on a fresh screen with both fields filled it did not
render at all — and the hint explaining why was behind the same condition. Two
filled fields, one Save, and nothing saying what came next.

Naming and creating are not separate decisions from the outside. You type two
names because you want two databases. So one button that records them and
creates whatever is missing, labelled with whichever of those it is about to do,
and disabled only when there is genuinely nothing to press.

The tick beside each field now asks pg_database about the text in the BOX rather
than the text last saved. That is the question somebody typing a name has, and
answering it about a different name was worse than silence.

The PUT that only stored names goes with the second button. Nothing called it,
and a second way to write the same two columns is a second thing that can drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:09:54 +01:00
pastilhasandClaude Opus 5 a1fe543dff two database names, and a button that creates what is missing
Production and development, stored as NAMES rather than URLs. The site runs on
the same Postgres Officer is already connected to — that is the whole reason a
name is enough — and a URL here would be a second copy of a host, a port and a
credential that already exist in the platform's configuration, free to drift
from it and free to point at another machine.

Naming and creating are separate, and were worth two buttons. A database that
already exists still needs its name recorded, and making Create the only way to
save would leave nobody able to point the site at something already set up. The
tick beside each field comes from pg_database, not from what was typed — the
useful question is whether the thing exists, and that came apart from "somebody
saved a name" the moment the two actions split.

CREATE DATABASE cannot be parameterised, so the name goes into the statement as
text and the identifier check is the entire security boundary. It is therefore
strict rather than accommodating — lowercase, leading letter or underscore,
nothing else — and it is re-run immediately before the statement is built,
because the gap between storing a name and using it is exactly where a check
that happened earlier stops being a check. Postgres folds unquoted identifiers
to lowercase, so accepting `Fofos` would create `fofos` and then report a name
nobody could find; refusing is kinder than silently changing it.

Officer's own database and the Postgres templates are refused outright. Pointing
the site at Officer's database would not read as a configuration mistake — it
would read as the platform corrupting itself.

Nothing here drops a database, and nothing should: `fofos_website` and
`fofos_old` are one keystroke apart and one of them holds the client's site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:06:40 +01:00
pastilhasandClaude Opus 5 300ae1b94f no Loading row either, since the header is a constant
The last flicker was one line of text being replaced by another. The title and
the hint never depend on anything fetched, so they can paint immediately; only
the check on the right and the body have to wait.

So the loading state renders the header with nothing in it. The settled case —
nearly every load — goes from header to header-plus-check, and nothing on screen
moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:58:19 +01:00
pastilhasandClaude Opus 5 e621bc761d the block knows it is settled before it paints, so it stops flickering
It could not. Each part of Root location fetches its own status, so the block
had no way to know whether it was finished until its children had mounted and
reported — meaning it painted open and then folded away, on every load. That is
not a rendering problem to tune: the information genuinely was not there yet.

So one request answers the three bits first, and nothing renders until both it
and the path have arrived. The children still own their detail and still report
changes upward while the block is open; the summary only seeds the opening
state. Two sources for one fact with a clean division — the summary is what is
true on ARRIVAL, the callbacks are what changes while somebody is looking.

`settledNow` picks between them: the summary while nothing has reported, the
live value once anything has. Using the live value throughout would open the
block for exactly the gap between the summary landing and the children
answering, which is the flicker with extra steps.

The summary walks the media tree, so it is deliberately not polled, and it skips
the walk entirely when there is no media folder to walk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:55:40 +01:00
pastilhasandClaude Opus 5 cc283ee7b6 Root location folds away once there is nothing left to do
Everything under it is in order most of the time, and a card that is four
paragraphs of "fine" pushes the settings that are not fine off the screen. So it
shuts, with a check on the right, and opens on a click.

"Finished" is three bits reported upward by the parts that own them: the path is
saved, the checkout is a repository, and media/ both exists and is current. None
of those is knowable in the parent — each child fetches its own status — and
lifting all of it up to answer one question would be a far worse trade than
three booleans.

It is only collapsible while settled. A block you can shut on unfinished setup
is a way to hide the thing you came here to fix. And the children are NOT
RENDERED while it is shut rather than hidden, because a collapsed block whose
children still poll is a settled panel making requests all afternoon about
something nobody is looking at.

The `null` in `collapsed` is a sentinel for "nobody has decided", which is what
lets a deliberate expand survive. Setting it to false while unsettled spent it
on the first render — before any status had arrived — so the block could never
fold itself away at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:52:54 +01:00
pastilhasandClaude Opus 5 f30d40446a a ledger of runs, and what is still outstanding
Two things the panel could not answer. A run's state lives in a Map, so a
restart erased it — and with it any record that a file FAILED. That is the gap
the manifests structurally cannot cover: a derived folder records the size,
mtime and variants it was built from, so provenance is already precise, but a
file that did not build has no folder and leaves no trace. Once the watcher is
running, a failure at three in the morning would never be discovered.

So one JSON line per run, appended to media/.sync-log.jsonl. A file rather than
a table because db/schema.ts is emphatic that the connection is the only thing
this plugin stores, and a ledger is losable — re-run and it rebuilds. Beside
derived/ rather than inside it, so wiping derived does not erase the history of
what happened before, which is the comparison it exists for. Written in a
finally, so a run that died still records that it did.

And the line above the button now says what is TRUE rather than what a run once
did: reconcile() walks originals, reads each manifest, and reports current,
missing and changed. It uses upToDate — the same predicate the sync uses to skip
a file — because two definitions of "current" is how a panel comes to say
everything is in step while the next run rebuilds half of it. Missing and
changed are separated because only the second means somebody edited something.

The ledger is not polled. It reads the whole tree, so it loads on arrival and
again when a run stops — the only two moments its answer can change.

Sync also takes an optional folder now, so a subtree can be re-synced on its own
rather than the whole library. The scope is recorded on each run, because "3
built" means different things for one folder and for everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:47:32 +01:00
pastilhasandClaude Opus 5 792f166c31 the sync lives with the folders it syncs
"Derived media" was a section sitting beside the block that lists `originals/`
and `derived/` — the same subject described twice, leaving the reader to work
out that the two were about one thing.

So the control moves under those folders, inside Root Location, and appears only
once both exist: before that the button would be asking to write into a
directory that is not there, and Create is the thing to press. An order, not a
choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:36:12 +01:00
pastilhasandClaude Opus 5 e87e19ee46 derived/ is built from originals/, and you can watch it happen
The mapping: derived/ mirrors originals/ directory for directory, and where
originals holds a FILE, derived holds a DIRECTORY of the same name — extension
included — with the renditions inside it. Keeping the extension is what makes it
total: a.jpg and a.png are different originals and must not share a folder. The
inverse is pure string work, so the filesystem is the record and there is no
second copy of the truth.

Images get a WebP ladder at 320/640/960/1280/1920/2560 plus full.webp, and
nothing is ever upscaled — a rung wider than the original would be a soft file
that srcset then confidently prefers over the sharp one. full.webp always
exists because for a HEIC it is the only browser-readable copy of the picture
there is.

Video gets av-club's web-1080 and web-720 presets by name, plus a poster. Images
are named by width because srcset descriptors are widths; video by height
because that is av-club's vocabulary and nobody misreads 1080.mp4.

The ladder is fixed here and the SITE chooses from it. This never reads
website/src/media/slots.ts — a plugin that knows a website's layout is a plugin
for one website, and that boundary is the difference between a template and
something fofos-shaped.

Each derived folder carries a manifest naming the size and mtime it was built
from, so the sync is idempotent and resumable: a run that died halfway is
finished by running it again. That is what makes an in-memory job acceptable
rather than merely convenient. It builds into <name>.partial and renames, so an
interruption can never leave a folder that looks finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:13:56 +01:00
pastilhasandClaude Opus 5 1b171c3335 the media preview steps through the folder
passes `onNavigate` to the viewer, which turns on the platform's new carousel:
arrows, a thumbnail strip and left/right/escape.

`?view=` is an absolute path in this panel, so the step is written straight back —
the name-relative-to-folder conversion the Files screen does is that screen's own
convention rather than a rule the viewer imposes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:28:39 +01:00
pastilhasandClaude Opus 5 1d820fc9d4 file preview, which never worked here and had to be built rather than fixed
on /files the viewer is NOT part of the file browser. `useFileViewerPanels` lives on
the SCREEN and swaps the whole workspace layout when `?view=` appears, opening a
viewer panel beside the browser. a plugin renders its own defaultLayout and has no
such hook — so double-clicking a file in this panel set `?view=` and nothing on earth
read it.

which means it never worked here. it LOOKED like it did for one deploy, because
`urlPath` was navigating to /files, where that screen did the work — so the bug I
introduced and then removed is the only reason anybody saw a preview.

the panel reads `?view=` itself now and renders FileViewerView, which the platform
exports for exactly this. OVER the browser rather than beside it: this panel is one
column of a two-column screen, and splitting it again would give a preview the width
of a phone.

closing clears `view` and `viewRoot` and leaves `media` alone, so you land back in
the folder you were in rather than at the top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:19:11 +01:00
pastilhasandClaude Opus 5 21935cbc27 the media folder lives in ?media=, not in the route
`urlPath` sent the whole app to /files the moment anybody opened a folder — it owns
the pathname, which is right for the Files screen and wrong for a panel. it uses the
platform's new `pathParam` instead, which names a search param and leaves the route
alone.

`?media=` rather than `?path=`: this screen already carries `?section=` and may one
day carry a second browser. a param named after what it addresses cannot collide
with one named after a concept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:01:41 +01:00
pastilhasandClaude Opus 5 3dd4dd6592 one header instead of three rows, and the folder lives in the URL
THE TOP WAS THREE ROWS OF FURNITURE before any content: the workspace's chrome
header saying "Detail", the panel's own strip saying "Media — Photos and video the
site is built from", and then the browser's toolbar.

a panel meta may supply its own `header`, which is the mechanism for exactly this and
which gitea, invoices and desktop already use. so the section's name goes where a
panel's name goes, and the strip inside is gone. "Detail" was never information —
the panel is the detail panel, and the useful word is which section is open.

the hint went with it. "Photos and video the site is built from" earns its place in
the NAV, where somebody is choosing between ten sections. repeated above the one they
chose, it is a caption for a decision already made.

AND THE FOLDER IS IN THE QUERY STRING. `urlPath` puts it in `?path=`, so a reload
lands where you were and a link can be sent to somebody — which for a media folder
somebody else uploads into is the point. it is opt-in in the platform because a
screen with two browsers would have them fighting over one parameter; this screen has
one.

`panelId` stays alongside it: the URL covers a reload or a shared link, and panelId
covers a panel re-opened later, which the URL cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:56:15 +01:00
pastilhasandClaude Opus 5 c7caa8af9f the banner actually goes this time, and the crumb says Media
the previous commit claimed to remove the explanatory line and did not. prettier had
reflowed that JSX onto one line, my multi-line match missed, and the commit carried
only the version bump — which I then reported as done. the banner was still on fofos
and the owner saw it.

same shape as the git-add failure earlier today: a command that did not do what I
asked, and no check afterwards. verified by grep this time before committing —
0 occurrences.

and the breadcrumb now reads Media rather than originals, through `rootLabel` on the
platform's FileBrowserApp. the folder's name on disk is how this plugin lays out a
root; the person looking at it never chose that word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:50:30 +01:00
pastilhasandClaude Opus 5 c38d269faf Media moves to the foot, and each section asks its own question
TWO REPORTS, AND THE SECOND ONE WAS A DESIGN MISTAKE RATHER THAN A SETTING.

Media is pinned now, beside Administration. it sat at the end of the scrolling list,
which is the bottom of the LIST and not the bottom of the panel. it belongs in the
foot for the same reason Administration does: both are about this machine, while
everything above them is about the site's pages.

and it was LOCKED BEHIND A CONNECTION IT NEVER USES. `ready` was one global flag —
"can anything be opened" — and Media is a folder on this disk that never speaks to
fofos. so sections now declare what they need:

  'connection'  the site's pages, which read fofos
  'root'        Media, which needs a folder and nothing else
  'nothing'     Administration, because locking the door you fix things through is
                locking somebody out entirely

`sectionOpen` is one function, used by the nav to decide whether to offer a link and
by the detail panel to decide what to render — so the two cannot disagree about a
section. that was already the rule with one flag; it matters more now that each
section asks a different question.

`/status` gained `rootSet`, a BOOLEAN, and stays not-owner-only. the nav needs the
answer for every account that can see the section; the path itself stays behind
`/_config`, which is owner-only. whether a root has been chosen reveals nothing
about the machine — where it is would.

the empty-state line follows too: "set a root location below to begin" before there
is one, "connect fofos to open the rest" after — rather than one sentence naming a
step that is not the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:44:11 +01:00
pastilhasandClaude Opus 5 944d3d9c5b a Media section, browsing the site's originals in place
a new section above Administration. it embeds THE PLATFORM'S OWN file browser rather
than drawing a file list — upload, delete, rename, move, thumbnails, the context
menu and every task hanging off it already exist and are maintained in one place. a
plugin's own would be a second to keep in step, arriving without any of that.

`basePath` IS A JAIL, not a starting folder: `useFileBrowserApp` resets currentPath
whenever it leaves, so pointing it at `<root>/media/originals` means the breadcrumb
stops there and `derived/` is not reachable by navigating. that is what was asked
for — people work in originals, derived is made for them.

IT IS A GUARDRAIL AND NOT A PERMISSION BOUNDARY, and the panel says so in its own
docstring because the difference will matter to whoever reads this next. the
file-browser API is unchanged and still addresses the whole home: anyone with file
access reaches derived/ through the Files screen. this prevents an accident, not a
determined person. confining it properly means scoping that API per user, which is a
platform change and a much larger one.

the panel also says, once, that derived/ exists and is made from these — otherwise
somebody who knows about it would think it had been lost.

`rootRelative` is new on the settings API because only the SERVER knows what home
is. the file browser addresses everything relative to a root, and a browser deriving
that prefix from the absolute path would be guessing. null when the root sits
outside home — a legitimate setting the browser cannot address, which the panel
explains rather than rendering an empty folder.

NOTHING HERE IS FOFOS-SPECIFIC, which matters now that this plugin is the template
for other site admins: the root is a setting, media/originals is the layout this
plugin creates, and both are read from the API. another site's plugin changes the
label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:39:53 +01:00
pastilhasandClaude Opus 5 1b7ffe3fcd the directory picker moves to the platform
kanban wanted the same one and a plugin cannot import from a sibling, so it now
lives in the officerdev barrel and this imports it from there. nothing in it was
fofos-specific — it browses the owner's home over the platform's file API.

deleted here rather than kept as a second copy. the copy that drifts is always the
one somebody is looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:09:50 +01:00
pastilhasandClaude Opus 5 b9f8ce4ee4 originals/ and derived/, because the names are the rule
media/
    originals/   nothing regenerates these
    derived/     rebuildable from originals/, safe to wipe

a pure rename: nothing had been created yet — checked the real root on fofos, only
`website/` was there — so there is no directory to move and no file to lose. that
is the last moment this was free, which is why it happened now.

`user/` and `treated/` both named the wrong thing. `user` said who put the file
there, which stops distinguishing anything the moment a job writes one, and reads
like it should hold a folder per person. `treated` said what had happened to it,
which stops being informative as soon as there is more than one kind of treatment.

these two say which one you can afford to lose. that is the only property that
matters about the pair, and it is the one that decides every rule anybody writes
about them later — what a cleanup job may delete, what a backup must never skip,
what a conversion may overwrite without asking. a name that carries it is a rule
that cannot be forgotten, which is more than the comment above it can promise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:14:47 +01:00
pastilhasandClaude Opus 5 86e18fc7bf media/ gets its shape: user/ and treated/
<root>/
    website/          the checkout
    media/
      user/           what people upload, untouched
      treated/        what comes back out of conversion

ONE list drives both provisioning and reporting. two lists is how a status comes to
say "ready" about a structure missing a folder the creator quietly stopped making.

THE BUTTON REPAIRS AS READILY AS IT CREATES. `mkdir -p` on every path, every time,
so the same press fixes a `media/` somebody made by hand with nothing in it and a
structure that lost a folder — and pressing it on a complete one changes nothing
and fails at nothing. that is what makes it safe to offer beside a status a few
seconds stale; the version that refuses when the parent exists is the version
somebody has to finish in a terminal.

three states, not two. `media/` with one child missing looked identical to a
finished one, and that is the state most worth seeing — so the row says complete,
partly there, or absent, and lists both children whether or not they exist. the
missing ones are the reason to show the list at all.

RECORDED BEFORE ANY RULE DEPENDS ON IT: the two halves are not symmetric. `user/`
is original — nothing regenerates it, and losing it means asking somebody for a
file they may no longer have. `treated/` is derived — every file in it can be made
again from the one it came from, so it is the half that is safe to delete, safe to
rebuild, and safe for a job to overwrite without asking. nothing enforces that yet;
it is the fact that should decide every rule written about them later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:11:16 +01:00
pastilhasandClaude Opus 5 003613872d provision <root>/media, beside the checkout
where the images and the files will live. same row shape as the checkout above it,
because it is the same kind of fact: a thing that either is or is not at a known
path under the root, with one button that puts it there.

BESIDE `website`, never inside it, and that is the point of it being here rather
than a folder in the repository:

  - it must SURVIVE the checkout. deleting `website` and cloning it again is a
    normal thing to do when something is wrong with it, and it must never be the
    thing that destroys the client's photographs.
  - it must never be TRACKED. media in git is a repository nobody can clone a year
    from now, and a .gitignore is a promise somebody eventually breaks with
    `git add -f`.

both under one root, so a backup of the root is the whole site — the code and the
things it serves. that is the property that makes the root worth being a setting.

NOTHING ABOUT ITS INSIDES IS DECIDED. this creates the directory and reports
whether it is there; the layout, who writes to it and what goes where are still
being described. inventing a folder tree now would settle that in the file least
able to change its mind, because directories on disk are not a migration you can
run.

`mkdir -p`, so it also creates a root that is still only a default — saving a root
creates nothing, and provisioning into a path that does not exist is the ordinary
first run. idempotent by the same flag, which is what makes the button safe to
offer next to a status that might be stale.

no console for this one: making a directory either works or gives one line about
why not, and a black transcript box for a single sentence makes a small thing look
like a large one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:03:28 +01:00
pastilhasandClaude Opus 5 c268e8438a a copy button on the console, and the failure case spelled out
a failure is something somebody PASTES — into a search, into a message, back to
whoever wrote the thing that broke. selecting text inside a scrolled box, on a
phone, is the point at which people screenshot an error instead, and a screenshot
of a stack trace helps nobody.

the button is pinned to the corner of the BOX and not scrolled with the text: one
that leaves the viewport when the transcript is long is missing exactly when the
transcript is worth copying. `pr-10` keeps the output from running underneath it,
and it carries its own dark background because it sits over the console rather
than beside it.

`copyToClipboard` from helpers, the same one CopyField uses, rather than reaching
for navigator.clipboard here.

the no-timer-on-failure rule was already the behaviour; the comment now says the
part that was only implied. the half-done case is the one that matters and it is
named: a clone that worked followed by an install that did not is a failure, `ok`
is false, and the transcript of BOTH stays. the only thing that clears it is
starting the next attempt — the one moment it stops being true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:00:13 +01:00
pastilhasandClaude Opus 5 91aae3e981 the output is a console, and it clears itself when it worked
pre/code on black. it is quoting a terminal, so it should look like one — as a
tinted paragraph it read as part of the form, which is exactly what it is not.

IT CLOSES ITSELF ON SUCCESS, after five seconds, and NEVER on failure.

a successful clone or update is worth watching and not worth keeping. "74 packages
installed" has been read by the time it finishes, and left on screen it becomes
furniture that outlives its own truth — still sitting there after the root has been
changed underneath it.

a failure is the opposite, and gets no timer at all rather than a long one. it is
the only copy of why, and a transcript that scrolled past is one somebody has to
reproduce in order to read. nothing takes it off the screen except the next attempt.

same `useFlash` as the Saved indicator, so there is one timer implementation and one
number, and the clear-the-previous-timeout property comes along with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:57:36 +01:00
pastilhasandClaude Opus 5 df394e29a4 the website checkout under the root: clone it, update it, say which
`<root>/website` is now checked, and the block says which of three things it is:
not there, there but not a checkout, or a checkout with its branch and its head
commit. the middle one is the state worth being loud about — cloning will refuse,
and the reason is something already sitting in the way.

SSH, hardcoded. the repo is private, so https asks for a username with nobody at a
terminal to answer, and the SSH remote is already authenticated by the owner's key.
hardcoded because this plugin administers exactly one website: a field for it would
be a setting nobody changes and one more thing that can point somewhere wrong. when
the checkout's origin is NOT that URL, the block says so — shown always it is noise,
shown on a mismatch it answers why an update pulled something unexpected.

NOTHING HERE MAY HANG. these run inside an HTTP request, so git gets
GIT_TERMINAL_PROMPT=0 and ssh gets BatchMode=yes. without them a key that lost
access does not fail, it BLOCKS forever waiting for a password at a terminal that
does not exist, and the panel spins until somebody restarts the server. there is a
five-minute kill on top for everything else that can wedge.

update is `--ff-only`, never a merge. a merge commit made by a button in a checkout
nobody is watching is a thing somebody finds days later; refusing is recoverable
and legible. it also refuses on a dirty tree rather than failing halfway — and
dirty is `--untracked-files=no`, because a build artefact or an uncommitted .env is
not a reason to make Update permanently unavailable.

clone takes FULL history, not --depth 1. this is a working checkout somebody will
pull into and read the log of, and shallow turns both into a support question.

it sits inside Root location rather than in a block of its own: the path and the
thing at the path are one question, and split apart somebody could change the root
in one place and read a status about the old one in the other, both looking equally
current. hidden while editing, because it describes what is SAVED.

git's own output is shown verbatim on failure. "Permission denied (publickey)" says
exactly what is wrong; anything written in its place would be a guess at which of a
dozen causes it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:51:44 +01:00
pastilhasandClaude Opus 5 9cbbc28c82 "Saved" is a moment, top-right, and gone in five seconds
it was a permanent line under the controls saying "Saved." — which said nothing,
because everything on this screen has been saved, all the time. it looked exactly
like feedback, so the one moment it actually meant something was indistinguishable
from every moment it did not.

now it appears when a save LANDS and clears itself. top-right of the block, beside
the title rather than in the flow: it is a remark about the block, not part of what
you are doing, and inline it pushes everything below down a line and then pulls it
back up — a layout that jumps every time somebody saves.

"Not set yet" stays. that IS a state, and it is the one worth knowing; it goes when
it stops being true rather than being replaced by a line that never changes.

the timer is a ref and is cleared before each new one. without that, saving twice
in quick succession leaves the first timeout running and it fires against the
second save — the confirmation vanishing a moment after appearing, which reads as
the second save failing.

Block moved to its own file and each setting now renders its own. the status belongs
to the setting that produced it, and lifting one block's timer into the panel would
put it in a component with nothing to do with it. that is also what made the
top-right slot possible without absolutely positioning anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:44:46 +01:00
pastilhasandClaude Opus 5 df52045eaa the path gets the row, the actions get their own
Root location is two states now. reading, it is a path and one Edit. editing, it
is a field and the things you can do to it.

the actions moved to the block's BOTTOM row, off the value's line. an absolute
path is long, and it was being truncated to make room for buttons that are not
needed most of the time — the thing somebody opened this to read was the thing
squeezed. shown, it wraps with `break-all` rather than truncating, because a path
is read from both ends: the machine at the front, the folder at the back, and
hiding either is worse than two lines.

Edit is a mode and not an always-live field. this is looked at far more often than
it is changed, and a live input invites a stray keystroke into a path everything
else will depend on. Cancel restores what is stored, so leaving the mode cannot
leave half a path on screen looking like the setting.

"back to the default" is gone, as asked — and `clearRootPath` and its DELETE route
with it, since that button was the only caller. an unreferenced endpoint is the
same rot as an unreferenced component, and this one was a second, quieter Save
that wrote a value nobody chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:41:50 +01:00
pastilhasandClaude Opus 5 d5f2d8f370 Root location: an absolute path, with a picker that can make folders
defaults to `<owner home>/fofos`, and stores the FULL path — never the tilde. a
tilde means "whoever is asking", and the people asking will not all be the same
person: this plugin is grantable, and the site is in one place regardless of who
is looking at it. stored as `~/fofos` it would give every user a different answer
to a question that has one.

THE DEFAULT IS COMPUTED, NOT WRITTEN. no row means the default is worked out on
every read, so it follows the machine. writing it in at install freezes whichever
home existed on install day, and a restore onto a different machine then points at
a directory that is not there while looking deliberately chosen. a saved path is a
decision and is stored exactly as given; only its absence is derived. the form
says which of the two it is showing, because "not set" reading identically to "set
to the default" is how somebody comes to believe they configured something they
did not.

THE PICKER USES THE PLATFORM'S FILE API — /file-browser/ls and /mkdir — not routes
of this plugin's own. they already refuse to leave home, and they already tell a
missing directory from a forbidden one, which is a distinction that cost somebody
an afternoon and would have been re-guessed here. shipping a second listing and
mkdir would be a second filesystem surface to audit for the sake of one form.

that API is an execution capability and never grantable, so for anybody but the
owner it 403s — said in a sentence rather than shown as an empty tree, because an
empty listing is data and must never be how a refusal looks. it also browses the
owner's home and nothing above it; a path outside home is still reachable by
typing, since the picker fills the field and the FIELD is what saves. one value on
screen, one Save, and picking a folder commits nothing by itself.

"Use this folder" takes the one that is OPEN rather than one highlighted in the
list. that makes looking inside and choosing one gesture, and it is the only way
to choose a folder with nothing in it — which a freshly made one always is.

a SECOND table, not a column on fofos_admin_connection, whose url and token are
both notNull: a root path could not be saved there without also having an HTTP
connection, and that connection is being replaced rather than extended. when it
goes, its table is a clean drop instead of surgery on columns that outlived it.
the rule against tables in this plugin is unbent — what it forbids is the CLIENT'S
work, and this row holds a path somebody can retype.

needs `bun db:push`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:35:16 +01:00
pastilhasandClaude Opus 5 ae9c30e53d Root location, above Database
empty like the one below it, and for the same reason: the block settles the shape
and says what is coming, while what goes in it is not decided.

the order is the order things are needed in — where the site is on this machine,
then how to read its data.

this is the block list paying for itself. adding the second setting was one entry
and moved nothing, which is what the previous commit chose it for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:29:49 +01:00
pastilhasandClaude Opus 5 fcc7436965 Administration is emptied, and starts again with a Database block
the address-and-token form is gone from the screen and ConnectionPanel.tsx with
it. leaving an unreferenced component behind would have been worse than deleting
it: it is in the history, and a file nothing renders rots without anyone noticing.

in its place, Administration is a LIST OF BLOCKS rather than one form. it will
hold several unrelated settings, and giving each a titled block now means the
second one moves nothing — a single form would have to be broken up the first time
anything joined it.

the first block is Database, and it is empty on purpose. it says "nothing to set
yet" rather than rendering nothing, because a section that draws blank reads as
one somebody abandoned. what goes in it is not decided, and inventing fields to
fill the space would be deciding it in the wrong place.

WHAT THIS COSTS, plainly: there is now no way to configure this plugin from the
screen at all. the server half is untouched — `/_config` still stores a URL and a
token and the proxy in api/router.ts still reads them — but nothing can set them.
that is consistent with the gate being hardcoded false rather than a second
breakage: both are the same unfinished change, and neither is worth half-fixing
while the shape of the connection is being replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:28:24 +01:00
pastilhasandClaude Opus 5 10b7bc34f7 nothing but Administration opens until the plugin is ready
every section was clickable and every one of them showed the same connection form,
because the panel forced it and the nav did not know. nine doors onto one message
taught nobody anything and made a setup step look like nine faults.

so there is now a gate, `useReady`, and both panels read it. that is the point of
putting it in a hook rather than in either panel: a nav that offers a row the
detail panel then refuses is worse than one that does not offer it.

the nav DECLINES TO OFFER — a locked section is a <div>, not a <Link>, so there is
no href to hover, no cmd-click into a new tab and nothing to focus. rendering a
link and swallowing the click leaves a row that looks navigable and behaves like
one everywhere except the click.

the detail panel HOLDS THE LOCK, and it has to, because `?section=gallery` typed
into the address bar or arriving from an old bookmark never passes through the
nav.

one line of copy in the foot rather than a badge on nine dimmed rows. without it a
locked nav reads as broken instead of as one step not done: the rows go grey and
nothing says why or what to do about it.

THE CONDITION IS HARDCODED FALSE, as asked. the plumbing around it is real — it
asks /status, re-asks when the connection changes, and reports while it is still
asking — so switching the gate on is one line. it returns a single boolean and not
its parts deliberately: "all the info needed in Administration" will grow past
`configured && lastProbeOk`, and callers must not start assembling their own
version of it out of the pieces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:25:57 +01:00
pastilhasandClaude Opus 5 5e96274ebd Administration sits at the foot of the panel, not the end of the list
it was last in SECTIONS, so it rendered as the tenth row and then stopped —
directly under Messages, with empty panel beneath it. that is the bottom of the
LIST. what it wanted is the bottom of the PANEL, the way Update-all sits in the
plugins nav.

`mt-auto` on a second group, divided by the same border the rows already use. it
holds whether there are ten sections or two, and it does not need a spacer element
to push it down.

the split is a `pinned` flag on the section rather than the nav testing for the
key. which sections are the client's site and which are this plugin's own
machinery is a fact about the section, and the nav should be reading it, not
knowing it. one array still — `sectionFor` and `isSectionKey` have to resolve
every key, and a second exported list is a second place to forget one.

the row body came out into `renderRow` so both groups draw the same row. no
change to what a row looks like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:20:39 +01:00
pastilhasandClaude Opus 5 7eec2ec51d Administration is a section, not an overlay
the connection form — where fofos is, and the token — was reached through
`?config=1` and a link in the detail header, because it was not one of the nine
sections. it is now the tenth, called Administration, last in the nav.

that removes the overlay and the second route to it. the two could disagree: the
form covered whatever section was selected underneath, so the nav highlighted one
thing while the panel showed another, and an unconfigured plugin rendered the form
with no header at all — which reads as the selected section being broken rather
than as one setup step not done yet. unconfigured now goes to Administration and
the header says so.

`site` and `administration` are deliberately separate and the file says why. Site
settings is the CLIENT's — partners, social links, contact details, all of it
public copy. Administration is Officer's end: how this plugin reaches fofos, and
whatever else it will need to know. nothing in it ever appears on the site, and
the people who touch the two are not the same people.

`path: null`, alone among the sections: it reads nothing from fofos, which is why
it keeps working when fofos is unreachable and the others do not.

the header's settings link is replaced by the section's own hint, so the row still
says what it is once you are in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:18:13 +01:00
PastilhasandClaude Opus 5 6b87956e44 Everything needed to be a plugin
manifest, one table, a router and two panels. Discovery is by convention, so
this is the point at which the directory stops being a directory and becomes
something Officer can install.

The manifest holds only what a listing cannot say. One permission, grantable,
with ownerOnly on /_config — reading and editing the site is exactly what a
member might be brought in for, but the token is unrestricted write authority
over everything the public sees, so handing it over is the owner's.

db/schema.ts is ONE table and should stay that way. fofos owns its own Postgres
and its own media store; this holds a URL and a token. That is what makes the
strongest thing about this plugin true: no operation on it can destroy the
client's work, checkbox ticked or not, because none of it was ever here. A cache
would be the thin end of that.

The router mostly forwards. fofos's /api/admin already answers in the shape a
panel wants, so re-wrapping it would be a second place for the contract to drift.
Two things in it are load-bearing rather than style:

  registerProxiedPrefix('/api/fofos-admin/media'). bodyParser calls parseBody on
  multipart across the whole protected router, and Hono then caches the parsed
  FormData — so asking for the bytes afterwards re-serialises them under a NEW
  boundary while the ORIGINAL content-type header is what gets forwarded. Header
  and body disagree and every upload fails with "Unexpected end of form".
  Narrow to the one route: everything else is JSON that is fine to parse.

  The catch-all is LAST in the file. Hono runs matching handlers in registration
  order, so /* above /_config would swallow it and the settings form would
  forward itself to fofos, which has no such endpoint — a 404 from a plugin that
  is working perfectly.

Panels are a nav and a detail pane that never speak to each other: the nav writes
?section=, the detail pane reads it. Real <Link>s rather than the buttons
proxy-manager uses — AGENTS.md says nav items are links, and the file documenting
that pattern says not to copy the older one. <Link> and not <NavLink> because
active state here is a query parameter and NavLink compares paths, which would
light every row at once.

Which component renders which section is a MAP, not a conditional on `built`.
The first version read `built ? <MessagesPanel/> : <Planned/>`, which is correct
only while exactly one section exists — the second one finished would have
rendered Messages under its own name.

Messages is the only section built, chosen because it is one read-only route: it
proves the path from an Officer panel through this proxy to fofos and back with
nothing else able to be wrong at the same time. The other eight are in the nav,
dimmed and labelled, saying what they will do and that the old admin still edits
them. A nav that hides what is unfinished gives no sense of what the thing is for.

Typechecked against the platform — and the typechecker was verified to actually
cover plugins/ by planting an error and watching it fail, because a clean run
that does not include your files is worse than no run.

NOT yet: installed, or run against a live fofos. Nothing here has been through
the permission gate or a real request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:05:34 +01:00