"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>
`web-1080` was asked for whenever the source was at least 720 tall, so a 720p
video was upscaled to 1080p — the exact thing the image ladder is careful never
to do. The output was a 14.6 MB file that looks worse than the 7.8 MB one beside
it, and it cost 84 seconds of a single core on an idle box. Under a running sync
that overran av-club's 255 s idle timeout and homepage_video.mp4 failed
outright, which is the only reason the waste was noticed.
A rung is now made only when the source is at least that tall. `web-480` becomes
a floor rather than a rung, so a source smaller than 720 still gets something
instead of nothing or an enlargement. An unreadable height makes everything: a
probe that told us nothing is not evidence the video is small.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A HEIC was being decoded once per rung. av-club is stateless per request, so
seven rungs asked for from the original are seven ImageMagick conversions of it
— measured on fofos at 8.5 s each, against 1.1 s for the resize and encode that
follow. Six of the seven produced a pixel-identical intermediate and threw it
away: about 67 seconds per photograph where 16 would do, paid again on every
future upload.
So when av-club says a format needs converting, convert it ONCE into the staging
directory and cut every rung from that. `needsConversion` is asked rather than
guessed, because which formats are expensive is av-club's fact and a list of
them kept here would go on paying after its build stopped needing it. JPEG and
PNG are untouched — writing a full-resolution intermediate to save six cheap
reads would cost more than it saved.
Concurrency drops to one. Two was chosen to match av-club and is the right
number on a machine with room; fofos has 1.87 GiB of RAM, and two concurrent
12-megapixel decodes left 520 MiB free with 648 MB already paged out. The work
is CPU-bound on a box already at 99%, so the second worker was not buying
throughput — it was buying a second resident bitmap.
And the sharp calls now name their output format properly. `output` takes
`{ format, options }`; passing `{ webp: { … } }` was silently ignored, so
everything built before this encoded at sharp's default quality rather than the
82 asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
`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>
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>
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>
dropped the line explaining that derived/ is made from these. the people using this
upload photographs — the two-folder split is the machine's business, not theirs.
a sentence at the top of every visit is a permanent tax paid by everyone to answer a
question almost nobody asks. it is written where it belongs: in the affordance every
agent reads, and in the plugin's own docs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
there were two markdown files sitting in media/originals/ — CLAUDE.md and
MEDIA_PROCESSING.md — carried in when that tree was copied. both are gone from
there now, and the originals in Projects/fofos are untouched and byte-identical, so
nothing was lost.
MEDIA_PROCESSING.md was not merely redundant, it was WRONG in three ways and each
would have failed on first use: it gave av-club's address as localhost:3020 (the
port is ephemeral now and it is reached at /api/av-club), it required a bearer token
(the platform's own gate handles that), and it described an upload-only model
(there is a path mode, so nothing needs uploading). it also pointed at
~/Projects/_avi_club_docs/, which does not exist on that machine.
documentation that lives beside the data goes stale invisibly — nobody re-reads a
file in a media folder when the service it describes changes. an affordance does not
have that failure mode: it ships with the plugin, updates with it, and disappears
when the plugin is removed.
KEPT SHORT DELIBERATELY. an affordance is compiled into EVERY conversation, unlike a
skill which is opened on demand — so this is the facts and a pointer, and the
reasoning stays in the media-conversion skill where it is read once by somebody who
needs it. the first draft was 206 words against gitea's 86; this one is under 120.
what it carries is the part an agent must not get wrong: originals are irreplaceable
and derived is rebuildable, and processing goes through av-club rather than a
hand-written ffmpeg line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
<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>
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>
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>
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>
a checkout with no node_modules does not run, so cloning without installing left
something that looked finished and was not.
run after an update too, and UNCONDITIONALLY — including when the pull changed
nothing. skipping it on "already up to date" is the shortcut that is right until
the one time it is not, and an install on an unchanged tree is close to free.
`process.execPath`, NEVER the string "bun". verified on fofos today: PM2 started
officer with PATH=/usr/local/sbin:…:/snap/bin and bun lives in ~/.bun/bin, so
looking it up by name fails inside the process and works perfectly when you run the
same command by hand — the worst version of this bug, because it cannot be
reproduced the obvious way. execPath is the bun already running us.
bun's directory is also prepended to PATH for the child, which execPath alone does
not cover: a postinstall script calling `bun` would not find it, and that reads as
a broken package rather than a missing PATH entry.
the two commands report as ONE, each prefixed with the line that produced it, so
the panel reads like the terminal somebody would otherwise have opened. it stops at
the first failure — installing into a clone that did not happen produces a second,
more confusing error about a directory that is not there — and a clone that worked
followed by an install that did not is legible as exactly that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`<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>
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>
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>
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>
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>
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>
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>
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>
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>
the previous icon came from the bigcartel storefront. this one is the mark the
site itself serves — fofos-logo-black.webp — which is the one that will stay
current when the branding moves.
it is TRANSPARENT, black on nothing, so committing it unchanged would have put a
black mark on a dark dock and shown nothing at all. flattened onto opaque white
instead, and the white travels with the file rather than being something the
theme is trusted to supply: the icon is drawn over a dark tile, a light
marketplace card and the plugin's own header, and only one of those would have
saved it.
trimmed before padding, because the source has the artwork inset in a square and
centring the file centres the wrong thing. 86% inset — the lower line is fine
strokes and stops being readable at dock size with any more air than that.
assets/README.md records where it came from and how to redo it. a binary is the
one thing a diff cannot show, and the last icon arrived with no note about its
source, which is why this one needed replacing rather than updating.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`assets/icon.png` — presence is the declaration, so this takes over the dock tile
from the lucide `PenLine` name in the manifest. no manifest change: a file that
is there is the whole mechanism, and there is no field to keep in step with it.
256x256, matching every other plugin's icon rather than the source's 300.
TRANSPARENCY SURVIVED THE RESIZE, which is the part that needed care: the source
is half fully-transparent pixels, and a resize without `-background none`
composites them onto white — a black logo on a white square, on a dark dock.
checked after: alpha min 0, top-left srgba(0,0,0,0).
encoding chosen by measuring rather than by habit. straight PNG32 came out at
44 KB — LARGER than the 300px source — because a forced 32-bit RGBA gets no
palette. PNG8 is 7 KB and bands the anti-aliased edges (RMSE 404). pngquant at
quality 90-100 is 15.5 KB with RMSE 68, six times more faithful than PNG8 and a
third the size of full colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
An Officer plugin that puts the fofos admin inside Officer, as panels — so
editing the site uses Officer's accounts and permissions rather than a second
password on a second domain.
Nothing is built. The README says so, and says what the shape will be: nav plus
detail after plugins/proxy-manager, nine sections, messages first because it is
one read-only route and proves the whole path before anything harder.
The API it consumes already exists, at /api/admin in fofos/platform. It is there
rather than here because the query layer is the only thing the site and the admin
share, and a plugin carrying its own copy of that schema would be a second
definition of the same tables in another repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>