Commit Graph
387 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 16fa869260 the dock rounds its tiles; now it clips them too
reported as a plugin whose dock tile had square corners while every other one was
rounded, and the expectation was right: the rounding IS dock infrastructure and
has been there all along. it was simply never reaching the artwork.

the box is `rounded-xl` and the image inside it is not clipped. a `badge` tile
insets its glyph to h-7, so the corners are empty and nothing looked wrong for as
long as every plugin used one. `bare` fills the box edge to edge — so the first
plugin to ship a logo on an OPAQUE background drew its own square corners over a
rounded box, and became the one tile in the row that had not been told the shape.

`overflow-hidden`, in the dock and in spotlight, which had the identical bug a few
pixels away. it fixes every bare plugin rather than this one, and it is a no-op for
badge tiles, which is why it belongs on the box and not on the image.

the plugins list drew the same artwork with no rounding at all while the grid
beside it used `rounded-xl`. rounded to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 00:12:31 +01:00
pastilhasandClaude Opus 5 fb0cc880ac sign in as a user, from user management
the support case this exists for is "it does not work for them and it does for
me". reproducing that with a password reset means locking somebody out of their
own account in order to look at it, which is worse than the bug.

THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and
reaches every handler, because user-middleware puts the whole payload on the
context. without it an impersonated action is indistinguishable from that
person's own — in a log, in a transcript, in anything keyed on a user id — and a
support tool that can forge activity as somebody else is a different and much
worse thing than a support tool.

ONE HOUR, not thirty days. a session lasts a month because a person uses their
own account daily; impersonation is a task you finish. an hour is long enough to
reproduce a problem and short enough that a forgotten tab stops being a live
credential for somebody else's account by lunchtime.

IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any
token carrying `impersonatedBy`, so impersonating another Super Admin does NOT
hand user management back — which would have made this a way to launder owner
actions through somebody else's id. stated twice, in the gate and in the handler,
because one file away is too far for this one.

THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an
owner session — a far more dangerous thing to own than this whole feature. the
cost is that clearing browser storage strands you as the other person until the
hour expires, and signing in again fixes that.

the banner is loud and sits ABOVE the header, outside everything a fullscreen
panel can hide. the failure it prevents is not a hole, it is forgetting: reading
a mailbox or deleting something while believing it is yours. it asks the server
who it is rather than decoding the token, so it cannot keep showing a name after
the hour has run out.

verified end to end against a throwaway account, since this host has one user:
impersonate 200 with the target's id and impersonatedBy naming the owner, 60
minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining
403, user management while impersonating 403; one [SECURITY] line per start. the
probe account was deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 21:51:20 +01:00
pastilhasandClaude Opus 5 421e742590 a dock tile for /bots, which had a screen and no way in
the route, the screen and the list have existed since bots did — `/bots` and
`/bots/:sessionId` are both in App.tsx. what was missing was any way to GET
there: no entry in CORE_DOCK_ITEMS, so it could not even be added to a dock, and
no `nav.bots` label in any of the six locales.

placed beside Command HQ, which is the neighbouring idea: a commander runs a job,
a bot watches one thing. left as "Bots" in all six locales — a borrowed technical
noun, and inventing translations for it would be worse than not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 17:05:50 +01:00
pastilhasandClaude Opus 5 d58f58813c the marketplace is browsed by tag, not scrolled
/marketplace was a list of every plugin on the left and a preview on the right.
that is a fine control panel and a poor shop: twenty-one rows sorted by nothing
anybody cares about, where "something for my calendar" means reading all of them.

now: tags down the left with counts, cards in a grid on the right, and clicking
a card takes over the panel with a back link. three things about the shape.

BOTH the filter and the selection are in the URL — ?tag= and ?selected=. a
filter in component state is lost on Back, so filtering, opening a plugin and
pressing Back would drop you in an unfiltered grid having lost your place.
browser Back works for free for the same reason. selecting a tag also CLEARS the
selection, because a detail for a plugin outside the new filter is a page the
grid you are now looking at cannot return to.

the detail REPLACES the grid rather than sitting beside it. a grid needs the
width and a detail beside one gets half of it; neither reads well. the back link
drops ?selected= and keeps ?tag=, which is the entire point of putting the
filter there.

the panel KEYS are unchanged though both components were replaced. a stored
layout names them, and renaming would drop everyone back to the default
arrangement for nothing — the key identifies a slot, not an implementation.

two things found on the way. PluginItem and AvailablePlugin had no tags, so the
api now carries them on both branches — from the manifest for a plugin that is
here, from the catalogue for one that is not, same field either way. and the
screen used useDashboardState, which persists the whole tree: the panels this
ships would never have reached anyone who had opened it before. useAuthoredLayout
takes the structure from source and persists only sizes, which is what an
authored screen wants.

21/22 -> 22/78 as well: the left panel holds seven tags now, not twenty-one
rows, and the width is worth more to the grid.

verified: bundle contains the new panels, api returns tags for all 21 with none
missing the field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 07:32:05 +00:00
pastilhasandClaude Opus 5 52f209c69f caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like
every other one. a fresh machine runs five processes instead of seven and gets a
calendar by installing one plugin, which is the whole point: the platform stops
carrying a python calendar server for people who may not want one.

what left: the radicale sidecar, four routers, the panel apps, the
dav_app_passwords table, the two screens, the two dock tiles, the permission
registry entry, the settings section, and radicale from every package list.

three platform things had to change first, each committed on its own:
6b5c87da a plugin can be more than one screen; e0df28ff a first-party plugin can
claim a reserved root path. this commit adds the third, `personal` on a plugin
permission — a member granted READ must still mint and revoke their own app
password, because that password is how their own phone reaches their own
calendar. those rows are theirs and nobody else's.

two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so
a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin
serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks
the claim registry now, so it follows whoever owns the route.

/calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh
dock that silently dropped it would read as a downgrade.

docs:check earned its keep twice here: it caught spotlight still offering
/settings/integrations/dav-app-passwords after the section was deleted, and the
permission count going 22 -> 21. neither is greppable and both were already wrong
when it ran.

