0a9649a12bc917d292c9c9c605c6615a86857921
97
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
2502c33804 |
put the settings section in the url
Five settings pages moved from a `*_SELECTED` global to `/settings/:page/:section`. The sidebar entry is a react-router `<NavLink>` rather than a button holding the key in its onClick closure, so a section is linkable, cmd-clickable and gets its active state from the router; each page renders one `SettingsRoute` guard that canonicalises both the bare route and a section that does not exist. Integrations needed more than the shared factory. It builds its own sidebar, and it kept the Enterprise/Personal tab in a second global — which is why a deep link to a Personal section could never have worked: the link set the section, the tab stayed on Enterprise, and the content pane said "Select a section" about a section that existed. The tab is derived from the section key now. Also removes the `/settings/resources` menu item (audit M8) and its two locale keys: there has never been such a route, so it bounced to the catch-all and out to `/`. |
||
|
|
64961d49f5 |
catch render errors instead of showing a white screen
The repo had no error boundary anywhere, so a single malformed stored layout took the whole app down and the only recovery was a psql session. Two boundaries, because "recover" means different things: - around the routed screen in DashboardLayout, with the dock and header deliberately left outside so navigating away is itself a way out, plus a two-click reset of every `screens/*` layout for when it fails again in the same place. Dashboards are not touched — they are user-created and hold content. - around each panel app in PanelSlot, so one bad app leaves the rest of the workspace running. Its recovery is "clear this panel", offered only when the layout is the user's to edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
537a77d320 |
capabilities: the dock a member sees, and the screen the owner grants from
useCapabilities is the frontend's view of the model and explicitly NOT its enforcement — hiding a dock icon is a courtesy, the 403 in origin-validation is the lock. so it fails OPEN: if the request errors the full dock renders. a member clicking through to a 403 is a bad minute; an owner locked out of their own platform by a transient network error is an incident, and the server refuses what it should refuse either way. the endpoint returns held routes AND denied routes, because absence from the held list cannot distinguish a route this account lacks from one no capability claims at all — `/`, the settings shell — and a guard that cannot tell those apart either blanks the app or guards nothing. i wrote the first version without the second list and it silently permitted everything. `can` and `canVisit` are memoised on the query data. a verb rebuilt every render gets a new identity every render, which is how every playback report in the jellyfin player was disabled for days; the dock filter puts one in a useMemo dependency list, so it would have been the same bug. the permissions screen is one role at a time, with an explicit save and a dirty state, rather than a roles-by-capabilities grid — a grid invites reading across rows, which is not a question anyone has, and makes revoking gitea for every member one click among fifty. it also states plainly why terminal, chat, files and the rest are absent, so their absence reads as a decision rather than as a missing feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7700e8b540 |
make the editable tab name survive refreshes
sessionStorage is the per-tab store — separate per tab, survives reload and navigation, dies with the tab. The route title is derived rather than assigned, so navigating no longer wipes a name you typed. Duplicating a tab clones sessionStorage, so a `navigate` that arrives already holding a name is treated as a clone and drops it; fails soft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
10ff23c5dd |
gitea: the app — repos, code, issues, pulls, notifications
Replicates what Gitea's own web UI offers, on top of the sidecar's /_api pass-through. Repository browsing (tree, file view with the FileViewer's shiki renderer, README), commits, branches, tags, releases, issues and pull requests both per-repo and cross-repo, notifications, explore/search and organizations. Routes are /gitea/:section plus /gitea/repo/:owner/:name/:tab/:item, all real Links with the URL as the source of truth — no selection channel. Markdown is rendered client-side (react-markdown + remark-gfm + rehype-sanitize, rehype-raw deliberately absent) rather than through the instance's /api/v1/markdown, because consuming that means dangerouslySetInnerHTML and there is no DOMPurify in the tree with installs frozen. The cost is Gitea's #123 and @mention cross-references; relative links and images are resolved instead. The /markdown and /markup allow-list entries stay, so that door is open when a sanitiser lands. retargetUrls rebases instance-minted URLs onto a browser-reachable origin, IN ONE DIRECTION ONLY. This instance answers with two: /user and /repos build from its configured ROOT_URL (http://localhost:9004), /contents from the public host. An unconditional rewrite onto the connection URL therefore broke the second set to match the first, turning working https links into dead loopback ones. Only a private/loopback URL is rewritten now, and only when the target is itself public; when the connection URL is a dial address nothing is touched and the connection screen says why avatars will not load. Also carries the frontend half of the one-instance-many-tokens model: the connection form draws a URL field only for the owner and sends no url key at all for anyone else, ServiceConnection.url is string | null to match officerdb, and the rebase origin comes from the resolved instanceUrl rather than connection.url, which is null for a member. Not verified: no runtime pass since the last four changes, the issues and pull views have never rendered a row (the instance has none), and the member path has never executed (one account). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a894d64cb3 |
jellyfin panel app: browse, detail and player
adds the /jellyfin screen and its two panels (nav + view) on top of the jellyfin sidecar, following the photos/invoiceshelf shape. the transcode path is a progressive mp4 rather than hls, so no hls.js dependency is added under the frozen-install rule. that stream has no length and no byte ranges, so the player owns its own scrubber and seeks by re-negotiating at a new startTimeTicks, tracking offset + currentTime; a direct file keeps native controls. the hls url is still returned, so switching later is a player change only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a328451250 |
add /settings/user-management
The role column now has somewhere to be used from. Lists every account, changes
roles, removes members.
API — all owner-only, mounted on the existing users router:
GET /api/users list, plus the role enum so the UI never
hand-writes the names
PATCH /api/users/:id/role change a role
DELETE /api/users/:id remove an account
ownerGate uses isSuperAdmin, which now reads the role column. The global backstop
in originScopeMiddleware already confines a non-owner token to /api/auth +
/api/music, so a Member cannot reach any of this — the gate is the explicit
statement of intent and gives a clear 403 rather than leaning on a rule written
for another purpose.
The password hash never leaves the handler: listing accounts is not a reason to
hand out hashes, so the response is an explicit shape rather than the row.
The owner is refused twice over, in both handlers, before the database has to.
ck_users_owner_is_super_admin and deleteUser() would each reject it anyway, but a
raw CHECK violation surfaces as a 500 in Postgres wording, which tells the person
clicking a dropdown nothing. Same reason `isOwner` is on the wire: the UI locks
that row rather than offering an action that cannot succeed.
The menu entry is shown to the owner only. That is tidiness, not access control —
the route stays reachable and the endpoints are gated server-side, because a
hidden menu item is not a permission and anything relying on it being hidden is
already wrong. Said so in the code, next to both.
Deleting cascades — passkeys, dashboards, screens, email accounts, playlists —
and there is no undo, so it asks first and says what goes.
Not built: invitations. Creating an account still means bootstrap or a row by
hand; an invite flow needs a token, an email and an acceptance screen, which is
its own piece of work.
Untested at runtime: the routes 404 on the running server because platform TS
does not hot-reload. Everything typechecks, the token path was verified against
/api/dashboards, /api/tasks and /api/jobs returning 200, and the 404 is the
restart asymmetry rather than the wiring. Needs `pm2 restart officer`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4d2ec215a2 |
add /calendar and /contacts screens over the caldav json door
two workspace screens backed by one app folder: the collection list on the left, the selected calendar's agenda or address book on the right. both read the officer-caldav sidecar through the /api/caldav auth proxy, which holds no DAV credentials of its own. the selected collection is a DAV path with slashes in it, so it lives in ?collection= on the same route rather than as a path segment — still in the URL, still bookmarkable, no selection channel. an absent or unknown value resolves to the first collection inside the panels instead of redirecting, because until the list has loaded there is no canonical URL to redirect to. the agenda is a grouped list, not a month grid: the sidecar returns RRULE unexpanded, so a grid would have to invent occurrences the server never claimed existed. a repeating event gets a badge instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cc95f7fa17 |
run an agent from the file browser
Right-click an entry and agents whose triggers match appear under their own Run Agent submenu — deliberately not folded in with tasks, because an agent run is a chat session and not a job, and one menu promising both would lie about what a click does. The modal shows the absolute target path, autofills entry_path with it (tilde expansion stays the server's job), and on Run links to /chat?cwd=<runs dir> instead of a queue entry: there is no job row to view. Trigger matching and category grouping are now shared with tasks rather than duplicated, and the task input form is reused as-is. Rescan counts agents and invalidates their caches, so a new AGENT.md shows up on the button rather than after the 60s staleTime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
035a1ba8f6 |
add photos, an immich-backed library behind its own sidecar
officer-photos owns the whole Immich contract: the instance URL and the API key live there and nowhere else, and the platform side is an auth-gated forwarder holding no credentials. The route surface is an allow-list keyed on the first path segment, so admin, auth, api-keys, sessions, jobs, system-config and libraries are unreachable by construction rather than by enumeration. The UI mirrors Immich's own sidebar — timeline, explore, map, search, albums, people, favorites, sharing, archive, trash — because the point of a sidecar screen is to reproduce what the upstream already ships, then extend it. The timeline reads Immich's columnar time-bucket format directly; selection lives in the URL per docs/navigation-audit.md. Two things worth knowing for anyone touching this later: - `duration` is an integer count of milliseconds in Immich 3.0. It was an HH:MM:SS.mmm string before, and every stale example still shows that form. - the map container is sized with h-full/w-full, never `absolute inset-0`. maplibre's stylesheet sets `position: relative; overflow: hidden` on the element it is given, and an unlayered vendor rule beats Tailwind 4's layered `.absolute` regardless of source order — so the div collapses to height 0 and clips its own canvas away. Nothing errors: the GL context is healthy, tiles download and pixels are drawn into a buffer nobody ever composites. maplibre-gl is pinned to 5.x deliberately; 6.0 resolves a separate worker file from import.meta.url, which Officer's index.html fallback answers with HTML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
188b45c113 |
remove projects and apps end to end
deletes the last of the projects/apps cluster: the published-app store (/api/apps + /api/app-serve), the project dev-server and its websocket proxy (/api/dev-server + /api/dev-server-proxy), the shared html-rewrite they were the only consumers of, and their frontend — the Preview panel, the UserApp panel/header, useUserApps and the /settings/apps screen. also drops getUserProjectsDir and getUserAppsDir, the ProjectType and ProjectDefinition types, and the 'dev-server' websocket provider from server.tsx. nothing on disk is touched. 1440 deletions, 31 insertions. tsgo clean. |
||
|
|
f8826e4c24 |
add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
5ee56e736b |
add the invoices ui over the invoiceshelf sidecar
a /invoices workspace screen replicating the parts of the invoiceshelf web ui that are actually used: dashboard, invoices, estimates, recurring invoices, payments, expenses, customers, items and reports. one document editor serves invoices, estimates and recurring invoices — they share the line-item table, the discounts, the taxes and the totals, and differ only in a header strip. the arithmetic is a port of upstream's use-document-calculations, rounding points included, because the server re-validates the totals it is sent. money is integer minor units throughout and is converted to major units at the edges only. selection and editor state live in the url (`/invoices/:section`, `?selected=`, `?edit=`), so the nav highlight is derived rather than held, back closes an editor instead of leaving the screen, and a half-written form survives a reload. sending a document is behind an explicit confirmation — it emails the customer. built against the running 2.4.2 instance rather than the 3.0 checkout in _references; the two differ materially. typechecked, not yet exercised in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
387664964c |
add a transmission sidecar and ui
officer-transmission is a new pm2 peer that owns the transmission rpc
connection and exposes a curated /_officer/* contract instead of proxying
raw rpc. it absorbs the three quirks callers otherwise have to know about:
the 409 x-transmission-session-id handshake, failures returned as
{"result": "..."} inside http 200, and basic auth where an empty username
must send no header at all.
/transmission is the ui, on the workspace/panel framework: a filter nav and
three sections (torrents, stats, settings). the torrent list is virtualised
with 30 available columns, multi-select, and a right-click menu; the detail
pane covers general, files as a real tree, peers and trackers. filters and
the open torrent live in the url, so a filtered view is a link.
phase 1 goal was parity with _references/transmission-web. follow-up work is
recorded in TODO.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
adf922de30 |
add the officer-headscale sidecar and its server registry ui
officer-headscale owns the whole Headscale contract: the registered servers and their admin api keys, the >=0.29 version floor, and every multi-call composition the ui needs. the platform side is auth+forward only and holds no headscale credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars are untouched and unrelated. officer manages many servers rather than one. the owner registers each with a url and a key generated on that server and switches between them; exactly one is active, enforced by a partial unique index rather than by convention. keys are encrypted at rest and never leave the sidecar — the list projection cannot return one. registration validates before it saves: an unauthenticated GET /version to prove something headscale-shaped is there and meets the floor, then an authenticated call to prove the key works. an edit that moves either half re-validates. there is deliberately no transparent /api/v1/* passthrough. headscale serialises every uint64 as a json string and its rest shape moved repeatedly below 0.29; proxying raw would push all of that into the browser, which is the mistake the soulseek panels made with 37 raw upstream calls. the /headscale workspace is nav + view over the panel system. only the servers section is implemented — nodes, users and pre-auth keys say so plainly rather than rendering an empty table that reads as a failed fetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |