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
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
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.
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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
/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>
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>
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>
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>
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>
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>
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>
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>
It read as cramped because the first pass reused the app's own list sizing — a 7px tile, text-sm, py-2 —
which is right for a row inside a panel competing with everything around it, and wrong for a modal you look
at for two seconds with nothing else on screen.
So the search input leads at text-base with a 5px glyph, rows are py-2.5 with a 9px tile, and the label is
text-[15px] with the hint and the route kept deliberately subordinate rather than scaled with it.
Two knock-on changes that are arithmetic rather than taste. The panel widened 560 → 640, because a row now
carries a label, a hint and a route on one line and the route is the first thing squeezed out — it is also
what disambiguates two rows with the same name. And the results box grew 52vh → 60vh, because taller rows in
the same box show fewer results, which would have quietly undone removing the eight-item cap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The focus guard was still there. I moved the trigger to alt-space, wrote that the guard was no longer
load-bearing, and then kept it anyway "as belt-and-braces" — which left the exact bug the move was meant to
fix. Focus is usually in the chat composer, so the launcher did not open from the place it is most often
wanted. Reported one commit later, by the owner, from a text field.
The guard existed because shift-space types a space and eating that keystroke was unacceptable. Alt-space
types nothing, so there is nothing to protect. preventDefault on the handler covers the residual case a
layout might deliver, which is what the guard was really insuring against.
isTyping is deleted rather than left unused: a dead helper carrying a long comment about a decision that has
been reversed is worse than no helper, because the comment still reads as current.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third chord in a day, and each move was the owner naming a collision on the machine he actually uses:
ctrl-space is Brave's address bar, shift-space types a space, alt-space is the window menu — and he rebound
that rather than give up the chord.
Shift-space was the worst of the three and it is worth saying why, because it read as the safe choice.
It is a real key combination inside a text field: it types a space, and people hold shift through one more
often than they think while typing capitals. So it had to stand down whenever focus was in an input — which
is where focus usually is, the chat composer — and a launcher you have to click out of a field to reach is
not a launcher.
Alt-space means nothing in a text field, so the isTyping guard stops being the thing holding the feature up
and becomes belt-and-braces. Kept anyway: two lines against some layout delivering alt-space as a literal
character, which is cheap to prevent and expensive to discover.
`altKey` with `ev.code`, not `ev.key` — alt-space produces a different `key` on several layouts and nothing
at all on others, while the physical key is `Space` everywhere.
No second chord. Two triggers for one thing means two things to collide with, and this one has moved twice.
The TODO item's id still says ctrl-space. Ids are stable by design, so it stays; its body now records all
three moves and which guard was load-bearing for which.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Breadcrumb links rested at duck-forest and hovered to duck-teal; they now sit at the
hover colour, so the hover and its transition go with it. One shared component, so the
full browser and the dashboard widget both pick it up. The current folder stays
duck-dark semibold, keeping clickable and current distinct.
Header: the theme control leads the right cluster and names the theme it is wearing
rather than showing a palette glyph — with 227 of them, which one is on is worth reading.
It takes JobsIndicator's pill styling so the cluster stays one shape, and keeps its
colour on --header-fg rather than hardcoding white.
The jobs indicators and the rescan button are Super Admin only now. Cosmetic, and the
comment says so: /jobs stays reachable by URL and rescan is gated server-side, so
nothing here is load-bearing. Uses the same user?.role check UserMenu already makes in
this folder rather than adding a second convention.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MAX_RESULTS was 8. Core items alone fill eight slots, so on a populated server the empty-query list stopped
at Browser and none of the fourteen plugin tiles below it could be reached — unless you already knew the
name well enough to type it, which is not a thing a launcher gets to require. The owner caught it on alpha
with the dock and the launcher side by side; on this machine, which has few plugins, it looked complete.
The items were there the whole time. This was not a missing source — it was a silent truncation, with
nothing on screen saying "and 20 more", which is exactly how a cap reads as an absent feature.
There was never a layout reason for it either: the results container has scrolled since it was written.
Removed, and the active row now scrolls itself into view with `block: 'nearest'` so arrowing past the fold
works and arrowing through the visible middle does not jump the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things made it read as a generic list rather than as this app's own, and neither was the item source —
that was already the dock, and plugins were already in it.
TILES. SpotlightItem dropped `tile`, so every row got a `${color}22` tinted square: a third visual language
belonging to neither of the dock's two. Which put a coloured pane behind Calendar, Contacts and every
plugin's logo — the exact thing the dock's own comment calls out as reading like a mistake. Rows now use the
dock's rule: a filled square of the item's colour with a white glyph, or no background at all when the
artwork brings its own frame. A settings row with no colour keeps a muted glyph instead of getting a swatch
invented for it.
SETTINGS. The hand-listed sections had drifted within two days of being written, and the file's own comment
predicted it ("worth revisiting if it drifts"). Nine rows against a settings area that has twenty-one:
Languages and Voice missing entirely, every AI and System section missing, `/settings/users` pointing at a
route that does not exist — it is `/settings/user-management/users` — and four different rows sharing the
User glyph, which is most of what made the panel look generic.
Now twenty rows, copied from the five screens that declare them, with each group naming its source file so
checking is one grep. The comment records that the drift HAPPENED rather than predicting it, and says
deriving them from the screens is the fix if it happens a third time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Maintenance had "rebuild the frontend" and "restart the server" — one compiles what is on disk, the
other reloads it, and neither CHANGES what is on disk. Doing that meant ssh, on every server, every
time.
Refuses a dirty tree and a non-fast-forward, the same two refusals as pullPlugin and for the same
reason: this checkout is somebody's working copy on at least one machine, and a button that discards
uncommitted work is worse than no button. Fetches `origin HEAD` rather than a branch name, because a
checkout made before the default branch was renamed is on one the remote no longer has — the trap
plugins/update.ts already hit, and it bites hardest on the installs most in need of updating.
It deliberately does not chain into anything. A pull touching bun.lock means an install, which the
frozen-lockfile rule exists to keep deliberate; one touching a schema means db:push against a live
database. So it REPORTS what the update implies — install, db:push, restart or rebuild — from the file
list, and leaves the decision with whoever pressed it. The commits pulled are shown, because "it
worked" is less useful than knowing what arrived.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ctrl-space focuses the address bar in Brave, so it never reached the app.
shift-space is a real combination inside a text field — it types a space, and people hold shift through
one more often than they think while typing capitals. So the trigger is ignored when focus is in an
input, a textarea or anything contenteditable, and the space is typed as normal.
The cost is worth stating rather than discovering: focus usually IS in the chat composer, so opening
Spotlight from there means clicking out first. If that turns out to be the common case, a double-tap of
shift is the escape hatch — it conflicts with nothing anywhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dock is full and cannot grow. Once navigation lives here it is free to be a short list of what you
actually use rather than a complete one — which is the other half of this and is not done yet.
It INDEXES THE DOCK rather than keeping a list of its own. The dock is already the composed,
permission-filtered answer to "what can this account open here": the shell's screens, plus whatever
installed plugins contribute, minus anything canVisit refuses. A second list would be a second answer
to one question, and it would be wrong the day somebody installs a plugin. So a new plugin appears in
Spotlight for the reason it appears in the dock — because it is installed — and nothing here is edited.
Settings sections are added, because they are what people hunt for and the dock deliberately does not
carry them. Those ARE hand-listed, which is a real cost written down in the file: a section added to
IntegrationsSettings will not appear until someone adds it here. The alternative couples five settings
screens to the shell to save five lines.
Matching is subsequence, not substring, and lives in its own file so it can be tested with strings:
"chq" finds Command HQ, "apik" finds API keys, "fi" gives Files rather than Profile. The weights encode
what "the right result" means — exact label, then prefix, then word boundaries, then adjacency, with
shortness breaking ties so "Chat" beats "Chat history". Labels are worth about three times hints, and
paths almost nothing, so a path fragment finds an item without ever outranking a real label. Nine tests
cover the ORDER rather than the matching, because returning the right set is easy and returning it with
the right thing first is the product.
ctrl-space, not alt-space: the more macOS-shaped chord is already load-bearing for the owner. Not
cmd-k either — that belongs to the browser on some platforms and to text fields on others.
Mounted beside the header and the dock rather than inside the content region, so it still gets you off
a screen that is maximised, erroring or mid-navigation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last two pieces the extraction missed. IntegrationsSettings/BrowserRelay.tsx drove the plugin's
pairing from a platform screen, calling /browser/relay-token and working only because the plugin
happens to serve that path — the same coupling removed everywhere else this morning.
public/browser-relay-extension.zip was a hand-built snapshot that had drifted 58 lines behind the
extension it claimed to be: built in August from something predating a February commit, never rebuilt,
and missing the multi-session recovery path. The plugin builds the archive from its own directory per
request now, so there is nothing left to fall behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
plugins/browser now owns the extension, the relay, the CDP bridge, the pairing routes and the two
panels. All of it was on disk and switched off since 2026-08-13 — nothing was recovered from history,
and nothing regressed, because none of it had been running.
What the platform keeps is /scrape, and the two were never one feature split in half. Scrape launches
a headless chromium through playwright as the OWNER and knows nobody; the plugin attaches to the
browser a person is already signed into, scoped per pairing. The permission is renamed "Scraping" so
the registry stops describing one thing in the other's words, and it drops the /browser frontend route
it could no longer serve.
Two dead screens went with it. BrowserScreen and DesktopScreen were officer-web screens rendering
panels through appType placeholders — a plugin supplies its own route and panels through
Plugins.gen.tsx, so both were redundant the moment their plugins existed. DesktopScreen was a leftover
from this morning's extraction that typechecked cleanly while rendering a panel the AppRegistry no
longer had: dead at runtime, invisible to the compiler.
The long comment in server.tsx explaining BROWSER_RELAY_PORT goes too, replaced by a pointer to where
it lives now. It was written for whoever did this extraction, and this is that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five revoked keys, no live ones, and no way to remove any of them — which is what the screen looks
like after a few rounds of testing. `listApiKeys` returns every row and nothing filtered them, so a
list whose job is "which of my keys still work" answered with a wall of dead ones.
Nobody decided that. Revoke was implemented and the list was never taught the difference.
Dead keys — revoked or past expiry — are now hidden behind a "Show 5 revoked or expired keys"
toggle rather than merely dimmed, because on any account in use for a while they outnumber the live
ones. The empty state distinguishes "No keys yet" from "No keys in use": the first is false on an
account whose keys are all revoked, and it is exactly the sentence that sends somebody hunting for a
bug in the list.
DELETE /:id/record removes the row for good, and only after it is revoked. The `revokedAt is not
null` clause is in the statement rather than checked before it, so there is no ordering in which a
live key is deleted by a request that raced a revoke. Nothing is weakened by allowing it: the key
cannot be honoured again either way, since authentication matches a hash and a deleted row has none.
What goes is the history — `lastUsedAt`, the only evidence of whether a leaked key was used before it
was killed — which is why it stays a second, deliberate step rather than what revoke does.
Also corrected the copy, which claimed keys are per-app. They are not scoped to anything: one key
works everywhere, and per-device is a revocation convenience, not a requirement. The owner had to ask.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported by the owner, who went looking for them in user management and then in profile, and found
them in neither. They were under Integrations.
A key is not an integration with anything. It is a second way to present the authority the account
already has, and the permission registry already says exactly that: `/api-keys` is `core`, sitting
beside `/user` and `/dock`, with the comment "a key is not new authority". Profile, next to the
password it stands in for, is where somebody looks.
Moved rather than duplicated, and the route moves with it — /settings/profile/api-keys. Nothing
linked to the old path, so there is no redirect to keep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`IntegrationsSettings/index.tsx` has carried the rule since email was extracted
on 2026-08-18: "A plugin contributes panels, not settings sections, so it now
lives inside the email workspace itself."
the atelier extraction brief said the opposite, and the brief was mine and
wrong. sonnet found the contradiction, recorded it in BLOCKERS.md under a
heading that says "against a direct precedent", and followed the instruction
anyway — which is what it was told to do. so the wrong call got implemented
carefully rather than caught.
settings in the platform mean the plugin is not self-contained: it cannot be
configured on an older platform, and uninstalling leaves a dead section in the
settings screen for someone to delete by hand. neither is true of a panel.
the section, its import and its now-unused icon are gone from here. the screen
is `plugins/atelier/web/Providers/` and a fourth panel.
the key field was an Input and a Button side by side, outside a form, so Enter
did nothing. pasting a key and pressing enter is the strongest habit there is
in a password field, and when it silently does nothing it reads as "the key
did not save" — which is the one thing this screen must never be ambiguous
about.
TODO.md tracks nineteen panels with the same defect. this is not the twentieth.
everything else in the screen was already right and is left alone: type=password,
autoComplete=off, the server never returning a key, and the field starting
empty with a placeholder that says whether one is set.
decision 2 of the atelier extraction: providers (which adapter, which model, whose key) becomes an
Officer settings screen rather than a panel, since it is owner-level configuration in the same shape
as the Apify token beside it. Talks to /api/atelier/providers over HTTP through useClient() — no
import from plugins/atelier/ in either direction.
recorded in BLOCKERS.md: this goes against IntegrationsSettings' own comment from the email
extraction five days earlier ("a plugin contributes panels, not settings sections"). Followed the
pre-made decision anyway per the kickoff prompt's own instructions, and wrote down the
contradiction rather than picking one silently.
the button counts skills — one directory with a SKILL.md is one skill. it now
also reports how many are actually OFFERED to an agent, which is a different
number: the loader skips any without a `description`, because a line that does
not say when to reach for something costs context in every conversation and
earns nothing.
so a skill can be present, listed, editable in the UI, and invisible to every
agent, with nothing anywhere saying why. the toast names the gap and the reason
rather than leaving two numbers to be noticed by whoever thinks to compare them.
the header's rescan walks ITEM_TYPES, and affordances are not one — no
directory per item, no canonical file — so they were invisible to it. write a
new affordance, press the button, and nothing in the response mentions it.
counted by LOADING them rather than counting .md files, so the number means
"affordances the agent will actually receive" and not "markdown files
present": one with no body is skipped by the loader and should not be reported
as though it were working.
counting is as far as this can honestly go, and the toast now says so.
affordanceBlock() is read when a chat session is CREATED, so a new affordance
is already live without a server restart — for the NEXT session. nothing can
retrofit one into a conversation already running, because its system prompt
was assembled when it started. the button's value here is "the file is on disk
and the platform can see it", which was the part genuinely unknowable from the
UI.
also creates the affordances directory, so it is discoverable on a machine
that has never had one, the same way the item dirs already are.
Fifteen sites across thirteen files where a raw green stood in for a
semantic state: connection dots, health lights, completed-job ticks, the
copied-to-clipboard check, the installed badge, the checksum-matched line.
All of them meant "on / connected / succeeded", and matched each other only
because several people happened to pick the same palette number.
The values do move: --success is #16a34a where green-500 was #22c55e and
emerald-500 #10b981. That is the point — one token, so a theme can change
its mind once, and under plain.css (the leak-detection theme, which greys
--success to #404040) these now go colourless instead of staying green.
Left alone, deliberately: the confetti palette and the Start button in
start-confetti-button, the duck-avatar debug HUD, MetricCard's up/down
delta ramp, CommandBlock's check on its hardcoded dark block, the
react-email template, and the plain.css comment that names the anti-pattern.
Reds are untouched — that is a separate pass, so some ternaries now read
bg-success : bg-red-500 until it happens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the old mark everywhere it is referenced — the header reads it through
--logo-src and every theme points at the same file, and the auth landing page and
the emailer read it directly, so one asset covers all three.
The CSS skew goes with it. That is where the old logo's slant came from, and
applying it to this one shears letterforms that were kerned pair by pair by hand
and signed off as finished. See landing/assets/brand/WORDMARK.md for the
construction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>