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.
| 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.
| surface | lands in | command |
|---|
Zed and Obsidian are local installs for now. Both have a theme store, and neither listing exists until the submission is through, so the page is not going to pretend otherwise.
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.