verified against alpha, which is still on the pre-extraction code: /.well-known/
caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for
byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than
being answered as a CORS preflight. installed here end to end — schema applied,
sidecar started, two tiles with their own artwork behind one permission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 04:54:42 +00:00
pastilhas 6b5c87da10 a plugin can be more than one screen
a plugin was one screen because the two things it declares — a mount prefix and
a frontend — happened to be the same string. caldav is where that stops holding:
one sidecar, one permission, one install, and two screens that have nothing to
do with each other on screen. calendar and contacts are not sections of one
page, and forcing them into one would be a worse product than the core screens
they replace.

nothing else moved. all eighteen plugins with a frontend use flat
web/panels.ts, which is now "one screen, at the mount prefix, labelled by the
manifest" — the same file, the same route, the same persisted layout key. the
nested form is opt-in and caldav is its only user.

the split of who declares what follows the rule this file already had. the
DIRECTORY declares that a screen exists (web/<segment>/panels.ts); the MANIFEST
says what to call it and which glyph to draw, because a directory listing cannot
say either and those are a human's choice. the two are cross-checked and a
mismatch is refused rather than reconciled: a segment with no directory renders
nothing, a directory with no entry has no tile and no label, and both are the
quiet kind of broken.

the part that needed the most care is that a segment is a URL the plugin CHOSE
— /calendar, not /caldav/calendar. mountPrefix is what makes it impossible for a
plugin to shadow a core route, and a chosen segment steps straight out of it. so
this is first-party only, refused at discovery, and tested: without that check
this field is a way for any installed plugin to take over /files.

screenId, not appName, is now the layout key and the panel allow-list's owner.
two screens sharing one would persist over each other's sizes and each would
accept the other's panels. existing plugins get screenId === appName, so no
stored layout is orphaned — verified by regenerating.
2026-08-27 04:15:51 +00:00
pastilhasandClaude Opus 5 513a50f2e8 a bot is a card, not a conversation in a list
it looked like the generic chat panel because it WAS one, and that leaks the
implementation at exactly the wrong moment: a bot is a chat underneath, and
nobody using this should have to hold that. the session list says
"conversation, last touched" because that is what a conversation is.

so it gets Command HQ's object instead — a card with a name, an id pill, rename
and delete, selected ring, controls on hover. same arrangement: the whole card
is the link with the buttons layered over it, so a click anywhere opens it and
the controls still work.

the pill is the first eight characters of the session, which is also what an
@mention carries. that is the point rather than a coincidence — the thing shown
on the card is the thing typed to reference it.

the panel's own header picker is gone. the cards are the list, and a second
picker in the panel bar is the same choice offered twice. command hq does not
have one either.

deleting the selected bot navigates back to /bots, or the route points at
something that no longer exists and renders an empty chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 07:50:55 +00:00
pastilhasandClaude Opus 5 20d60cd3be /bots looks like Command HQ, and stops opening the wrong chat
TWO BUGS AND A LAYOUT.

the first outing of /bots opened a months-old conversation about deleting proxy
hosts. resume-newest called useClaudeSessions(absoluteCwd) with no context, and
a screen that owns its own directory has cwd '~', so that join yields null and
the hook listed the caller's HOME. the panel then resumed the newest thing
there. same bug the header had, one function down; the declared context wins now.

second: ChatPanelWrapper takes an optional sessionId, because a screen whose
selection lives in the URL cannot also let usePanelSession decide — two sources
would disagree and the URL would lose. null means "the screen chose a new chat",
undefined means "no opinion", and collapsing them would make New snap back to
the newest.

the layout is Command HQ's: a narrow list left, the conversation right, and a
route pair — /bots is the new-bot state, /bots/:sessionId is one of them. rows
are real links, so cmd-click works and a reload keeps its place.

no categories pane. a commander is a job somebody files and a filter earns its
place; a bot is a sentence, and a filter over a handful of one-line watches
would be furniture.

no table either: every bot is a chat in bots/, so the list IS the session list
for that directory. nothing to keep in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 07:21:28 +00:00
pastilhasandClaude Opus 5 de538cfc1b /bots — one sentence arms a watch
a bot is a chat whose working directory is bots/, armed through the background
job any chat can already arm. no scheduler was written and none was needed:
handoff already means "poll cheaply, wake the agent only when it fires", which
is the expensive half of a commander without any of its ceremony.

ONE directory, not one per bot. a commander earns its own because it is a job
with procedures and a ledger; a bot is a sentence and a watch, and a directory
each would mean a creation step before anything can be said. sharing it makes
the list free — every bot is a chat in that cwd, so the header's picker is the
list of bots.

it earns NO permission of its own. /bots rides `chat`, because a bot adds no
surface: same routes, same socket, same confinement. the registry says so
rather than leaving the next reader to wonder.

the brief is the feature. it tells the agent to arm on the FIRST message and
not interview — pick a sensible interval, arm, say what you armed in one line,
and let the owner correct it, which is cheaper for them than three questions.
it says the owner will never say "notify" or "nudge" and must not have to. it
forbids the loop that wakes on a timer to look for itself, which is the
expensive mistake available here. and it draws the line: a bot watches and
tells, it does not fix — a request for work is a commander, say so and stop.

bots joins commanders and plugins in confineUserTree, third time: a member's
bot runs as the member and a 700 service-user directory kills the turn on its
own cwd.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 07:13:57 +00:00
pastilhasandClaude Opus 5 a7b61a07eb the structure is the author's, the sizes are the user's
a locked screen persisted its whole layout tree, and `locked` means the user
cannot add, move or close a panel — so the only thing in that blob they could
actually change was how big things are. everything else was a copy of the
author's design sitting in the user's slot, where it could only go stale or get
damaged. it did both.

STALE: ship a new defaultLayout and nobody who had opened the screen ever saw
it. their copy won forever, with no way back a person could reach.

DAMAGED: WorkspaceView's appTypes guard replaces an unrecognised appType with
the fallback and writes the repair back — right for a user-authored dashboard,
catastrophic here. today a plugin layout naming a panel that did not exist yet
was rewritten to two copies of its first panel and SAVED. it survived every
reload and every fix, and the row had to be deleted from postgres. twice,
because an open tab wrote its in-memory copy back over the first delete.

