Guides
Custom themes
Write a JSON theme file with your own colours and load it into the desktop app.
The desktop app ships with a set of palettes, each with a dark and a light half. If none of them is quite yours, you can write your own as a JSON file and load it from Settings → General → Custom theme. It appears next to the shipped palettes, is kept between launches, and can be replaced or removed from the same place.
Start from a shipped palette
The file has exactly the shape of the palettes the app ships with. The quickest start is
to copy one — wharf.json
is a good template — and change the colours:
{
"id": "harbour",
"label": "Harbour",
"description": "Deep navy under a lamp-lit azure accent.",
"dark": {
"background": "oklch(0.155 0.028 256)",
"foreground": "oklch(0.955 0.008 240)",
"card": "oklch(0.195 0.03 256)",
"card-foreground": "oklch(0.955 0.008 240)",
"popover": "oklch(0.205 0.032 256)",
"popover-foreground": "oklch(0.955 0.008 240)",
"primary": "oklch(0.72 0.145 230)",
"primary-foreground": "oklch(0.16 0.04 240)",
"secondary": "oklch(0.255 0.032 256)",
"secondary-foreground": "oklch(0.955 0.008 240)",
"muted": "oklch(0.235 0.03 256)",
"muted-foreground": "oklch(0.685 0.028 246)",
"accent": "oklch(0.285 0.038 254)",
"accent-foreground": "oklch(0.965 0.008 240)",
"destructive": "oklch(0.63 0.2 22)",
"destructive-foreground": "oklch(0.98 0.01 22)",
"success": "oklch(0.74 0.14 172)",
"success-foreground": "oklch(0.17 0.035 172)",
"warning": "oklch(0.82 0.14 82)",
"warning-foreground": "oklch(0.19 0.04 82)",
"info": "oklch(0.74 0.11 222)",
"border": "oklch(0.3 0.035 254)",
"input": "oklch(0.27 0.033 254)",
"ring": "oklch(0.72 0.145 230)",
"chart-1": "oklch(0.72 0.145 230)",
"chart-2": "oklch(0.74 0.14 172)",
"chart-3": "oklch(0.62 0.16 268)",
"chart-4": "oklch(0.82 0.12 200)",
"chart-5": "oklch(0.76 0.14 58)",
"sidebar": "oklch(0.128 0.03 258)",
"sidebar-foreground": "oklch(0.94 0.01 240)",
"sidebar-primary": "oklch(0.72 0.145 230)",
"sidebar-primary-foreground": "oklch(0.16 0.04 240)",
"sidebar-accent": "oklch(0.245 0.038 254)",
"sidebar-accent-foreground": "oklch(0.965 0.008 240)",
"sidebar-border": "oklch(0.26 0.033 254)",
"sidebar-ring": "oklch(0.72 0.145 230)"
},
"light": {
"background": "oklch(0.985 0.005 235)",
"foreground": "oklch(0.22 0.035 256)",
"card": "oklch(1 0 0)",
"card-foreground": "oklch(0.22 0.035 256)",
"popover": "oklch(1 0 0)",
"popover-foreground": "oklch(0.22 0.035 256)",
"primary": "oklch(0.53 0.145 245)",
"primary-foreground": "oklch(0.99 0.008 240)",
"secondary": "oklch(0.955 0.012 235)",
"secondary-foreground": "oklch(0.25 0.035 256)",
"muted": "oklch(0.955 0.012 235)",
"muted-foreground": "oklch(0.495 0.032 248)",
"accent": "oklch(0.925 0.022 235)",
"accent-foreground": "oklch(0.22 0.035 256)",
"destructive": "oklch(0.55 0.2 22)",
"destructive-foreground": "oklch(0.99 0.01 22)",
"success": "oklch(0.55 0.115 172)",
"success-foreground": "oklch(0.99 0.01 172)",
"warning": "oklch(0.63 0.125 72)",
"warning-foreground": "oklch(0.99 0.01 82)",
"info": "oklch(0.52 0.12 230)",
"border": "oklch(0.895 0.016 238)",
"input": "oklch(0.935 0.014 238)",
"ring": "oklch(0.53 0.145 245)",
"chart-1": "oklch(0.55 0.145 240)",
"chart-2": "oklch(0.58 0.12 172)",
"chart-3": "oklch(0.5 0.16 272)",
"chart-4": "oklch(0.65 0.11 205)",
"chart-5": "oklch(0.64 0.14 55)",
"sidebar": "oklch(0.962 0.012 236)",
"sidebar-foreground": "oklch(0.22 0.035 256)",
"sidebar-primary": "oklch(0.53 0.145 245)",
"sidebar-primary-foreground": "oklch(0.99 0.008 240)",
"sidebar-accent": "oklch(0.918 0.022 235)",
"sidebar-accent-foreground": "oklch(0.22 0.035 256)",
"sidebar-border": "oklch(0.895 0.016 238)",
"sidebar-ring": "oklch(0.53 0.145 245)"
}
}| Field | What it is |
|---|---|
label | The name shown in the picker. Up to 40 characters; "Custom theme" if absent. |
description | One line under the name. Up to 120 characters; optional. |
dark | The colours used when the theme setting resolves to dark. Required. |
light | The colours used when it resolves to light. Required. |
id | Ignored. A loaded theme is always the app's one custom slot. |
Both dark and light are required, and each must define every token below. A
missing token would leave that surface painted in whatever palette was active before,
so the file is refused with the names of the tokens it lacks rather than loaded
half-finished. Keys the app does not recognise are ignored.
The tokens
| Token | Paints |
|---|---|
background, foreground | The page and its text. |
card, card-foreground | Panels and grouped settings. |
popover, popover-foreground | Menus, tooltips, dropdowns. |
primary, primary-foreground | The accent: filled buttons, the selected item, links. |
secondary, secondary-foreground | Quiet buttons and inputs. |
muted, muted-foreground | Backgrounds and text that should recede. |
accent, accent-foreground | Hover and active rows. |
destructive, destructive-foreground | Delete and other irreversible actions. |
success, success-foreground | Delivered, connected, passing. |
warning, warning-foreground | Lag thresholds, unsaved state, conflicts. |
info | Neutral status text. |
border, input, ring | Dividers, input outlines, the focus ring. |
chart-1 … chart-5 | The metrics page's series, in order. |
sidebar, sidebar-foreground | The rail and the collections sidebar. |
sidebar-primary, sidebar-primary-foreground | The selected item in the sidebar. |
sidebar-accent, sidebar-accent-foreground | Hover rows in the sidebar. |
sidebar-border, sidebar-ring | The sidebar's dividers and focus ring. |
The Accent colour setting still layers over a custom theme: choosing anything other
than "Palette" replaces primary, ring, their sidebar twins and the two -foreground
tokens with the chosen accent, exactly as it does for a shipped palette.
Colour values
A value is a hex literal or a single colour function. The shipped palettes use
oklch(), which
keeps perceived lightness even across hues, but any of these load:
#1a2b3c #1a2b3c80
rgb(26 43 60) rgba(26, 43, 60, 0.5)
hsl(210 40% 17%) hwb(210 10% 76%)
lab(…) lch(…) oklab(…) oklch(0.72 0.145 230 / 40%)
color(display-p3 0.1 0.2 0.3)Anything else is refused, and the message names the token. That is deliberate rather
than strict for its own sake: a theme file is untrusted input, and the values in it end up
in the app's stylesheet. Keeping them to colours means a file cannot reference another
property with var(), fetch anything with url(), read an attribute with attr(), or
close the declaration it was written into. Named colours such as red, color-mix(),
and values over 64 characters are refused for the same reason. Labels are trimmed to a
single line.
Loading and removing
- Open Settings → General and scroll to Custom theme.
- Click Upload theme… and pick your
.jsonfile. If it loads, it appears in the palette grid and is selected; if not, the reason is shown under the button. - Click Replace theme… after editing the file to load the new version, or Remove to drop it and return to the default palette.
There is one custom slot. Loading a second file replaces the first. Choosing a shipped palette while a custom theme is loaded keeps the file on hand, so you can switch back without loading it again.