`platform/themes/<id>/` — untracked, same rule as `plugins/`, only the instructions
tracked. Drop a directory in and the theme appears in the picker on the next page load.
themes/my-theme/theme.css required — `[data-theme='my-theme'] { … }`
themes/my-theme/theme.json required — { name, family, dark }
themes/my-theme/assets/ optional — wallpaper, logo
── Why this is cheaper than installing a plugin ──
A plugin is code: it needs a manifest module, mounting, and a frontend rebuild to reach
the bundler. A theme is a block of CSS custom properties, so it needs none of that. The
server reads the directory at request time and the client adds a `<link>`. No install
step, no rebuild, no restart — which is the whole reason this is eighty lines of
discovery rather than four hundred.
The id is the DIRECTORY NAME, not a manifest field, for the same reason a plugin's
`appName` is not one: an id that can disagree with where its files sit eventually will.
Discovery checks that the CSS actually targets its own directory name, because the
failure is otherwise silent — the theme appears, is selected, and nothing changes.
── Problems are reported, not swallowed ──
A malformed theme returns a reason rather than vanishing:
broken-probe: theme.css does not target [data-theme='broken-probe']
The client logs those, so an author has somewhere to look instead of wondering why
their theme never appeared.
── Boundaries kept ──
The `theme` package still performs no IO. It gained an `extraThemes` prop; Officer
fetches and injects, exactly as it supplies the panel registry to `workspaces`. A
failure to fetch is not an error — no server, no session, no directory all mean "you get
the built-in themes", which is a complete product.
`/api/themes` is a `core` capability: read-only, and not deniable, because an account
that cannot read the theme list renders unstyled. Installing remains a directory on
disk; an upload endpoint would be a way to write arbitrary CSS into the page from a
request.
Verified in-process: listing, the problem report, CSS served as text/css, and a
traversal attempt answered 404.
── Needs a restart ──
Server-side change. Until then the client's fetch 404s and silently keeps the built-ins,
so nothing breaks in the meantime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4.2 KiB
Themes
Drop a theme in here and it appears in the picker on the next page load. No install step, no rebuild, no restart — a theme is a block of CSS custom properties, and none of that needs compiling.
This directory is untracked, exactly like plugins/. Only this file is in the repository. Your themes
are yours; the platform's own live in src/workspaces/theme/css/themes/.
The shape
themes/
my-theme/
theme.css required — the values
theme.json required — the name a person reads
assets/ optional — a wallpaper, a logo
The directory name is the theme's id. It is not repeated in the manifest, because an id that can disagree with where its files sit eventually will.
theme.json
{
"name": "My Theme",
"family": "My Theme",
"dark": true
}
name |
required — what the picker shows |
family |
optional — groups variants together, like Duck Light and Duck Dark. Defaults to name |
dark |
optional — adds the .dark class. Set it for a dark theme; several hundred dark: utilities still resolve against it |
theme.css
Must target its own directory name, or nothing will happen and it will be baffling:
[data-theme='my-theme'] {
--background: #1e1e2e;
--foreground: #cdd6f4;
/* …the rest */
}
The server checks this on discovery and reports it rather than letting you wonder.
What to put in it
Twenty tokens are required. They are the standard shadcn set, which is what every published palette already ships — Tokyo Night, Catppuccin, Gruvbox, Nord all come in this vocabulary, so porting one is mostly copy-paste.
background foreground · card card-foreground · popover popover-foreground
primary primary-foreground · secondary secondary-foreground
muted muted-foreground · accent accent-foreground
destructive destructive-foreground · border input ring · radius
Everything else is derived from those. Panels, the header, the dock, glass, grid lines, scrollbars —
all computed from card, border, background and foreground. A palette theme never mentions them.
Four more are worth setting, because the standard set has no notion of them and the defaults are
generic: --success, --warning, --info, --busy, each with a -foreground. busy means running,
not warning — it is what a live job wears.
Anything else you want to change, you can. Declaring a variable in your theme beats the derived
default, because both land on <html> and yours is more specific. That is how a theme gets a texture
rather than just a palette:
[data-theme='my-theme'] {
--panel-bg: rgba(255, 255, 255, 0.08); /* glass instead of a flat card */
--panel-blur: 20px;
--panel-radius: 0; /* square panels */
--page-bg-src: '/themes/my-theme/assets/wallpaper.webp';
--logo-src: '/themes/my-theme/assets/logo.svg';
}
The full list of what you can override is in src/workspaces/theme/README.md, and
src/workspaces/theme/css/themes/duck-dark.css is a worked example of a textured theme.
Assets are quoted paths, not url(...). The bundler resolves url() at build time and your files are
served at runtime, so a url() token would break. The app wraps the path for you.
Writing one
Start from src/workspaces/theme/css/themes/plain.css rather than from a Duck theme. Plain is white,
black and grey throughout, so every value you change is one you decided about, with nothing inherited from
someone else's brand.
Then: reload the page, open the picker or press ctrl+shift+space, and use j/k to browse. l applies
the focused theme to the app behind the list, so you can watch your own theme against real screens.
Editing theme.css and reloading is the whole loop. The stylesheet is cached for a minute.
Checking your work
Render the app under Plain first if something looks wrong under yours. Anything still coloured under Plain is a component painting itself rather than reading a token — that is a platform bug, not yours, and worth reporting.
bun run check:theme counts hardcoded colours in the platform. It does not check themes, but it is why
your theme has a chance of working: the number can only go down.