useAuthoredLayout takes the structure from source on every render and persists
only sizes. a stale structure cannot exist, a repair has nothing to write
itself into, and a layout change reaches everybody on the next load — no
version to bump, no migration, nothing for an author to remember. the
corruption stops being repaired and becomes unrepresentable.

a group whose child count no longer matches keeps the author's numbers: two
sizes cannot describe three panels, and applying them anyway would leave the
new one with the remainder. that guard is what makes shipping a layout change
safe rather than merely possible.

what is stored is source-with-your-sizes, a valid layout because the route and
a CHECK constraint both demand one — written and never read as structure. the
asymmetry is the design.

PluginScreen only, for now. every core screen is locked and has the same bug;
that is a mechanical sweep of one call each and wants its own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 17:47:18 +00:00
pastilhas d3deca0d08 theme overlay keeps its size across the favourites filter, f flips the filter 2026-08-25 17:10:47 +00:00
pastilhas d0f83b58c9 theme overlay is one plain rotation, favourites in place, tab toggles a favourites filter 2026-08-25 17:06:46 +00:00
pastilhas 5f803c3fb0 theme browse steps the displayed list, favourites block out of the rotation 2026-08-25 16:58:32 +00:00
pastilhas 4efca226f6 spotlight on ctrl+space, theme browser on ctrl+shift+up/down, no backdrop behind the theme list 2026-08-25 16:53:29 +00:00
pastilhasandClaude Fable 5 420cd07507 /todos: a kanban board over the todo/ tree, owner-only
The filesystem is the database: GET reads the tree through the same module the
index generator now imports, PATCH splices frontmatter (status, priority, due,
order) and never touches a body byte, and every write regenerates TODO.md.
Columns open/doing/blocked/done, native drag between them, selection and theme
filter in the URL. Registry entry 'todos' (kind admin, 22 entries, docs:check
green), isSuperAdmin double-gate on the router.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:18:14 +00:00
pastilhasandClaude Opus 5 00baa83a2c a commander's own files, beside its transcript
a commander writes into its own directory — procedures, reports, the loop
it armed — and there was no way to look at any of it. /files cannot reach
it and never could: the file browser serves two roots, `home` and
`user-data`, and a commander lives at $DATA_PATH/<email>/commanders/<dir>,
which is under the one /files does not use.

so the Plan button becomes a files button on a commander. instead, not
beside: plan mode is research-and-propose-until-a-human-approves, and a
commander is the case where nobody is watching to approve. the control
promised something that cannot happen there, while the thing genuinely
missing was any view of what it had written.

an EPHEMERAL panel, the same trade /files makes for the file viewer.
/command-hq is a locked workspace with a three-item allow-list whose
comments are explicit that its structure is code's rather than the user's;
a persisted panel would mean unlocking it and widening that list. an
ephemeral layout renders beside workspace.value and never enters it, so
neither has to change.

?files=1 rather than a route, and a panel rather than a link: the
transcript stays mounted and stays the page, and a link on this surface
opens a second copy of the whole app in a new tab and loses the
conversation you were reading.

breadcrumbs stop at the commander for free — Breadcrumb renders relative
to basePath and its home crumb IS the base. worth knowing that this is the
UI's floor and not the server's: the root really is all of user-data, which
is the owner's own data either way, so it bounds the navigation rather than
the account.

the button reads its state from props rather than useSearchParams.
ModelSelector is rendered bare in its own tests and reaching for the router
inside it failed all twelve — a control that has to be mounted in a route
to render is not a control any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 14:22:58 +00:00
pastilhasandClaude Opus 5 6d673a1321 plugins: a plugin's tasks are read where they live
the last piece of docs/plugin-capabilities.md. skills, affordances and
notification catalogues already loaded from each installed and enabled
plugin's own directory; tasks did not, so the transcribe plugin shipped a
task nothing could see — right-clicking an .m4a offered no Run Task at all.

it turned out to be one function rather than several. every path that runs
a task resolves it through getTaskByDirName and then reads implementation
off the record, so the sources go in there and in listTasks and a plugin's
task is runnable everywhere at once, identity unchanged: still a directory
name, still what pipeline_jobs stores.

the record carries `plugin` and that one field does the rest. updateTask
and deleteTask throw on it, DELETE /api/tasks/:name answers 409 naming the
plugin, and the capability screen shows whose it is instead of an edit and
a delete that cannot work. without the delete guard it was quietly a no-op:
rm -rf on a path in the owner's store that does not exist, then ok: true,
and the task still in the menu.

owner's store first, so a plugin cannot take over a name already in use.

getTaskByDirName now refuses a name containing a separator. it always
joined a caller-supplied string onto a path — from a websocket message and
a job row — and searching several roots is the moment that stops being one
directory nobody could escape.

capabilityRootsFrom is gone; it was dead. capabilitySources replaces it and
carries the provenance, with capabilityRoots the thin map over it that the
loaders that only want paths still use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:20:18 +00:00
pastilhasandClaude Opus 5 e35ff5c7ad stt leaves the platform for the transcribe plugin
Speech-to-text is now plugins/transcribe — sidecar, its own config, its own
skill and a task for the file browser's context menu. The transcription logic
moved BYTE FOR BYTE: 416 lines whose every decision has a reason attached, from
the two-pass detect-then-transcribe to the 25 language priming prompts to
timeout:false. Verified identical rather than assumed.

Removed here: api/stt/, server-settings/stt.ts and its mount, POST /chat/stt,
POST /file-browser/transcribe, the FileViewer handler behind the button hidden
last release, the Settings screen, the dead useAudioRecording hook and the
orphaned locale keys across all six locales.

blobToWav stayed. It lived in the same file as the dead hook and is still used
by DictateDialog — unrelated to STT, and deleting by file rather than by use is
how that would have gone.

No permission entry to remove: STT never had one, living under server-admin,
chat and files. The registry count is unchanged and docs:check is green.

The plugin ships a task that will not appear in the menu yet — the platform
loads a plugin's skills, affordances and notifications from its own directory
but not its tasks. That loader is the remaining piece of docs/plugin-capabilities.md
and is deliberately not built here.

