Skip to main content

Theme Authoring

The Daintree theme file format: the palette-first authoring model, the ~145-token contract, extensions, import validation and contrast warnings, export, and sharing themes with a team.

Reviewed

A Daintree theme is a JSON file. Export any built-in theme, edit it, import it back, and it appears in the picker, the theme palette, the Theme Browser, the shuffle pool and the match-system preferences alongside the built-ins. This page covers the format, the contract you are writing against, and what the importer will and will not accept.

If you are choosing rather than building a theme, see Theme System.

The Three Layers

The theme pipeline has three layers, and knowing which one you are editing is most of the work:

  1. The palette. Roughly 40 inputs: five surface planes, four text tiers, a border ink, an accent (and optional secondary accent), four status colors, four activity colors, a full terminal color set, syntax colors, and a small strategy object. This is the layer built-in themes are authored in.
  2. Semantic tokens. The palette compiles into roughly 145 app-wide tokens: the stable contract every component reads, exposed as --theme-* CSS variables. You can override individual tokens where the derivation does not give you what you want.
  3. Extensions. Component-level CSS variables for specific regions of the UI: toolbar, sidebar and worktree rows, settings dialog, Project Pulse, panel chrome, panel focus chrome, the dock, the grid background, the welcome screen. These are optional; omit them and the CSS fallbacks apply.

The division of responsibility is deliberate: the app owns layout, spacing and animation timing. A theme owns color, shadow, material and component chrome. There is no way for a theme to move things around, and that is what makes an arbitrary imported theme safe to run.

The File Format

The importer accepts three shapes, and you can mix them. All of them are a single JSON object.

Palette format

The closest thing to how the built-ins are written. Give a palette and let the engine derive everything:

{
  "name": "My Custom Theme",
  "type": "dark",
  "location": "Somewhere worth naming",
  "palette": {
    "type": "dark",
    "surfaces": {
      "grid": "#0e0e0d",
      "sidebar": "#131312",
      "canvas": "#19191a",
      "panel": "#1d1d1e",
      "elevated": "#2b2b2c"
    },
    "text": {
      "primary": "#e4e4e7",
      "secondary": "#a1a1aa",
      "muted": "#71717a",
      "inverse": "#0e0e0d"
    },
    "border": "#282828",
    "accent": "#36ce94",
    "status": {
      "success": "#36ce94",
      "warning": "#d3a343",
      "danger": "#d17b72",
      "info": "#6ba3d6"
    }
  }
}

Nested token format

Override compiled tokens directly, under a tokens object. Useful for small edits to an exported theme:

{
  "name": "My Custom Theme",
  "type": "dark",
  "tokens": {
    "surface-canvas": "#1a1a2e",
    "surface-sidebar": "#1e1e34",
    "surface-panel": "#232340",
    "text-primary": "#e8e8ed",
    "text-secondary": "#a0a0b0",
    "accent-primary": "#36ce94",
    "border-default": "#2a2a45"
  }
}

Flat format

Token keys can also sit at the top level alongside the metadata fields. Anything that is not a recognized metadata key and not a recognized token key is ignored.

Metadata fields

FieldWhat it does
nameDisplay name in every picker. Falls back to the filename when omitted.
type"dark" or "light". Decides which built-in template fills your gaps, and which polarity's validation rules apply. Omitting it produces a warning.
idOptional. Generated from the name as custom-<slug> when omitted.
locationA free-text place name. This is what the Theme Browser renders as the caption under the hero image. Stripped on export.
heroImageThe image the Theme Browser shows at the top of the drawer, and the thumbnail on each row. Validated on import. See below.
extensionsOptional map of component-level CSS variable overrides, keyed by the allowed extension names.

Falling Back to the Template

You do not have to define all ~145 tokens. Anything you leave out falls back to the matching built-in theme: Daintree for dark themes, Bondi Beach for light themes. This is not a convenience detail, it is the design principle that keeps custom themes coherent.

Both templates are complete, contrast-validated themes. A handful of core tokens (surfaces, text, accent, borders) is enough to define a distinct look, because every token you did not think about arrives from a theme that has already been audited. It is also why the light-mode rebuild made Bondi Beach the reference light theme rather than just another entry: every light theme anybody imports inherits its decisions wherever they left a gap.

Tip

Exporting a built-in theme is the fastest way to start. Export Daintree or Bondi Beach, change the accent and a few surfaces, and import it back: you inherit a fully validated baseline for everything you did not touch.

Non-color Tokens

Not every token is a color. Material blur and saturation, radius scale, scrollbar width, focus-ring offset, shadow profiles, grain opacity and blend, the state-chip and label-pill opacities, and the chrome noise texture are all pass-through values: they are checked for being a non-empty string and otherwise handed to CSS verbatim.

One token is neither: accent-rgb carries a comma-separated RGB triplet like "62, 144, 102", because components composite accent tints as rgba(var(--theme-accent-rgb), …). If your accent-primary is a non-hex color (an oklch() value, say) and you do not supply accent-rgb yourself, it falls back to "0, 0, 0" and every accent tint renders black. The importer warns when that happens.

Import Validation

Imports run through two layers: errors that block the import outright, and warnings that let the theme load with a note. Errors are collected in a single pass and reported together, so you see every problem at once rather than fixing them one at a time.

