wharf

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)"
  }
}
FieldWhat it is
labelThe name shown in the picker. Up to 40 characters; "Custom theme" if absent.
descriptionOne line under the name. Up to 120 characters; optional.
darkThe colours used when the theme setting resolves to dark. Required.
lightThe colours used when it resolves to light. Required.
idIgnored. 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

TokenPaints
background, foregroundThe page and its text.
card, card-foregroundPanels and grouped settings.
popover, popover-foregroundMenus, tooltips, dropdowns.
primary, primary-foregroundThe accent: filled buttons, the selected item, links.
secondary, secondary-foregroundQuiet buttons and inputs.
muted, muted-foregroundBackgrounds and text that should recede.
accent, accent-foregroundHover and active rows.
destructive, destructive-foregroundDelete and other irreversible actions.
success, success-foregroundDelivered, connected, passing.
warning, warning-foregroundLag thresholds, unsaved state, conflicts.
infoNeutral status text.
border, input, ringDividers, input outlines, the focus ring.
chart-1 … chart-5The metrics page's series, in order.
sidebar, sidebar-foregroundThe rail and the collections sidebar.
sidebar-primary, sidebar-primary-foregroundThe selected item in the sidebar.
sidebar-accent, sidebar-accent-foregroundHover rows in the sidebar.
sidebar-border, sidebar-ringThe 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

  1. Open Settings → General and scroll to Custom theme.
  2. Click Upload theme… and pick your .json file. If it loads, it appears in the palette grid and is selected; if not, the reason is shown under the button.
  3. 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.