1426 pass, tsgo clean, 18 plugins discovered with none broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 03:29:58 +00:00
pastilhasandClaude Opus 5 75d5a795a2 i18n: parity checks itself, over the locales actually on disk
master was out of parity and every test was green. sv-SE named
commandHq.referencePath and referencePathHint; en-GB named workingDirectory and
cwdHint. Two reasons neither was caught.

The three parity tests LISTED their locales literally, so adding a sixth added a
file none of them looked at. A guard that has to be maintained to keep guarding
is the thing it exists to prevent — the same shape as the bug that made three
locales ship dead last night. They now read the directory.

And findMissingKeys is one-directional: it asks whether every English key is
present, so a rename reports half of itself. The new key is missing and caught;
the stale one the translation still carries is invisible. findExtraKeys is the
other half, and both are asserted.

Realigns sv-SE onto the committed key names. It was translated from a working
tree where that rename existed and was not yet committed — the commander
directory work, still unreviewed — so on master it named keys English did not
have. The rename will land across all six locales together when that work does.

Also asserts there IS at least one locale besides en-GB, because otherwise every
assertion passes over an empty set and reports nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 02:02:40 +00:00
pastilhasandClaude Opus 5 1600a57b66 add swedish (sv-SE) locale — all three catalogues, wired in all four registration points
538 app.* + 541 panel.* + 43 ui.* = 1122 keys, full parity with en-GB. Registered in
catalogues.ts, locales.ts SHIPPED_LOCALES, and both workspace locales/index.ts —
the four-edit checklist docs/localization.md calls out, since a missed import is
what shipped es-ES/de-DE/fr-FR dead the first time.

files.menu.selected (officerdev) was number-invariant in English ("selected") but
needs one/other in Swedish ("vald"/"valda"), same class of bug as the "1
seleccionados" fix in es-ES — split it rather than copying the English shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 01:59:09 +00:00
pastilhasandClaude Opus 5 fbdfd3fb89 minesweeper leaves the platform; plugin sockets relay ws-to-ws
Minesweeper is now plugins/minesweeper (gitea.officer.dev/plugins/minesweeper).
Removes /api/minesweeper, the Dashboard screen, the officerdev panel, the
permission registry entry, the App.tsx route and the hand-written websocket
provider in server.tsx. Registry entries: 22 -> 21, execution 4 -> 3.

The relay changed transport, and that is the part worth reading.

A sidecar announces exactly one port. That single announcement is the whole
provenance argument in target.ts — the platform watched the port arrive at
registration, which is the only thing separating it from a number a manifest
asserted. But a raw-TCP relay requires that port to BE a raw TCP listener, so
the sidecar cannot also serve HTTP on it, and the plugin's ordinary routes
have to fall back to the typed command vocabulary over the registration
socket. That is the mechanism the fleet has spent months retiring, and
Minesweeper would have been the first thing to go back to it.

Relaying websocket-to-websocket dissolves the conflict. The sidecar serves
HTTP and /ws on the one port it announced, exactly as the pty sidecar already
does; createSidecarProxy handles the HTTP half; and the conversion from frames
to whatever the plugin actually speaks happens inside the plugin, which is the
only process that knows. The relay is still ignorant of the payload — the
ignorance sits one layer up.

Also: the caller's JWT is no longer forwarded upstream. A sidecar has no use
for it and forwarding one puts a bearer token into whatever that process logs.
X-Officer-User carries the only part it needs. (The terminal relay still
forwards its token to the pty sidecar; not touched here.)

Verified: bunx tsgo clean, bun test 1403 pass / 0 fail, bun run docs:check
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 17:43:43 +00:00
pastilhasandClaude Opus 5 6490dbc368 localization: three shipped locales were never loaded
es-ES, de-DE and fr-FR shipped dead. Every one of their 1,129 keys was
translated and in parity, all three were in SHIPPED_LOCALES so the picker
offered them, and choosing one rendered the whole UI in raw keys.

All three catalogue files still imported only en-GB and pt-PT, so
`catalogues[code]` resolved to `{}` in every namespace — present, and useless.
Adding a locale is four edits across four files and nothing knew whether all
four had happened.

The parity tests could not have caught this and never will. They compare the
JSON files against each other, and a file nobody imports is exactly as
parity-correct as one that is loaded. The gap was between "the translation
exists" and "the app can reach it".

Registers all three in appCatalogs, panelCatalogs and uiCatalogs, moves the
merge out of I18nBridge into lib/catalogues.ts so it is testable without
mounting React, and adds catalogues.test.ts as the place that knows: every
shipped locale must resolve to a populated catalogue in all three namespaces,
and be the same size as the fallback.

Verified the test fails against the shipped code — 6 failures, panel.* for
each of the three — and passes after. 26 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 17:38:55 +00:00
pastilhas d7a4a18ce8 localization: ship es-ES
Complete Spanish catalog across all three namespaces, 1129 keys,
machine-translated then reviewed for terminology and plurals.

Fixed on review: an awkward literal fragment in settings.accounts.description
("Quién tiene acceso, y como qué") reworded to use the established
role → "función" glossary term instead of an ad-hoc phrase. Everything
else held up — "compositor" for the chat composer avoided the same
Editor-collision German needed fixing for, Minesweeper stayed
untranslated matching pt-PT/fr-FR/de-DE precedent, and the impersonal
"se" constructions used for the file-operation plurals (Se copió /
Se copiaron) read correctly at both n=1 and n=0 — Spanish's plural
rule is one/other like English, count=0 takes the plural branch.

All 17 plural entries verified at count 0/1/2.

This closes out the three-locale rollout: es-ES, de-DE and fr-FR all
now in SHIPPED_LOCALES and all three parity tests, each shipped in
its own commit only after passing review. pt-BR stays out per the
owner's call — European Portuguese first, Brazilian is a separate
decision.
2026-08-23 17:21:37 +00:00
pastilhas d6a3bea7c9 localization: ship de-DE
Complete German catalog across all three namespaces, 1129 keys,
machine-translated then reviewed for terminology and plurals.

