Files
platform/themes/CLAUDE.md
T
pastilhasandClaude Opus 5 0d29e242a3 themes are installable, like plugins
`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>
2026-08-17 01:52:21 +00:00

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.