`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>
119 lines
4.2 KiB
Markdown
119 lines
4.2 KiB
Markdown
# 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`
|
|
|
|
```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:
|
|
|
|
```css
|
|
[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:
|
|
|
|
```css
|
|
[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.
|