Fixed on review: "composer" (the chat input box) had been translated
as "Editor" in two shortcuts strings, colliding with this app's actual
Editor/Code-Editor feature — changed to "Eingabefeld". scopeDockers
had drifted from the lowercase, untranslated "dockers" every other
locale (including this one's own pt-PT/fr-FR precedent) uses for that
filter-scope label. All 17 plural entries verified at count 0/1/2 —
German uses the same one/other split as English, so this was mostly
confirming "0 Elemente" reads right, not hunting for a CLDR surprise
the way French needed.

SHIPPED_LOCALES and the three parity tests now include de-DE. es-ES
is drafted and under review — held out of the picker until it passes
its own pass.
2026-08-23 17:19:59 +00:00
pastilhas 0457bf9ff3 localization: ship fr-FR
Complete French catalog across all three namespaces (app/panel/ui),
1129 keys, machine-translated then reviewed for the two things that
go wrong automatically: technical vocabulary and plurals.

Fixed on review: a gender-agreement slip ("décrivez-la" referring to
the masculine "Prompt", should be "décrivez-le") and an incomplete
verb construction in agentRunner.keepsGoing. Also reverted "Minesweeper"
→ "Démineur" back to the untranslated proper name, matching pt-PT's
own precedent of not translating this bundled app's name.

French is the language where the 0/1/2 plural check actually bites:
Intl.PluralRules puts count=0 in the SAME category as count=1 ("one"),
unlike English/Portuguese/German/Spanish where 0 is "other". All 17
plural entries were verified against 0, 1 and 2 rather than trusting
the category names — the "one" branch is phrased as a plain singular
so it reads correctly for both 0 and 1.

Parity tests now check every non-en locale in one assertion instead
of hardcoding pt-PT; SHIPPED_LOCALES gains fr-FR so the picker offers
it. es-ES and de-DE catalogs are drafted and under review — held out
of this commit and out of SHIPPED_LOCALES until each passes its own
review, so a half-translated locale never reaches the picker.
2026-08-23 17:17:50 +00:00
pastilhas 736a45e1ff panel names re-render when the words behind them change
Closes the gap left by the thunk: resolving at render proves nothing is cached, but
AppPicker and PanelSlot are not subscribed to anything that changes when the locale
does, so a switch updated every string in the app except the panel titles.

An opaque revision, which this package interprets in no way at all. It is a string
that changed; the caller decides what changing means. officer-web sets it to the
active locale from I18nBridge, during render rather than in an effect — an effect
leaves the first paint after a switch showing the previous language's titles.

A module-level store rather than a prop on WorkspaceView, deliberately: that
component has 28 call sites, and a prop is a thing 28 places can forget. A forgotten
one is a silently stale title, which is the failure being closed.

The new test bumps the revision and asserts the name follows with NO rerender from the
test. The existing one calls rerender, so it could only ever prove the absence of
caching — it could not have caught this.
2026-08-23 15:55:06 +00:00
pastilhas 7899539d4b add the product-name reason that should have been in 43197745
Repairs my own mistake rather than adding anything. The commit that moved
Untranslated into components/ was meant to widen `reason` to
'foreign-ui-label' | 'product-name' at the same time; its `git add` listed the
pre-move path with stderr suppressed, so the add failed silently, nothing but the
`git mv` rename was staged, and the widening stayed in my working tree.

Consequence, stated plainly: f4ab274d..fa32c82e do not typecheck in isolation,
because the terminal panel uses reason="product-name" against a union that did not
yet have it. The working tree was green throughout, which is exactly why I did not
notice — the per-commit guarantee was broken while the visible state looked fine.
Not rewriting the pushed history: master is shared and another session is pushing to
it, and a force-push to fix a type union is the wrong trade.

The lesson worth keeping is about `git add ... 2>/dev/null`, which turns a bad
pathspec into a no-op that still lets the commit succeed.
2026-08-23 14:50:06 +00:00
pastilhas 43197745f1 move Untranslated into shared components, and add product-name
The panels need it -- they wrap Jellyfin, Soulseek, Gitea, Transmission and name
tools like Tmux and Neovim -- and it was in officer-web/lib, which officerdev cannot
import. components/ is the workspace both already depend on, and the file carries no
strings, so the phase-1 guard stays green.

`reason` gains 'product-name' alongside 'foreign-ui-label'. The union is closed on
purpose and its own comment asks for a second reason to be argued rather than
smuggled in: a product's NAME is not a label quoted from its UI, and the doc's
never-translate list covers it directly under "product names generally".
2026-08-23 14:09:58 +00:00
pastilhas a48a7ff696 the last two shell strings were the composer's pre-filled prompt (2 -> 0)
CapabilityPage puts a suggestion in the chat composer, which the reader sees and
edits before sending -- user-facing, so translated. The hidden promptFrontmatter
beside it is NOT: that one instructs the agent and carries file paths. The scanner
never flagged it, correctly, because it opens with a tag rather than a capital.

officer-web is now at zero convertible strings.
2026-08-23 05:38:17 +00:00
pastilhas bde59eb74e activity, capabilities, chat history and the locale picker (21 -> 2)
The locale picker's labels are now AUTONYMS computed from Intl -- each language named
in its own language, so "Português europeu" reads as Portuguese whichever locale the
UI is in. That is the convention for a language picker and the useful behaviour:
somebody who cannot read the current language still has to find theirs.

CapabilityPage interpolated the capability KIND into six strings by lowercasing it in
code. "skill" does not lowercase into a Portuguese noun, so kind is a parameter now.

What is left is two strings, both deliberate: see the next commit message.
2026-08-23 05:37:04 +00:00
pastilhas 0b85d42197 jobs keyed (73 -> 21)
The three job detail panes and the jobs page. Status labels share one jobs.status.*
group across all three panes rather than three copies of "Running".

statusBadge in two of the panes is a module-level helper, not a component, so it
takes `t` as a parameter -- the alternative is calling a hook outside a component
body. ScriptJobDetail's version built "Failed (exit N)" by string concatenation;
that is a key with a parameter now.
2026-08-23 05:30:45 +00:00
pastilhas 2e846933e9 shortcut registry keyed (96 -> 73)
shortcuts.ts is data with no component around it, so it carries catalog keys and
ShortcutsModal resolves them -- same shape as the settings sections.

gen-platform-skill.ts also reads this registry to generate the agent-facing SKILL.md,
which docs:check validates. That doc stays in one language, so the generator resolves
against en-GB directly and throws if a key is missing rather than emitting a dot-path
into a file an agent will quote at somebody. The regenerated skill is byte-identical,
which is the check that keying the registry changed nothing an agent reads.
2026-08-23 05:26:43 +00:00
pastilhas 5e032898f8 integrations keyed, and the Google guide's foreign labels marked (163 -> 96)
Applies the rule added to docs/localization.md in 91b9bec9: a label quoted from
another product's interface is an identifier, so the prose around it is translated
and the label itself is left in English and NOT keyed.

Gives that decision a name in the code. <Untranslated reason="foreign-ui-label">
states it at each of the ~30 call sites, where whoever next edits the sentence will
see it, and gives the scanner something exact to skip -- without which labels that
MUST stay English count as debt forever and the area can never be held at zero. That
is the same trap as counting new Error() messages. `reason` is a closed union so a
second excuse has to be argued for rather than smuggled in as free text.

The sentences needed rephrasing, not just splitting. "Go to the [New Project] page."
decomposes into "Go to the" + link + "page." -- and the Portuguese value of that
second key is "." A key that holds a full stop is the fragmentation smell, so the
English now puts the label at a clause boundary ("Open the project creation page:"
then the link), which reads naturally in both languages and needs no node-level
interpolation. Worth knowing for phase 3: sentences with an inline link are the one
shape this catalog format cannot express, and rephrasing is the cheap way out.

Scanner: the existing SCREAMING_CONSTANT rule now allows . and - so Google's
client-secret prefix example GOCSPX-... falls under it. Same rule, wider class.
2026-08-23 05:24:32 +00:00
pastilhas b978e09327 authentication and calendar-sync passwords keyed (214 -> 163)
Authentication reaches zero -- sign-in, bootstrap, forgot and reset password. These
are the first words a new account reads, which is why the brief ranked them above
the larger screens.

DavAppPasswords had the third toLocaleDateString(undefined, ...) in the shell; it now
uses useFormat().date like the other two.

DebugPanel's three labels are keyed too. Worth knowing rather than acting on: that
panel is rendered behind showDebug, which Background.tsx hardcodes to false, so it
never appears. Keyed rather than deleted because removing it is not my call.
2026-08-23 05:15:22 +00:00
pastilhas 7f8455f1af user management keyed (267 -> 214)
Accounts, permissions and the create-user form. The confirm dialogs for removing an
account and resetting a password were multi-line JSX the scanner cannot see, so they
were converted on sight rather than from its list.

Cancel comes from ui.button.cancel in all four places it appears here.
2026-08-23 05:10:17 +00:00
pastilhas 44f5246e38 server settings keyed (352 -> 267)
ServerSettings reaches zero: OCR, STT, TTS, SMTP, maintenance and the AI harnesses.

Two more hand-rolled plurals became real plural entries -- maintenance said
"commit" with a ternary on length === 1, and both read correctly at 0, 1 and 2 in
both locales now. The three Cancel buttons in AIHarnessesSection take ui.button.cancel
rather than adding a fourth app-level copy of the word, which is what phase 1 put it
there for.

Scanner: a single token carrying digits and punctuation is a name, not a sentence --
the OCR placeholder is the example model file Qwen2.5-VL-7B-Instruct-q4_k_m.gguf.
Prose has spaces, and requiring whitespace to be absent is what keeps this from
eating real one-word labels. Verified the delta is exactly that one string.

Note for the owner: another session has src/servers/api/sub-agents/router.ts in
flight with three type errors. Untouched by me and excluded from my gate; the full
tree will not typecheck clean until they land it.
2026-08-23 05:04:22 +00:00
pastilhas ad8ed88519 profile settings keyed (425 -> 352)
ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the
model defaults.

Language names now come from Intl.DisplayNames rather than the catalog. The runtime
already knows "German" in English and "alemao" in Portuguese, so 28 names would have
been 56 hand-written entries that Intl gets right for free -- including for locales
not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays
hardcoded: which languages you may claim to speak is a product choice.

ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale --
the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key
toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now.

Scanner: an Authorization header value like "Bearer <token>" is not a sentence.
Excluded by auth-scheme prefix; verified the delta is exactly the four such
credentials across both remaining areas.
2026-08-23 04:57:48 +00:00
pastilhas c3c62828e3 settings framework and section metadata keyed (487 -> 425)
SettingsSection now carries titleKey/descriptionKey instead of title/description, and
the sidebar and content pane resolve them. The section arrays are module-level consts
holding JSX content, so they cannot call a hook — the data holds keys and the renderers
translate. Sidebar search matches the RESOLVED words, so it works in the language on
screen.

Capital K in titleKey is load-bearing: docs-check-settings.ts greps key:\s*'…' for
route segments, and a lowercase title_key would have fed it twenty bogus paths.
Verified 0 bogus, 26 declared, 20 offered, 0 missing.

Section titles now share one key with the Spotlight rows that name the same section,
per 'one key per meaning'. Two casings had to converge, so Settings shows Spotlight's:
'Change Password' now reads 'Change password'.

Settings sidebar search placeholder was 'Search...'; it is now 'Search settings…',
which is what the skeleton catalog already said and what distinguishes it from the
global Spotlight.
2026-08-23 04:46:07 +00:00
pastilhas 926034750f keyed the dock, header and shell chrome (555 -> 487)
Layout reaches zero. The dock, header, user menu, theme switcher, spotlight,
shortcuts modal, screen-error fallback, rescan and bug report.

Three things worth knowing:

CORE_DOCK_ITEMS now carries labelKey instead of label, and one useDockItems() hook
resolves the composed list. DashboardLayout, Spotlight and DockSettings each spread
the same two sources before, so each would have had to resolve labels itself.
Plugin tiles keep the name from their own manifest — a plugin's strings are not ours.

Spotlight resolves labels INSIDE the hook, so rank() still matches what the reader
can see rather than a dot-path. Its to: '/settings/...' literals are untouched, which
is what docs:check parses — verified still 20 offered, 0 missing.

The rescan toast said 'N skill(s)'. That is English giving up on a plural, so it is
now a real plural entry per noun and reads correctly at 0, 1 and 2 in both locales.

ThemeSwitcher used t as its theme loop variable; renamed to entry.
2026-08-23 04:40:39 +00:00
pastilhas 0ddcdcbdbf memoize t in the typed wrappers, and give app code typed access to ui.*
Each wrapper built a fresh closure every render, so `t` in a dependency array
recomputed forever. That is the documented useClient()/Jellyfin trap in
platform/CLAUDE.md, and phase 2 needs t in useMemo deps immediately.

useTranslation in officer-web now also returns tUi, typed against the shared
components' catalog. 'Cancel' and 'Close' recur across the shell and already exist
in ui.* — the alternative was importing two hooks in a hundred files.
2026-08-23 04:30:07 +00:00
pastilhas b640ec486d i18n: default-language picker offers only shipped locales; widen stored bare codes on read 2026-08-23 03:43:35 +00:00
pastilhas 0bd8c6c16a i18n: officer-web catalog goes regional (en-GB, pt-PT), bridge deep-merges app/panel/ui and sets dir 2026-08-23 03:43:01 +00:00
pastilhasandClaude Opus 5 ca7d3c657b a send form for SendOff, and no receiving one
/sendoff — pick files, pick one of your own machines or a person who has allowed
you, send. The consent list sits on the same screen rather than in settings,
because the two questions arrive together: the first time somebody opens this and
finds nobody to send to, the reason is that consent runs the other way, and the
answer has to be visible where the question was asked.

There is deliberately no received view. Files land in Documents/SendOff/ inside
the recipient's own tree, so the file browser already IS the inbox — a second view
of the same directory would be a worse copy of one that works, kept in sync by
hand.

Two details worth keeping. The file input resets its value after every pick, or
choosing the same file twice in a row does nothing, because `change` does not fire
on an unchanged value. And a send clears the files but keeps the target, since
several batches to the same place is the common second action and re-picking it
each time is the sort of small tax that stops somebody using a thing.

The multipart goes through `client.post`, which already recognises a FormData and
drops the JSON content-type so the browser picks the boundary. That was the only
thing making this look like it needed a raw fetch, and a raw fetch would have
carried its own copy of the token resolution — which is a chain of seven sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:47:47 +00:00
pastilhasandClaude Fable 5 af918251d3 /minesweeper — a wine session streamed to the browser over RFB
a websocket byte relay onto x11vnc's tcp socket, the same shape as the
terminal's relay onto the pty sidecar. this is the piece plugins/desktop
could not have: a plugin cannot claim a websocket provider.

the rfb client is ours rather than vendored novnc — raw and copyrect,
one pixel format we dictate so a rectangle memcpys into ImageData. we
control the server, so the other eight encodings are surface we would
carry and never run.

execution, so owner-only: wine runs as the owner, which makes the game's
menus file dialogs over the owner's home.

two bugs worth keeping. the token key is BEARER_TOKEN, not token, so the
upgrade 401'd before any rfb byte moved. and the /status health check
opened a tcp connection to prove liveness — libvncserver blocks its
accept loop peeking for a websocket handshake, so a polled health check
filled the backlog and locked out the real viewer. a liveness check must
not be indistinguishable from a client; it reads /proc/net/tcp now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:39:35 +00:00
pastilhasandClaude Opus 5 b1dfa3f7bb spotlight on shift+space, overriding both of its stock behaviours
Fifth setting in three days: ctrl-space → shift-space → alt-space → ctrl-space → shift-space. Ctrl-space was
tried twice on the theory that preventDefault would beat Brave's address bar. It does not — that binding is
at the browser level and the key never reaches the handler. Recorded in the file so it is not tried a third
time.

Shift-space overrides two stock behaviours deliberately, and the owner's reasoning is the right one: it types
a space in a text field, and it scrolls a page UP when nothing has focus. Neither is worth a launcher.
Page-up-by-shift-space is a fossil, and typing a space is what the unmodified spacebar is for.

The cost is stated once in the file rather than argued again: typing in CAPS by holding shift instead of
using caps lock puts a shift-space between every word, which opens the launcher mid-sentence. If that turns
out to bite, the fix is a text-field guard — which this had for one day, and which is exactly what made the
launcher unopenable from the composer, where it is most wanted. Trading that back would be trading down.

Every other modifier must be absent: ctrl+shift+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix. Verified disjoint in the built bundle, both conditions
quoted from it rather than from the source.

Five moves in three days is the argument for making chords configurable rather than for choosing better ones,
which is why the ctrl+? registry names the implementing file for each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:05:59 +00:00
pastilhasandClaude Opus 5 f586d748d6 ctrl+space opens spotlight, and ctrl+? tells you everything else
Fourth chord for the launcher, and the last move is a return: ctrl-space → shift-space → alt-space →
ctrl-space. Every one was the owner naming a real collision, and this one is the interesting direction —
alt-space is the GNOME window menu, he rebound the desktop to keep it, disliked what he rebound it to, and
would rather move the app than the window manager.

ctrl-space was ruled out on 2026-08-20 as "focuses the address bar in Brave". That observation was made
before anything preventDefault'ed it, and the trade has changed: rebinding a browser is cheaper than
rebinding a desktop. Written down in the file, because if the address bar still wins the key never reaches
the handler and nothing in this repository can help — the fix would be in Brave.

`!ev.shiftKey && !ev.altKey` matters: ctrl+SHIFT+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix on the theme side. Verified in the built bundle that the
two conditions are disjoint.

CTRL+? renders every shortcut from a registry, ported in shape from vimazing's gameInfo.ts — controls
declared as data, help screen renders them. Its renderer was a recursive `any` walk, right for a game whose
help also carries rules and scoring; here the content is one shape, so the renderer is typed and boring and
the value is in state/shortcuts.ts.

The registry is deliberately NOT authoritative yet, and says so: the handlers still hold their own
conditions, so changing a chord here does not move it. Each entry names the file that implements it, so the
pair is one search apart. Making it the source of truth is the configurable-shortcuts work, and doing it now
would rewrite three handlers for nothing visible.

Also removed a line I put in the theme dropdown yesterday promising "alt+space to search every theme by
name". Themes are not Spotlight items — that was an instruction for a feature nobody built, sitting in the
UI as though it worked. It now points at ctrl+?, which exists. Two comments naming ctrl+alt+space for the
theme browser were corrected to ctrl+shift+space with the older chord noted as still accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:57:42 +00:00
pastilhasandClaude Opus 5 430f058a6d command hq: two columns, with the navigation stacked
It was 16 / 34 / 50 — a category column, a commander column, and the transcript. So half the screen was
navigation for a list holding a handful of commanders (one, on this machine) while the only thing anybody
reads got the other half.

Now the two navigation panels are stacked into one 24% column — commanders on top, categories beneath — and
the transcript takes 76%.

Categories BELOW the commanders, not above: the commanders are what you came for and the categories are how
you narrow them. Above, the filter would push the list down the screen and be read first; below, the list is
at the top where a list belongs, and the categories can grow their own scrollbar without taking room from it.

The categories panel lost seventy pixels of branding — a Radar tile, "Command HQ", "Watchers &
long-running jobs" — which made sense when it owned a column and does not now that it sits at the foot of
one. The screen's own header says Command HQ two inches above it.

`All commanders` and `Uncategorised` moved INTO the scroll box with the categories. They are the same kind of
thing, a filter over the list, and splitting them into a separate nav block above made the second look like a
different mechanism. The heading and the + button stay pinned; only the list scrolls — the same rule as the
theme dropdown.

MIGRATION, and it is the part that could have gone wrong. This key is persisted per account, so the default
alone moves nobody who has opened the screen. No appType was retired either, so `hasAppType` cannot see it
the way the /chat migration could — the test is structural instead: a horizontal root with three children is
the old shape. It terminates because the replacement has two, and replacing outright is safe on a `locked`
screen where the only user contribution is column sizes. Same reasoning, and same trap, as
ChatHistory/index.tsx.

Two of my edits silently did nothing first, because I searched for `officerdb` where the module is
`officerdev` and used replace without an assertion. Both are asserted now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:38:41 +00:00
pastilhasandClaude Opus 5 dd6135cac1 favourite a theme from anywhere, and the browser stops fighting the menu
Three reported faults and two additions, all from one keystroke's worth of use.

THE MENU OPENED BEHIND THE OVERLAY. The theme pill is a Radix DropdownMenuTrigger, and a focused trigger
opens on Space — so ctrl+shift+space opened the browser AND the dropdown behind it. preventDefault could
never have fixed it: React attaches its handlers at the root, which bubbles before window, so the trigger
saw the key first whatever this listener did with it. The listener now runs in the CAPTURE phase and stops
the event dead.

THE VIM KEYS DIED WHEN OPENED FROM AN INPUT. Same shape as the Spotlight bug earlier tonight: the guard
that refuses to steal keys from a text field was still true after the overlay opened, because focus had
never left the field. Opening now blurs, and the guard no longer applies once the list is open — there is no
input in it, so there is nothing left for it to protect. Only the GLOBAL bindings defer to a text field now,
which is the case it was written for.

f STARS WHAT IS FOCUSED, inside the list, so a browse and a shortlist are one pass rather than two.

ctrl+shift+f STARS THE CURRENT THEME FROM ANYWHERE, and deliberately without the typing guard the arrows
beside it need: word selection is what ctrl+shift+←/→ collides with in a text field, and ctrl+shift+f
collides with nothing — no character, no browser binding. Guarding it would only break it in the composer,
which is where you are when a theme starts annoying you.

The header pill now shows a star when what you are wearing is one of yours — only when true, because a
hollow star on every theme is chrome that says nothing two hundred times.

The row star in the overlay is a span with role="button", not a button: it sits inside the row's own
<button>, and nesting one is invalid HTML that browsers resolve by dropping one of them.

useThemeKeys takes the toggle as a callback rather than reaching for it. Favourites are per account and live
behind an API, which is app knowledge; this hook owns keys and selection and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:15:36 +00:00
pastilhasandClaude Opus 5 bd15a186b5 themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording.

ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together
in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it".
Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which
is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the
ctrl+shift+space browser, or one teaches an order the other contradicts.

FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a
DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would
lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are
directories discovered at request time — so a star can outlive its theme and the reader intersects with the
live registry rather than trusting the table.

Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer
"what else is there". Filtering to favourites only is a separate, explicit toggle.

THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line
telling you the shortcuts exist was visible for half a second and then gone under two hundred entries —
which is the same as not having written it. Header and footer are now siblings of a scroll box instead of
passengers in it, and the header names alt+space too.

THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the
sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500,
which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account`
permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as
a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it
away from, and caught that by reading rather than by testing.

Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200
so the login screen keeps its colours.

10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in
the original survives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:51:53 +00:00
pastilhasandClaude Opus 5 a5f387929d spotlight takes focus, and its arrow keys no longer depend on getting it
The arrow keys sometimes did nothing when the launcher was opened from a text field, and the two halves of
that are one bug: navigation lived on the panel input's onKeyDown, so every keystroke was conditional on
focus having actually landed there. When it had not — the composer kept it — ArrowDown went to the textarea
and the launcher never saw the key.

Two fixes, because either alone leaves the other half.

Opening now blurs whatever had focus, explicitly, before the overlay mounts. The effect that focuses the
panel's own input was already there and is enough on paper; "on paper" is what produced an
intermittent bug, so the outgoing element is blurred rather than assumed away.

And navigation moved to the window listener that already handled Escape, so it works regardless of what has
focus — a panel that grabbed it back, a blur that lost a race, an element with its own key handling. The
input's copy is deleted; leaving both would move the cursor twice per press whenever focus did land.

The handler reads `results` and `active` through refs, deliberately. The listener registers once per open,
so closing over them would pin it to their values at registration — arrowing down would read a stale list
and Enter would open whatever was first when the panel appeared. Putting them in the effect's deps instead
re-registers the listener on every keystroke, which is the same bug in a costume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:00:45 +00:00