Files
pastilhas 2360a3ef60 agents.md holds the instructions, claude.md points at it
opencode discovers only AGENTS.md, Claude Code only CLAUDE.md — so with the
content living in CLAUDE.md, every doc here was invisible to opencode. Swaps
all five pairs (root, src/apps, src/databases, themes, plugins): AGENTS.md now
holds the full text verbatim, CLAUDE.md is a short pointer. docs-check's
permission-count regex now reads root AGENTS.md instead of CLAUDE.md; the
workspace-level officer-dev/CLAUDE.md check is untouched, out of scope.
2026-08-27 06:35:31 +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.