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.
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:
- 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
strategyobject. This is the layer built-in themes are authored in. - 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. - 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
| Field | What it does |
|---|---|
name | Display 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. |
id | Optional. Generated from the name as custom-<slug> when omitted. |
location | A free-text place name. This is what the Theme Browser renders as the caption under the hero image. Stripped on export. |
heroImage | The image the Theme Browser shows at the top of the drawer, and the thumbnail on each row. Validated on import. See below. |
extensions | Optional 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.
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 adata: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-imagedata: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()andrgba(), legacy comma and modern space syntaxhsl()andhsla()oklch()andoklab()color-mix(), validated one level deepvar()references, optionally with a color fallback- All 147 CSS named colors, plus
transparentandcurrentcolor
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.
Invalid color values block the import. Older theme files that once loaded with warnings on a malformed color need cleaning up before they will import.
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
typefield, so the polarity was guessed from the surface colors. Add"type": "dark"or"type": "light"to make it explicit. - Unknown tokens: the
tokensobject 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 acolor-mix()the runtime will do but the validator cannot. These are skipped rather than failed. accent-rgbfallback: 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
- Open Settings > Appearance > App and select the theme to export.
- Click Export app theme... below the picker.
- 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
- Open Settings > Appearance > App and click Import app theme...
- Select a
.jsonfile. - 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.
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.