68d60e409b8e035e8b2b1be9ef62cff49286da86
112
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
e4117441b0 |
spotlight had list metrics, and it is not a list
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> |
||
|
|
7671368faf |
alt-space opens spotlight from a text field too
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> |
||
|
|
6b07aafc67 |
spotlight opens on alt-space, and on nothing else
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> |
||
|
|
f9dfd05676 |
header cluster and breadcrumb colour
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> |
||
|
|
3cab16ca5b |
spotlight showed eight items, so every plugin was unreachable
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> |
||
|
|
f023ab5da0 |
spotlight draws the dock's tiles, and lists the settings that actually exist
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>
|
||
|
|
a2cca526f9 |
spotlight opens on shift-space, and stands down inside a text field
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> |
||
|
|
81f32a1faa |
spotlight: ctrl-space, type a few letters, go
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> |
||
|
|
17243c2201 |
rescan: say when a skill exists but is not offered
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. |
||
|
|
ab214d1a9e |
rescan: count affordances, and say what a rescan cannot do
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. |
||
|
|
0a9649a12b |
brand: the drawn Officer wordmark, unskewed
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> |
||
|
|
73cdc2cb77 |
bell: notify a chat's page and list row, any chat, any time
Client half of the per-chat notification preference — DetailBar gets a bell
dropdown (every end of turn / only when input is needed / none), a global
useChatNotifications hook holds one connection to /api/chat/notify/ws for
the whole session so a background chat can still ring the bell and fire a
browser Notification, and the session list shows a badge on any row set to
notify. The server side (schema, the notify-delivery socket, the sidecar
signal at turn-end/plan-gate/question-gate) landed already in
|
||
|
|
d49776307d |
gitea, extracted into plugins/gitea — the platform half
Eleventh extraction, and the only one that deleted its UI instead of moving it. Seventeen files under apps/Gitea/ reimplemented a fraction of an interface Gitea already has; the plugin renders the real one in an iframe over a same-origin proxy. Deleting rather than moving removed a platform change nobody now has to build: useGiteaLocation read :owner/:name/:tab/:item from five route patterns where a plugin gets two, so an as-is extraction would have required teaching the plugin system to declare extra route patterns. Nothing inside an iframe needs a route. Nothing working was lost. The router had been commented out of hono.ts since 2026-08-13 with api: [], and officer-gitea was running nowhere — the screen rendered and every request 404'd, like soulseek, jellyfin and invoices before it. Gitea also stops being a CORE DOCK ITEM. It was hardcoded in CORE_DOCK_ITEMS, the only installable feature that was, on the reasoning that it fronts a remote instance and installs nothing locally. As a plugin its tile arrives from assets/icon.png like every other — which is also why the icon added to public/icons/ this morning was reverted rather than kept. 'gitea' stays in the ServiceName union: the connection lives in core service_connections, and gitea is the reason that table has its "member rows carry only a token and inherit the owner's URL" shape at all. registry.test.ts's grantable-permission test named gitea as "the case this was built for"; a plugin's permissions are registered at install and are never in that compile-time list, so it now names a core one. catalogue.test.ts re-anchored to vnc/notify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d8cccad84c |
Revert "dock: Gitea gets the teacup"
This reverts
|
||
|
|
061e213780 |
dock: Gitea gets the teacup
git + tea, drawn to the icon brief: a green voxel teacup with the branch diagram pressed into the glaze as a maker's emblem, and Officer Duck on the rim. Core dock item with no phone app behind it, so there is an icon and no splash — a splash belongs to something that launches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
839199bf53 |
dock: Calendar and Contacts carry their artwork, not a glyph
Both were redrawn to the icon brief today, so the dock shows the same voxel mark the phone apps do rather than a lucide CalendarDays and Contact. tile: 'bare' for the same reason a plugin's artwork gets it — the art brings its own frame, and a coloured square behind it reads as a mistake. color stays because the active glow, the indicator dot and the settings tint all still use it. These two are core rather than plugins, so the files live in public/icons/ alongside the duck rather than arriving through publishAssets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f2c6ebaa4d |
calendar and contacts get dock tiles again
Their tiles were contributed by the app-store `caldav` catalogue entry, so
deleting that entry in
|
||
|
|
a9be3a92c9 |
command hq: web frontend v1 — nav, grid, detail
Builds the Workspace/Panel screen the design doc's "Layout, v1" described but nothing had implemented yet: /command-hq and /command-hq/:sessionId, three always-mounted panels (officerdev/src/apps/CommandHQ/) — a flat category nav filtering the grid via ?category=, a commander grid reading GET /commanders, and a detail pane that is either an empty state, the new-commander composer, or the selected commander's resumed transcript. Follows the frontend route conventions in CLAUDE.md throughout: Workspace/Panel framework (locked, three fixed panels, no standalone screen), category and session both addressable in the URL rather than a channel, real <Link>s for every row and card, a route pair (/command-hq + /command-hq/:sessionId) the same shape as /chat's. The composer reuses the exact mechanism /chat already uses to create a commander — an ordinary turn with context: 'command-hq' via useChat/ EmbeddableChat — rather than the mobile-only POST /commanders/new HTTP route. Once the harness reports a real transcript id the URL is replaced onto it (useChat's own permalink writes /chat/<id>, which is the wrong screen here, so that's done by hand). fileAsCommander always files a new commander uncategorised — it has no way to know a category was selected in the composer — so if one was, a client-side PATCH sets it after the fact, the same race POST /commanders/new already resolves server-side for mobile, just resolved here on the client instead. The detail panel's transcript resume mirrors ChatPanelWrapper's fetch-then- mount pattern (GET /chat/sessions/:id first, then useChat with resumeSessionId + initialMessages + paginate) rather than reusing it directly — Command HQ's version is simpler (no dashboard/agent-panel concerns) and scopes rename/category/disarm through Command HQ's own PATCH route rather than /chat's, since a commander lives in a different directory than an ordinary chat session. Card status is the three-state read COMMS/command-hq/mobile-4.md proposed and the design record adopted: green running, amber armed-but-not-running (the watcherArmed && !running pair that makes a silently-dead watcher visible), grey neither. Registered in AppRegistry.tsx, re-exported from officerdev's barrel (route helpers + types, per CLAUDE.md's rule for anything an officer-web screen needs), a CORE_DOCK_ITEMS tile (not in DEFAULT_DOCK_PATHS — opt-in via the Dock settings screen, same as Jobs/Monitor/Activity), and a usePageTitle rule. bunx tsgo clean, suite at its 7 pre-existing baseline failures, none of them this feature's. NOT yet clicked through against a running server — the composer's redirect-on-real-id, the disarm button, the category-race PATCH have all typechecked and nothing more. That's the next real test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
54f63b70df |
arrows are the only global binding
`ctrl+shift+h`/`l` are gone. The arrows do that job, and two spellings for one action is what invited the reasonable guess that `ctrl+shift+j` moved down — which opens the browser console. Worth recording that the reported conflict was not one we had: nothing bound `ctrl+shift+j`. The global letters were `h` and `l` only, and the `j`/`k` keys live inside the list with NO modifier. But an interface that makes someone guess a wrong key is the interface's fault, not the guess's. Inside the list the vim keys stay, unmodified: `j`/`k`/`l`/`h` beside `↓`/`↑`/`→`/`←`. Nothing there can collide with a browser shortcut, and this is a vim person's application. Verified: `ctrl+shift+l` no longer cycles, `ctrl+shift+→` does, and `j` then `l` still browses inside the list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f492905023 |
one modifier, and arrows beside the vim keys
Standardised on `ctrl+shift` for all three global bindings, and added arrows everywhere the vim keys work — `h`/`←`, `l`/`→`, `j`/`↓`, `k`/`↑` are now interchangeable. ctrl+shift+→ or ctrl+shift+l next theme ctrl+shift+← or ctrl+shift+h previous theme ctrl+shift+space the list ── Why ctrl+shift and not the alternatives ── Checked GNOME's actual defaults rather than reasoning from convention: <Control><Alt>Left/Right switch workspace <Control><Shift><Alt>Left/Right move window to workspace <Super><Alt>Left/Right switch workspace So ctrl+alt is unusable — those arrows change your workspace, and `ctrl+alt+l` locks the screen, which is presumably why the original bindings put the letters on ctrl+shift in the first place. `shift+alt` is free on Linux but is the keyboard-layout switcher on Windows and `alt+space` opens the window menu. `ctrl+shift` has no OS binding at all. Its one collision is select-by-word inside a text field — which is now explicitly protected. ── The guard moved earlier ── Typing was only checked once the list was OPEN. With arrows on the global cycle that was no longer enough: `ctrl+shift+←/→` is select-by-word in every text field, and stealing it mid-sentence to change a colour scheme would be indefensible. The check now runs before the global cycle too. Verified against a focused textarea: theme unchanged, and the word selection still happened. The first run of that test reported a hijack, and the test was wrong — it clicked a selector that matched nothing, swallowed the failure, and read body-focus as a text field. `ctrl+alt+space` still opens the list. It shipped an hour ago and fingers learn faster than they unlearn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ecd45c4a9 |
themes get their own control, and the vimazing keys
Fourteen themes was too many to keep under an avatar next to "Sign out" — that is where a preference goes when nobody has decided it matters. It now has its own button in the header, between the controls and the avatar, grouped by family. ── The keys, ported from the owner's vimazing ── ctrl+shift+l / ctrl+shift+h cycle forward / back, from anywhere, no UI ctrl+alt+space open the list j / k move the focus l or space apply the focused theme to the app BEHIND the list h back to where you started enter keep what you are looking at escape restore and close The browse is the point, and it is why the overlay is a portal over a 55% backdrop rather than a modal: you are not reading names, you are watching the application change. The theme you started on is captured when the list opens, so `h` and `escape` undo a whole browse however many previews happened inside it. Typing is still typing: the list ignores j/k/l/h while focus is in an input, textarea, select or anything contenteditable. ── Where it lives ── The key handling is in the theme package — it is behaviour that belongs to themes, and it needs no UI. The list and the button are Officer's, because a dialog would drag a component library into a package that currently has none. Verified by driving the real keyboard in a browser: start duck-dark · ctrl+shift+l -> plain · ctrl+alt+space -> overlay j, l -> tokyo-night (previewed) · escape -> plain, closed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
997f7e7a87 |
the account stores a theme, not a colour mode
Your theme now follows the account instead of living in one browser's localStorage.
Choosing Plain on a laptop reaches the phone.
`appearance.colorMode` is superseded by `appearance.theme`. Along the way this
resolves a mismatch that had been sitting there: the server's default was
`appearance: { theme: 'light' }` while the client read `appearance.colorMode`, so that
default had never once been read by anything. Both sides say `theme` now.
The Light/Dark item is gone from the user menu. Light and dark are two themes, both
already in the list above it, and that toggle was the last writer of the legacy key.
── Two bugs found by testing this, both mine, both silent ──
**A default that looked like a choice.** `useSettings` returns `DEFAULT_SETTINGS`
until the request lands — `settings` is never null, it is a complete valid object. So
"wait for settings" fired on the first render against the defaults, marked itself
done, applied the default theme and never saw the real one. Then the persist side
wrote that default over the account's actual preference. It overwrote the owner's own
`dark` while I was testing it; restored by hand from the `colorMode` it had not yet
destroyed. `useSettings` now exposes `settingsLoaded` so the difference is expressible.
**Defaults masking a migration.** With that fixed, an account holding only
`colorMode: 'dark'` STILL came up light. The client merges `DEFAULT_SETTINGS` over
whatever arrives, so a missing `theme` is filled in with the default before any
consumer sees it — and a default is indistinguishable from a choice. The migration
therefore belongs on the SERVER, which sees the stored object before anything is
merged over it. It is read-only: a GET that rewrites the row on every call is a
surprise, and the client persists on its next change anyway.
Verified by putting the account into the pre-themes shape and asking the new handler
directly, rather than by restarting to find out:
stored: {"colorMode":"dark"} -> returned: {"colorMode":"dark","theme":"duck-dark"}
── Needs a restart ──
The first server-side change in this work. Everything until now was frontend and a
rebuild was enough.
950 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e9cb244818 |
the wallpaper, the header and the mark belong to the theme
Asked whether these could follow the theme too. They are the three most visible things
on screen and were the three least reachable: `Background.tsx` hardcoded
`url('/landscape1.webp')`, the header carried `rgba(255,255,255,0.25)` and `#1d2724`
in an inline style, and the logo was an `<img src>`.
Between them that is most of what "the app looks like Officer" actually meant. The
Plain theme turned every panel white and all three stayed exactly as they were, which
is how they were found — the leak detector doing its job on its first outing.
Verified across all three themes:
duck-dark wallpaper=landscape1.webp header=rgba(255,255,255,0.25)
duck-light wallpaper=landscape1.webp header=rgba(255,255,255,0.25)
plain wallpaper=none header=rgb(255,255,255)
── Images could not be done the obvious way ──
`--logo-image: url('/officer-logo.svg')` fails the build. Bun's CSS bundler resolves
`url()` at BUILD time and these files are served from `public/` at RUNTIME, so it goes
looking for a module that does not exist. Colours apply themselves; images cannot.
So a theme publishes a quoted PATH — an ordinary string token the bundler ignores —
and `useThemeAsset` reads it and wraps it in `url()`. One hook rather than the read
inline in two components, because the quote-stripping is the kind of detail that gets
forgotten the second time.
The logo is a `<div role="img">` with a background image rather than an `<img>`, since
CSS cannot set a `src` and a white-labelled build has to replace the mark from its
theme rather than by editing the header.
The header's title colour goes through a `.header-ink` class: `EditableTitle` accepts
a className and not a style, and the token stays the source of truth either way.
Panel radius and the ~985 raw palette usages are still outstanding — see
THEMES-NOTES.md.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
105766a4eb |
extract the visual layer into its own workspace
The application no longer knows what it looks like. It imports one stylesheet and
mounts one provider; every colour, surface and radius lives in `src/workspaces/theme`.
Proven both directions, because either alone would be worthless:
Duck renders IDENTICALLY to before — checked against screenshots taken before
anything was touched, not asserted.
Plain renders as white with black ink, no component edits — panels turn white, the
teal avatar turns black, the grid fills vanish.
── What moved ──
`globals.css` held three unrelated things: Tailwind wiring, the token contract, and
the Duck brand's hex values. Split along those seams. `base.css` is structure and
names no colour; a theme file is values and nothing else, scoped to
`[data-theme='<id>']`. Adding a theme is a CSS file plus one registry line.
Light and dark are separate themes rather than a mode — the owner's call, and it
removes a concept from the app. Each is written out in full: a theme that only makes
sense as a diff against another cannot be authored by a stranger or sold to a
customer. `.dark` survives only as a per-theme flag, because 396 `dark:` utilities in
33 components resolve against it; retiring those retires the flag.
The `duck-*` utility names are kept for now and pointed at neutral variables, so a
non-Duck theme can define them sensibly. Renaming 1,470 occurrences in the same commit
as the extraction would make any visual difference impossible to attribute. Worth
recording that `duck-dark` was never a brand colour — `#14532d` in light, near-white
in dark, used by 946 components to mean "text". It is `--ink` now.
Surfaces are complete CSS values rather than HSL triplets: `--panel-bg` has to be able
to be a gradient or an image, or the glass the app already has would be unthemeable.
The old provider is a re-export, so its six call sites are untouched and the
extraction stays one variable.
── Bug found by the Plain theme ──
`useThemeSync` stamped the server's `appearance.colorMode` over the local theme on
every load, so choosing Plain and reloading silently reverted it. Diagnosed by asking
the page what it was wearing rather than reasoning: `stored` said `duck-dark` when the
script had written `plain`. The server value is now a starting point for a browser
that has never chosen, not a continuous instruction.
── Tools ──
Two, both of which lied to me before they worked. The snapshot script photographed
light twice and called it a clean run (one browser context, accumulated init scripts);
it now warns when a light/dark pair is identical. The preview server returned the
shell for missing assets, so the logo and wallpaper vanished and read as regressions
in the comparison — it falls through to upstream now.
Dead Tailwind v3 residue deleted: `components/ui/tailwind.{config.ts,css}`, referenced
by nothing, with content globs pointing at a directory that does not exist.
── Not done, deliberately ──
`THEMES-NOTES.md` has the full list. The headlines: the reference repo has not been
read, so the switching mechanism is deliberately the dumbest thing that works; the
wallpaper, panel radius and logo are still component-owned and Plain proves it; and
~985 raw Tailwind palette usages remain, worst in Soulseek.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1b65b83f1f |
the plugins screen becomes /marketplace
`/plugins` redirects rather than 404s: the word is in the dock, in the docs and in whatever anyone bookmarked, and a route that worked yesterday returning nothing reads as the feature having been removed. The API keeps its name — only the screen moved — so the permission entry lists both paths, because a client-side redirect is still a route that has to resolve before it can send anyone anywhere. Also: the fetch stubs in marketplace.test.ts needed `as unknown as typeof fetch`. Bun's `typeof fetch` carries `preconnect`, so a bare stub is not assignable — tests passed while tsgo did not, which is the wrong way round to find out. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
29170dd922 |
installing a plugin pins its dock tile, with no reload
Installing Music left the dock unchanged. The tile existed server-side the moment the plugin was enabled, and the only way to see it was Profile → Dock, by hand, after a page refresh. Installing something is already the decision to use it; saying so twice is the bug. Three separate causes, which is why it looked like one stubborn one: 1. `dock_configs.paths` is a COMPLETE ordered list, not a diff — useDock renders the saved list rather than everything available, so anything not named in it is invisible. Nothing in the plugin system had ever written to that table. installPlugin now appends the new route, for the caller (threaded through the route) rather than a guessed owner id. Only for a user who HAS a row. A user without one is left alone deliberately: there is nothing to append to, and writing one would freeze today's defaults into their account permanently. That case is (2). 2. The fallback for a user who never customised was DEFAULT_DOCK_PATHS, which is core-only — so a plugin tile was hidden there too. `defaultDockPaths(plugins)` is now that list plus a tile per installed plugin, and the dock and its settings screen both call it so they cannot disagree about what "default" means. 3. useDock holds ['DOCK'] at staleTime: Infinity and the plugin verbs invalidated only ['plugins'] and ['self-permissions']. So even once the server wrote the path, nothing asked again — that is the part that made a reload necessary. Also fixes a guard I broke reaching for this: typing VERBS as Record<string, VerbFn> widens keyof to `string`, which makes `isVerb` prove nothing and lets an unknown verb index the map. `satisfies` instead, and there is now a check that Verb is still the four-member union. Uninstall deliberately does not remove the path. A stale path with no item behind it is dropped by useDock, and keeping it means disable/enable and reinstall put the tile back where the user had it rather than at the end. tsgo clean, frontend builds. 787 pass / 7 fail — 6 identical to before; the 7th is plugins/music/cliamp, which install pulled into test discovery and which the manifest documents as parked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
027b10bd6e |
step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
|
||
|
|
2e8ec845c8 |
step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:
1. the permission registry → RENAMED here
2. $OFFICER_ROOT/capabilities/ items → kept; this is what capabilities are
3. sidecar routing keys → step 3, becoming `handles`
4. Lightning wallet features → kept; a domain term, and on the wire
to the mobile apps
The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.
Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.
Three breaks the typechecker could not see, all found by exercising it live.
The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.
The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.
And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.
The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.
The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.
Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.
tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
|
||
|
|
9b1c0a75b2 |
a dock tile can be bare, so artwork is not framed in a swatch
The tile background exists so a white lucide glyph has something to sit on — on nothing it is invisible. Artwork does not need it, and a logo framed in an arbitrary coloured square reads as a mistake. badge rounded square filled with `color`, icon inset (what a glyph needs) bare no background, artwork fills the tile (what a mark wants) DEFAULTED from whether the plugin ships assets/icon.png — artwork gets bare, a glyph gets badge — with `tile` on the manifest to override either way. Presence is the declaration, as everywhere else here; the field exists only for the plugin whose artwork genuinely wants a backdrop. Music sets nothing and gets bare. `color` is still required either way. It does four jobs and only one of them is the square: the active glow, the indicator dot and the plugins-list tint all read it too. The icon was also smaller than it looked. The source carried ~8% transparent margin on every side and the artwork is 233x221 inside a square frame, so with the old `h-7 w-7` inset inside a `w-10` box the duck occupied roughly 39% of the tile's area. Cropped to its alpha bounding box, padded back to square to keep aspect, and rescaled: it now fills 95% of the frame instead of 79%, and `bare` gives it the whole tile instead of half. Measured rather than eyeballed — the bounding box came from extracting the alpha plane and scanning it, then the crop was applied to the 1254px original so nothing was resampled twice. tsgo clean, 797 tests, 787 pass, same 7. Verified live: music reports tile=bare, example and offscale still badge. |
||
|
|
f78abbe05a |
a plugin ships its own dock icon as a file, not a lucide name
The manifest's `icon` was a lucide NAME, resolved by `resolveIcon` — which knows 106 glyphs out of lucide's ~1,500. A plugin naming one outside that set silently rendered a neutral box, and a plugin from a marketplace had no way to see the ceiling coming. So the icon is a FILE now: `plugins/<name>/assets/icon.png`, discovered by presence like everything else here. `manifest.icon` stays as an optional fallback for a plugin with no artwork — example and offscale still use it — and the file wins when both exist. No new mechanism was needed. The app store already published sidecar assets: `<dir>/assets/` → `public/plugins/<id>/`, served by a dynamic `/plugins/*` route, with `DockItem.image` rendering an <img>. `pluginDockManifests()` simply never emitted `image`. Install now publishes and uninstall unpublishes — the one thing uninstall is allowed to delete, because these are copies whose originals are still in the plugin's source. Base64 in the manifest was considered and dropped. It would ride in every /api/user/capabilities response for every user on every page load, can't be cached separately, and puts a 5KB string literal in a source file — against the rule this manifest keeps: what a directory listing can say, it says. It also needs no support: `image` goes straight into <img src>, so a data: URL already works for anyone who wants one. Music ships OffMusic.png, resized 1254² → 256² (1.6MB → 108KB) with alpha intact, sized for 3× DPI at the dock's 32px render. It also drops `icon` — the artwork is a voxel duck in headphones, not a glyph. The plugins page showed a generic Puzzle for every plugin; the list and detail header now show the plugin's own icon when it has one. Two bugs found while testing. Dock.tsx imported 26 lucide icons and used 14. The twelve dead ones — Music, Bitcoin, Receipt, Images, CalendarDays, Contact, Clapperboard, Mail, Network, ArrowDownUp, FileText, FolderKanban, MonitorSmartphone — were residue from when every feature had a hardcoded tile. Deleted. And uninstalling a plugin left its icon URL answering 500, not 404. server.tsx globs ./public at BOOT into one exact route per file, each holding a Bun.file handle, spread into the route table AHEAD of the /plugins/* wildcard. So an icon present at boot got an exact route that outlived the file, returning ENOENT on every dock render with the error logged each time — exactly what the wildcard's own comment says it exists to prevent. The comment covered the ADD case; this is its mirror. `plugins/` is now excluded from the boot glob, so the wildcard owns that prefix alone. Verified: 200 installed, 404 uninstalled, 200 reinstalled. [open] A plugin's icon cannot be seen BEFORE installing it, which is the one place an app store most wants to — assets are published at install by design, and an authenticated icon route is no use to an <img>. |
||
|
|
0a55964db5 |
the player moves to the plugin, and src/ has no music code left
officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar, favourites, lyrics toggle and the library vocabulary — ten files. The barrel stops exporting a player it no longer has, and DashboardLayout stops rendering one. The reasoning that kept it was removed rather than refuted. It stayed because the dashboard widget imported useMusicPlayer from officerdev and the platform cannot import from a plugin, so the state had to stay whatever was decided about the UI. The owner moved the widget into the plugin in the previous commit, and the constraint went with it: the whole remaining dependency became one line, DashboardLayout.tsx:66. MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until you notice it already returned null on /music — the mini bar is the transport there, and the host existed purely to own the GaplessEngine. In the panel it does exactly that, and the bar code stays intact for whenever there is a slot. [phase 2] Leaving /music unmounts the host and playback stops. Deferred on the owner's call; the bar was "navigating away must not break the application", and that holds: seekPlayer is optional-chained so a call with no host registered is a no-op, registerPlayerSeek clears only its own registration, the host's cleanup destroys the engine and nulls its ref, and the queue is global state so returning to /music remounts and reloads. Solving it properly needs either a shell slot a plugin can contribute to — which reopens "there is no way to export a component" — or the engine hoisted to module scope, which keeps the rule and loses only the off-route controls. Also: the parked widget now imports the player as a sibling rather than through officerdev, and shared.ts stopped being a re-export shim now that the real file is in the plugin. Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts /example /music /offscale, / and /music both 200, and the player is in the built bundle (music.volume, music:lyrics, now-playing?device=web all present — GaplessEngine is a class name and the production build is minified, so grepping for it proves nothing). Not verified by me: what it looks like in a browser. That needs your eyes. |
||
|
|
e13128846b |
offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
543e88a9a6 |
every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.
web/panels.ts appRegistryMetas — at least one panel
web/layout.ts defaultLayout — how they are arranged
both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout,
not a screen.
there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.
the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.
the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
98c400bf33 |
a /plugins screen to install, enable, disable and uninstall
the management surface for what the last commit made possible. two panels either side of a selection that lives in ?selected= and is read by both independently, so neither can be telling the other something stale — rows are real Links, not buttons holding the name in a closure. the detail panel shows what the tree declared (api, schema, sidecar, web), because "installed and nothing happened" is otherwise a mystery, and it names what uninstall does NOT do: neither disable nor uninstall deletes anything the plugin stored, and the screen says so rather than leaving someone to guess whether a button destroys their data. a directory whose manifest will not parse is listed with its error rather than skipped. a malformed plugin that simply does not appear is indistinguishable from one nobody wrote. `outdated` is surfaced as an Update button: the version on disk moving after an install is the normal state on a developer's machine, and it should be visible rather than inferred. the four mutations are written out rather than generated in a loop — useMutation is a hook, and a hook called from inside a helper is a rules-of-hooks violation even when the call order happens to be stable. caught before it shipped. verified against a running server: the spa builds (19.8 MB bundle containing the new screen), / serves 200, /api/plugins answers authenticated and 401s without a token. full suite 719 pass, same 10 pre-existing failures. live server and plugin_installs left untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eb1fd8c31a |
headscale is core, so stop offering to install it
Reported as: the routes are unreachable and there is no dock tile, on a server where officer-headscale is up and healthy. Both symptoms, one cause. Availability is derived ONLY from the sidecar_installs table — `usable` is the rows with status='installed' AND enabled, and every capability mapped to a sidecar outside that set is added to `unavailable`. A CORE sidecar never gets a row there, because core processes are started by pm2 from the generated ecosystem file and never go through the app store. So `headscale` and `vpn` were permanently unavailable, which withheld the dock manifest AND put /headscale into deniedRoutes for the route guard. The design already knew. catalogue.test.ts has a test called "does not offer to install the baseline", and it has been FAILING since headscale was promoted: Expected to not contain: "officer-headscale" docs/secret-store.md predicted it in as many words — "moving headscale into the light profile also removes it from the app store automatically: catalogue.test.ts asserts the catalogue equals full − light, so the test fails until the entry is deleted". The entry was never deleted, and the failing test was never read. So: entry removed, and the tile moved to CORE_DOCK_ITEMS, where the other things that are always present live. DashboardLayout filters every tile through canVisit(), so a member still never sees it — the capability is kind: 'admin'. The entry's existingFields (URL + API key) are not lost. Servers are added from the Servers view inside the app — ServersView.tsx, ServerForm.tsx, useHeadscaleServers.ts — which is where they were really configured; the app-store form was a second place to type the same two values. Verified: catalogue.test.ts 19 pass/1 fail → 20 pass/0 fail, tsgo clean. PRE-EXISTING, not touched: 10 other tests fail on master, 8 of them in src/servers/capabilities. Confirmed identical before and after this change by stashing it and re-running. Worth a look but not this change's business. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
595dd082a7 |
delete Task Logs
A complete read path over a table nothing could write to. task-logger.ts exported createTaskLog, appendToLog and finalizeLog, and none of the three was called anywhere in the tree — so `task_logs` could never gain a row, and the screen was permanently empty for everyone. The read half was fully wired: mounted router, capability claim, dock icon, two routes and a page-title rule. That is why it looked alive. Gone, in the order it was reached: Screens/Dashboard/TaskLogs/ the screen App.tsx /task-logs and /task-logs/:id Dashboard/index.tsx the export Layout/Dock.tsx the 'Logs' icon, and ScrollText with it state/usePageTitle.ts the title rule api/task-logs/task-logs.ts the router, and its mount in hono.ts api/task-logger.ts 101 lines of orphaned writer officer_db/src/operations/ the directory officer_db/src/schema.ts the export line officer_db/src/types.ts TaskLogSelect / TaskLogInsert capabilities/registry.ts loses '/task-logs' from the `tasks` capability's `api` AND `routes`. The api half is not optional: assertCapabilityTotality check 2 refuses to boot on a capability claiming a prefix nothing mounts, so unmounting the router while leaving the claim would have stopped the server starting. db:push now creates 21 tables, down from 43 at the start of the evening. officer_db/src/operations was one of the two lopsided directories the schema/queries merge exposed. integrations/ is the remaining one, and it is legitimate — it spans server and user-data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eda004a46d |
a naked platform does not describe what it does not have
Reversing my own call from an hour ago. I built the denied-route screen to EXPLAIN the absence — "Music is not installed", with a link to the app store — and argued a redirect erases what you asked for. The owner's correction is the better principle: a server should not know about a sidecar it does not have. Explaining Music is the app describing a feature that, as far as this install is concerned, does not exist, and it leaks the whole catalogue of what could be installed to any member who types a URL. So a denied path is now indistinguishable from an unknown one: redirect home, the same answer App.tsx's path="*" already gave. One behaviour for a member without a grant, an owner without the sidecar, and a typo. Nothing disclosed. The Permissions screen loses both explanatory blocks for the same reason. One listed every capability whose sidecar is absent — a catalogue of uninstallable features presented as a permissions decision. The other described chat, tasks, the desktop and the wallet as "not grantable" to an owner who may have none of them installed. `notInstalled` is gone from the API too, not just hidden in the UI. What is on that screen is what this server can actually do. Still short of what the owner described, and worth naming rather than implying otherwise: routes are DECLARED in App.tsx for every screen and this hides the ones that should not resolve. The end state is routes REGISTERED from the manifests of installed sidecars, so an uninstalled feature has no route to hide. The manifests already exist and the dock is already built from them; the router is not, yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b4f88ec161 |
routes refuse at the route, and a new home is empty
Three things, from a member sitting on /music with no music capability on a server with no music sidecar: an empty library, and 403s in the console. PERMISSIONS AT THE ROUTE. `canVisit` filtered the dock and nothing else, so the tile was hidden and the route was wide open — typing the path, following an old link or restoring a tab rendered the screen anyway. RouteGate now wraps every screen in one place, inside the error boundary. It does not redirect. Sending someone to `/` erases what they asked for and reads as a bug: they clicked Music and landed on Home. It says why instead, and the URL stays put so a reload after installing the thing just works. And it says which of the two reasons applies, because they need different screens and send the reader to different places. `not-installed` is a fact about the SERVER — the owner gets a link to the app store. `not-granted` is a fact about the ACCOUNT, and only the owner can change it. Presenting either as the other sends you looking in the wrong place. ROUTES FOLLOW THE SIDECAR. Free, once the above exists: `deniedRoutes` already covers "held but its sidecar is not installed", so an uninstalled feature has no tile AND no screen. The dock, the Permissions list and the routes now agree because they read one answer. NO MORE SEEDING. Downloads/Documents/Music/Videos/Pictures are gone from both places that made them — the member's provisioning and, older and worse, `/ls`, which created folders in somebody's home as a side effect of LOOKING at it. A listing that invents its own contents is a listing you cannot trust, and the platform has no standing to choose a person's folder layout. A new home is empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d3bed0add9 |
the file browser can actually read a member's home, and plans is gone
"This folder is empty" was a lie. The five seeded directories were sitting there and the platform's readdir raised EACCES: a member's home is 700 and owned by them, which is correct for a shell and locks out the file browser, which runs inside the platform process. /ls caught the error and returned an empty listing, so a refusal looked exactly like data. Two doors, two boundaries, and that is the point rather than a compromise. The terminal and the agent RUN AS the member and the kernel is the boundary there. The file browser acts on the member's behalf from inside the platform, which already applies its own containment and is the owner's process on the owner's machine — it can read anything via sudo regardless. Giving it access describes who is doing the work. Done with named POSIX ACLs, because it has to hold in BOTH directions: a file the platform writes must be editable by the member and vice versa. Mode bits cannot say that — whichever party is neither owner nor group lands in "other", and widening "other" opens the home to every account on the box. A shared group fails the same way, since both parties would have to be in it and that puts every member in a group that can read every other member's home. Two named entries plus `d:` defaults grant exactly two users and are inherited by whatever either side creates, whatever their umask. Verified: platform lists the home, member edits a platform-written file, platform edits a member-written file, and a SECOND member is refused on both ls and cat. /ls now distinguishes EACCES from a missing directory. An empty result is data and must never be how a refusal looks. acl joins the core packages in setup.sh — the alternative is an account that provisions and then cannot list its own home. Also: the file browser's own useTasks/useAgents fired /tasks, /agents and both category endpoints on every render, which is where the last four 403s came from — they are the context menu's Run Task and agent submenus, execution-only. Gated. And plans is deleted: router, screen, routes, dock tile, hook, page title and its capability. It read markdown from <repo>/plans, which does not exist. Fresh-install Permissions is now Files alone, with Terminal to come. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e393d0f5c2 |
a member's screens render, and the shell stops asking for things it cannot have
Three findings from granting Files to a role and signing in as the member. THE BLANK SCREEN. WorkspaceView returns null until workspace.isLoaded, and isLoaded was the success flag of GET /api/dashboards — which the `dashboards` capability gated. So a member with files granted got a completely blank Files screen and no request to /api/file-browser at all: the panel never mounted. Terminal, Chat and every other workspace screen were the same. /api/dashboards is not a feature. It is the per-user key-value store where every screen keeps its layout, entirely `personal`, every row keyed to the caller. Gating it does not restrict an account, it breaks it — which is the definition of `core` at the top of the registry. Moved there. And the failure mode was wrong independently: `isLoaded` now covers a failed fetch as well as a successful one, with `loadFailed` for the difference, so a screen that cannot remember its layout still renders with defaults instead of showing nothing and explaining nothing. THE STRAY REQUESTS. Six shell-level queries gated on isAuthenticated but not on capability, so a member's first paint fired 403s at /server-settings/settings, /jobs/counts (every three seconds, forever), /chat/models, /plans, /music/now-playing and the chat access policy. Each now checks the capability it needs. JobsIndicator and RescanButton also render nothing without `tasks` and `items` — the header was offering two links to a screen the member cannot open and a button that would 403. THE PERMISSIONS SCREEN. It listed all fourteen app capabilities on a server where none of their sidecars are installed. Offering to grant Photos on a machine with no Immich is not a permission decision. It now shows only what is installed, lists the rest as "nothing installed for these yet" so their absence reads as a fact rather than a bug, and marks confined rows as needing a Linux account. Fails open on a degraded read. Found while checking that: the headscale catalogue entry claimed only the `headscale` capability, but the same sidecar also serves `vpn` — a member enrolling their own device — so vpn was never subtracted. Hence `alsoServes`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b7184283e0 |
app store: a screen, so this can be clicked instead of curled
/app-store, built to the platform's own conventions: a locked WorkspaceView with two panels, the selection in `?selected=` rather than a channel, and rows that are real links so cmd-click and a pasted URL both work. `?selected=` and not a /app-store/:id detail route, per docs/navigation-audit.md: this is a master list with a live preview, and linking rows to a detail route would make the detail the whole page and destroy the side-by-side. Both panels read the URL independently — the list and the detail cannot disagree if neither is telling the other anything. The install form is generated from the catalogue's fields rather than written per service, which is what lets a sidecar shipping from its own repository present a form nobody here wrote. `existing` is first in `modes` by catalogue rule, so the default selection is "I already have one" — the answer that avoids starting a second copy of something already running. States are distinguished rather than flattened. Blocked is amber and titled "Needs you", not an error: everything worked and it is waiting for a token only a person can mint. Installed-and-enabled but with a dead process shows a warning rather than a tick that lies. And the disable/uninstall copy says plainly that data, configuration and tables are kept either way, because that is the question anyone hesitates over before clicking. The dock tile is CORE, not plugin-derived: the store is how every other feature arrives, so it must never be one of the things that disappears. Verified through the API the screen uses — 14 items, email reporting installed/enabled with its process online, and /app-store present in the capability routes so the tile renders. NOT verified in a browser: no page has been opened, so the rendering itself is reasoned rather than seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
82e9fdacc2 |
dock: the shell keeps its own items, sidecars contribute theirs
ALL_DOCK_ITEMS was a hardcoded list of everything, so a fresh machine offered Photos, Jellyfin, Transmission and the rest — each leading to a screen reporting itself unavailable — and adding a sidecar meant editing the shell. Neither survives sidecars shipping from their own repositories. Split in two. CORE_DOCK_ITEMS is the baseline that exists on every install: chat, files, terminal, the app's own screens, and Gitea, which is in the light profile because it fronts a remote instance. Everything else is derived from installed sidecars' UI manifests, delivered with /capabilities. Sent with the capability answer rather than fetched separately so the dock has ONE source. Two requests means two moments, and a dock rendered between them shows a tile for something uninstalled or nothing for something installed. Filtered by capability server-side too: a member is not handed the manifest of a feature they cannot use, because "hidden in the client" is the kind of privacy that lasts until someone opens the network tab. Verified live. The owner — who bypasses every permission check — does not bypass this: /photos is absent from routes and present in deniedRoutes because Photos is not installed. Flipping a row's `enabled` makes its tile leave and return with no process touched. Two things fell out. A manifest can declare extraTiles, because CalDAV is one sidecar presenting as Calendar AND Contacts, and collapsing them to keep the model tidy would make the app worse. And DEFAULT_DOCK_PATHS no longer pins /music: useDock drops a path with nothing behind it, so the default dock came up a tile short on any machine where Music was never installed — a default that references an optional feature is how an app looks subtly wrong on a fresh install for no stated reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6a74b3d198 |
name a chat from its own pane, and name the page after it
Two things, one title. The pane's header is now editable and calls the same `renameSession` the list's pencil does, so both surfaces write the transcript's `summary` line and the invalidation that follows refreshes the row. The id comes from the URL rather than `resumeSessionId`: they agree for an ordinary conversation and not for a merged `/clear` chain, where the resume target is the tail while the list and the server address the chain by its head — renaming the tail would have written a title nothing displays. `/chat/new` has no transcript yet, so there the title is read-only. And on `/chat/<id>` the conversation names the page, sitting between a typed tab name and the route default: `label ?? override ?? titleForPath()`. Naming a window is deliberate and must still win. Not gated on full screen, though that is where it earns its keep — the nav header is hidden there, so the browser tab strip is the only thing telling two side-by-side windows apart. Tiled, the same value fills the header's centre. The edit interaction is now one `EditableTitle` shared with the nav header instead of a second copy of it. `allowEmpty` is what keeps the header's "clear it to hand the tab back to the route name" working; everywhere else empty means keep, since the rename endpoint 400s on it. `SessionList`'s row rename is deliberately NOT folded in — it opens from a pencil and confirms with a check, so it is a different interaction wearing the same styling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
417860b892 |
let a maximized panel take the whole window, header included
Maximize had one depth: fill the content region, leave the nav header visible. That was never a choice about how much room to take — the region is an `absolute z-2` stacking context and the header is a `fixed z-10` sibling, so no z-index a panel gives itself can paint over the nav. `top-[56px]` was the workaround. So full screen is cooperative rather than a bigger overlay. The panel asks, and the shell hides its own header for it; `inset-0` is then genuinely the window. Still the same element and the same class swap — no portal, no remount, so scroll position and playback survive the step between depths the way they already survived maximize. The mode rides beside the maximized panel id in sessionStorage as one value, so the two cannot drift; a tab open across this change reads the old bare string, gets undefined for `.id`, and lands on "nothing is maximized". The toggle is offered from every state, so taking the window is one click from a tiled panel, and it steps back to a maximized panel rather than all the way out. The amber light is present at both depths and always goes all the way out, so neither is a trap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
39125b5028 |
let the router decide which nav item is active
Dock, Header and the mobile sheet were computing active state from useLocation with two copies of the same startsWith helper. react-router's NavLink already knows. end is set for Home only: without it NavLink treats '/' as an ancestor of every route, and with it on the others a detail route would lose its highlight. Segment matching is stricter than the string prefix it replaces, which is what was meant all along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |