The button drew its text from --status-danger, which is tuned as a FILL colour — the confirm button in the remove modal sits white text on top of it, and there it is correct. Reused as text on the app's own background it measures 3.79:1 on nebula, 3.84 on dark-blue and 3.95 on the example theme, all under the 4.5:1 AA floor, which is why the control read as barely there. Light is the only theme where the same value passes, at 4.53:1, so brightening it globally would have fixed three themes by breaking the fourth. Two jobs, two values. --status-danger-text is now defined per theme: #f87171 on the dark ones (6.29-6.47:1) and #c92a37 on light (5.43:1, up from 4.53). #f87171 is not a new invention — it is the red the CVE severity chips already use for exactly this job, so danger-as-text now looks the same wherever it appears. The border follows the text so the two stay coherent, and both fall back to --status-danger, leaving any theme without the token no worse off than before. The filled modal button is deliberately untouched: white on #dc3545 was never the problem. Other danger-as-text call sites exist (loading screen, network notifier) and would likely benefit, but they render on their own backgrounds rather than the app surface, so they are not assumed to share the measurement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Custom Themes
Drop a folder here named after your theme and refresh the browser — the topbar dropdown will pick it up automatically.
Minimum
frontend/themes/<your-name>/
theme.css (required) — palette inside a [data-theme="<your-name>"] block
meta.json (optional) — { "displayName": "Pretty Name", "author": "you" }
The folder name (<your-name>) becomes the data-theme value. Use
lowercase + dashes — no spaces, no slashes.
Tokens
Copy frontend/themes/example/theme.css as a starting point. Every CSS
variable defined there is what style.css and friends consume — change
the values, keep the names.
The visible-essentials list (override these at minimum):
| Token | What it controls |
|---|---|
--surface-bg |
Body background (gradient or solid) |
--text-primary |
Default text colour |
--accent |
Theme's signature colour (manage btn) |
--accent-rgb |
Same colour as r,g,b for rgba() mixes |
--text-rgb |
255,255,255 (dark) or 0,0,0 (light) |
--bg-rgb |
The opposite of --text-rgb |
Status colours (--status-success, --status-danger, --status-warning,
--status-info) are usually safe to leave as the defaults — they're
brand-stable across themes.
How discovery works
On page load the frontend calls GET /api/themes/list. The backend walks
this directory, returns one entry per folder containing a theme.css, and
the frontend (js/system/theme-registry.js) injects each entry's CSS
into <head> and adds it to the topbar dropdown. The currently-saved
theme is <link>-loaded synchronously by the inline bootstrap in
index.html so first paint has the right palette — no flash.
The built-in themes (nebula, dark-blue, light) live in this folder
too — their meta.json files set "builtin": true so a future UI can
distinguish them (e.g. a "reset to built-ins" affordance). If you delete
a built-in folder you'll remove it from the dropdown; the app survives
but the deleted theme is gone until you put it back.
The dropdown orders themes by:
nebula,dark-blue,light(built-ins, in that fixed order)- Everything else, sorted by display name.