Errors: the import is blocked

  • Invalid color values. Any color token or palette color leaf whose value is not a structurally valid CSS color: Invalid color values for token(s): <list>. Values must be valid CSS colors (hex, rgb/rgba, hsl/hsla, oklch/oklab, color-mix, var, or named color). Palette-format files get the equivalent message naming the failing palette fields.
  • Invalid accent-rgb. Must be three integers between 0 and 255, comma-separated.
  • Invalid heroImage. Only a data:image/ URL or a path (relative, dot-relative or root-relative) is accepted. Everything carrying a URL scheme is rejected (http:, https:, file:, ftp:, javascript:, and non-image data: payloads) along with protocol-relative URLs (//cdn.example.com), Windows absolute paths and UNC paths. A theme file should not be able to make the app fetch from the network or read an arbitrary location on disk.
  • Nothing recognizable. A file with no known tokens and no palette fails with No recognized app theme tokens or palette found.

Accepted CSS color formats for any color value:

  • Hex: 3-, 4-, 6- and 8-digit (#abc, #abcd, #aabbcc, #aabbccdd)
  • rgb() and rgba(), legacy comma and modern space syntax
  • hsl() and hsla()
  • oklch() and oklab()
  • color-mix(), validated one level deep
  • var() references, optionally with a color fallback
  • All 147 CSS named colors, plus transparent and currentcolor

This is structural validation rather than full CSS parsing. The goal is to reject obvious garbage like "not-a-color" at the import boundary without rejecting legal CSS the browser would happily render.

Warnings: the import succeeds with a note

Non-fatal. The theme loads and activates, and the message lists what is worth a look. A theme carrying warnings also shows a small warning pill on its row in the Theme Browser.

  • Inferred type: no type field, so the polarity was guessed from the surface colors. Add "type": "dark" or "type": "light" to make it explicit.
  • Unknown tokens: the tokens object holds keys Daintree does not recognize. They are ignored. Usually a theme exported from a newer version.
  • Low contrast: a text-on-surface or indicator-on-surface pair falls below its threshold.
  • Overlay contrast: the hover overlay does not move the rendered luminance enough to be perceptible, so hover states read as dead.
  • Terminal legibility: an ANSI or syntax color does not separate sufficiently from the terminal background.
  • Unevaluable: a value the static checker could not resolve to a concrete color: an unresolved var() or a color-mix() the runtime will do but the validator cannot. These are skipped rather than failed.
  • accent-rgb fallback: a non-hex accent with no explicit triplet, as described above.

The Contrast Validator

The contrast checks are the substantive part of validation, and they run against far more than a single text-on-background pair.

Primary text is checked at 4.5:1 against all five surface planes: grid, sidebar, canvas, panel and elevated. Secondary and muted text are checked at 3.0:1 and above against the same five. Every status color (success, warning, danger, info) is checked at 3.0:1 against all five, because status colors render as text in this app rather than as a fill. Search highlight text is checked against its own highlight background. Link text has its own floor, at body-text strength, because a link is readable body text.

Critically, the validator does not stop at opaque hex. Tokens carrying alpha are composited before they are measured: an rgba() value, or a hex value with an alpha channel, is blended over the surface it will actually render on and the resulting concrete color is the one that gets scored. Without that, every translucent overlay, wash and hairline in the theme would pass validation trivially and fail in practice. Where a value genuinely cannot be resolved statically, it is reported as unevaluable rather than silently passing.

Beyond ratio checks there are separation checks, which ask whether two things that must look different actually do: the Project Pulse heat ramp's four levels, the working and waiting activity colors, cards above a dialog body, selected versus unselected filter chips, and the surface ramp itself. These use perceptual distance in OKLCH rather than contrast ratio, because "these two greens are distinguishable" is a different question from "this text is readable".

The built-ins are checked too

The same machinery runs against every shipped theme as part of the test suite, not just against imports. Every built-in theme's ANSI slots and syntax colors are validated for legibility against their own terminal background, every accent is checked for chroma, canvas separation and perceptual distance from every other same-polarity accent, and every surface ramp is checked against the step and span floors. A built-in theme cannot ship with a contrast regression.

Exporting

  1. Open Settings > Appearance > App and select the theme to export.
  2. Click Export app theme... below the picker.
  3. Choose a location. The filename defaults to the theme's name with unsafe characters stripped, plus .json.

The exported file is the compiled scheme: name, type, the full token set, and the palette and extensions when the theme has them. The location and builtin fields are stripped, so the file is clean for redistribution. An active accent override is baked in as accent-primary. Cancelling the save dialog does nothing, with no error.

Importing

  1. Open Settings > Appearance > App and click Import app theme...
  2. Select a .json file.
  3. The theme parses, validates, is added to your custom themes and becomes active. You will see Imported "Theme Name".

With non-fatal warnings the message becomes Imported "Theme Name" with N warning(s). and lists them; the theme still loads. With errors the import is blocked and every error appears inline at once. Cancelling the file dialog is silent.

Sharing Themes

Sharing is file-based and works over any transfer you like: Slack, email, or a file committed to a repository. An exported file re-imports without loss, so round-tripping is safe.

For a team theme, export a built-in, adjust the accent and surfaces to match your branding, check the accent against the low-contrast warning, and distribute the file. Everyone who imports it gets an identical setup. Imported themes participate in everything the built-ins do: they appear in the picker and the Theme Browser with their file path shown, they can be set as the preferred dark or light theme under match system appearance, and they join the shuffle pool.

Note

Custom themes live on the machine that imported them. They are not stored in the project and are not synced, so each person on a team imports the file themselves.