a dark colorscheme for neovim
cendre
one wood fire, taken apart five pigments, none of them chosen
The ground is the ash bed: wood ash is spectrally flat, so under a flame it returns the fire's own colour and nothing else. One hue at seven depths, which is why a float over a statusline over a cursorline reads as the same bed at different depths.
// every hue on this page came out of this func hueOf(nm float64) float64 { x, y, z := cie1931(nm) _, a, b := oklab(x, y, z) deg := math.Atan2(b, a) * 180 / math.Pi if deg < 0 { deg += 360 } return deg }
Three depths, one palette#
Cendre started with a rule that nothing readable may fall under 4.5:1, comments included. That rule was wrong, and measuring the dark themes people actually keep is what showed it: every one of them puts comments below that line, most around 3.3:1. A comment as loud as the code it explains is noise. Softness is partly low contrast, and a contrast floor applied without judgement removes it.
The variants move the ground and nothing else. That is deliberate: the pigments are the identity, and a theme whose screenshots don't look like each other never becomes anyone's default. Pick a depth at the top of the page and every number below is remeasured against it.
In the editor#
Diagnostics, git signs, diff and statusline draw from the semantic family. An error never wears the same red as a keyword, and the three diff tints below are the ones every terminal file carries.
- captures.go
- registry.go
- palette.lua
func LoadCaptures(path string) ([]Capture, error) { ▎ raw, err := os.ReadFile(path) ▎ raw, err := ioutil.ReadFile(path) ▎ var parsed []Capture ■ declared and not used: parsed return normalise(raw), nil ▲ prefer 'parsed' over re-reading 'raw' }
Specimen#
The same file in five languages, so the claim is testable rather than asserted: a role keeps its pigment whatever the syntax around it.
package registry import ( "encoding/json" "os" ) type Role uint8 const ( RoleKeyword Role = iota RoleString RoleType ) type Capture struct { Name string `json:"name"` Role Role `json:"role"` Weight float32 `json:"weight"` Enabled bool `json:"enabled"` } const MaxDepth = 24 // resolve a treesitter capture into a semantic role func LoadCaptures(path string) ([]Capture, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } var parsed []Capture if err := json.Unmarshal(raw, &parsed); err != nil { return nil, err } return parsed[:min(len(parsed), MaxDepth)], nil }
use std::fs; use std::path::Path; #[derive(Debug, Clone, Copy)] pub enum Role { Keyword, String, Type, } #[derive(Debug, Deserialize)] pub struct Capture { pub name: String, pub role: Role, pub weight: f32, pub enabled: bool, } const MAX_DEPTH: usize = 24; // resolve a treesitter capture into a semantic role pub fn load(path: &Path) -> Result<Vec<Capture>, Error> { let raw = fs::read_to_string(path)?; let parsed: Vec<Capture> = serde_json::from_str(&raw)?; Ok(parsed .into_iter() .filter(|c| c.weight > 0.0 && c.enabled) .take(MAX_DEPTH) .collect()) }
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> typedef enum { ROLE_KEYWORD, ROLE_STRING, ROLE_TYPE, } Role; typedef struct { const char *name; Role role; float weight; bool enabled; } Capture; #define MAX_DEPTH 24 /* resolve a treesitter capture into a semantic role */ int load_captures(const char *path, Capture **out) { FILE *f = fopen(path, "rb"); if (f == NULL) { return -1; } *out = calloc(MAX_DEPTH, sizeof(Capture)); if (*out == NULL) { fclose(f); return -1; } return parse(f, *out); }
const std = @import("std"); pub const Role = enum(u8) { keyword, string, type, }; pub const Capture = struct { name: []const u8, role: Role, weight: f32, enabled: bool, }; const max_depth: usize = 24; // resolve a treesitter capture into a semantic role pub fn load(alloc: std.mem.Allocator, path: []const u8) ![]Capture { const raw = try std.fs.cwd().readFileAlloc(alloc, path, 1 << 20); defer alloc.free(raw); var out = std.ArrayList(Capture).init(alloc); for (try parse(raw)) |c| { if (c.weight > 0 and c.enabled) { try out.append(c); } } return out.toOwnedSlice(); }
package registry import "core:encoding/json" import "core:os" Role :: enum u8 { Keyword, String, Type, } Capture :: struct { name: string, role: Role, weight: f32, enabled: bool, } MAX_DEPTH :: 24 // resolve a treesitter capture into a semantic role load :: proc(path: string) -> ([]Capture, bool) { raw, ok := os.read_entire_file(path) if !ok { return nil, false } parsed: []Capture if json.unmarshal(raw, &parsed) != nil { return nil, false } return parsed[:min(len(parsed), MAX_DEPTH)], true }
Whatever the language, a field or a parameter is orange, every literal
green, a type blue. A name someone declared, MaxDepth or MAX_DEPTH
or max_depth, is left in plain text: what a thing is called is not a role, and
only the value it holds takes a pigment.
Derivation#
A theme with a story usually has the story painted on afterwards. This one runs the other way. Every hue below was computed from a physical source in a burning log, using Planck's law for the coals and published emission wavelengths for everything else, then pushed through the CIE 1931 colour matching functions into OKLCH. Nothing was nudged toward a nicer number.
What we chose is lightness and chroma, because a spectral line is far outside sRGB and has to be brought into gamut anyway. Hue survived that trip intact; the last column is what actually shipped.
The computation is in the repository, a hundred lines with no data file and no dependency,
because those curves have a published analytic fit and OKLab is two matrix multiplies around
a cube root. nvim -l derive.lua prints the derived column below, and the test
suite holds this table to it. The fit is the approximation in the chain: its authors publish
it as within about one percent of the tabulated curves, which is worth up to a degree of hue
here.
| pigment | source | derived | shipped | drift |
|---|
Ground: wood-ash reflectance under a 1300 K illuminant gives hue 42.9°, less than a degree off the bare flame. A fire is generous with warm hues and stingy about spacing them, which is why cinder, ember and brass sit 17.9° and 17.6° apart instead of the 54° an unconstrained palette would take. That is the cost of deriving rather than choosing.
Pigments#
Three families, at the depth you have selected: one ground hue at seven steps, four levels of ink shared by every depth, and five pigments in order of lightness.
Geometry#
hue × chroma angle = hue, radius = chroma
arc , smallest gap
ground hue 43°, hard
| swatch | role | hex | hue | ratio |
|---|
| swatch | token | hex | hue | L | chroma | ratio |
|---|
Rules it obeys#
Six rules, and the measurement each one answers to. The sections above are the evidence; this is the summary.
| number | rule | measured |
|---|---|---|
| 1 | Hue comes from the source, separation is bought with lightness | cinder, ember and brass 17.9° and 17.6° apart, so 0.089 and 0.084 of lightness carry them |
| 2 | Lightness follows the temperature of the source | 0.238 spread, brass L 0.838 down to frost L 0.600. Normalising it gave 0.14 |
| 3 | Orange does syntax work | ember on properties, fields and parameters, not reserved for the cursor |
| 4 | Punctuation is not a token | operators, commas and brackets on fg_dim at L 0.670, under the body text |
| 5 | Diagnostics are their own family | chroma 0.125 to 0.160 over the pigments' 0.072 to 0.115, and 0.08 minimum in OKLab from the nearest. An earlier set had 0.036 |
| 6 | Contrast is chosen, not maximised | comments at 3.32:1 on purpose, measured against the ground you selected |
Nothing in the theme depends on bold or italic. Every row above is
asserted in test/smoke.lua, not just written here.
Install#
The palette below is generated at the depth you have selected. Italics off, and the background painted rather than left transparent. The ash bed is a colour the theme chose, so it may as well be on screen.
Everywhere else#
The editor is where it starts, not where it stops.
Every file under extras/ is rendered from the same palette module the
colorscheme reads, so a colour cannot be right in Neovim and stale in a terminal.
Each one ships at all three depths: the command below is the default, and
-medium or -soft sits beside it.
Click a command to copy it.
Inside the editor, these carry groups of their own rather than whatever their defaults land on: gitsigns, blink.cmp, Snacks, fzf-lua, which-key, Noice, neo-tree, nvim-tree, netrw, flash, trouble, lazy.nvim, mason, nvim-dap and dap-ui, neotest, mini.indentscope, mini.hipatterns, hlchunk, grug-far, diffview, illuminate, treesitter-context and rainbow-delimiters.
| surface | lands in | command |
|---|
Obsidian is in the community store: Appearance, Browse,
search cendre. The store carries one theme, so the command above is still how
you get -medium and -soft into a vault. Zed is in the extension
registry: Extensions, search cendre. One extension carries all three
depths there, so the command above is only for installing from a checkout.
KDE Plasma is a colour scheme, not a Plasma theme. Breeze window decorations and the Breeze Plasma style both read the scheme that is active, so cendre reaches the titlebar and the panel with nothing else installed. Klassy reads it too, if you want a rounder button without giving up the palette. A decoration or style that carries its own colours keeps them, which is why one of those next to cendre reads as two themes at once.
Get cendre#
Nothing above was chosen. Every hue came out of a fire, every ratio is measured, every file generated from one module. What is left is three lines in your config.
Neovim 0.9 or newer, with termguicolors.
:help cendre once it is installed.