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.
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.