cendre
Background
starsongithub

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.

see it in an editor

derive.gogo
// 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'
 }
NORMAL captures.go main ■ 1▲ 1◆ 2 go31:22

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.

Hue in, hue out
pigmentsource derivedshippeddrift

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.

ground · hue 43°
    ink
      pigments

        Geometry

        hue × chroma angle = hue, radius = chroma

        radius = chroma, outer ring = 0.15
        arc , smallest gap

        ground hue 43°, hard

          L 0.156L 0.440
          Semantic family
          swatchrole hexhueratio
          Full palette · ratios against the editor ground
          swatchtoken hexhueL chromaratio

          Rules it obeys

          Six rules, and the measurement each one answers to. The sections above are the evidence; this is the summary.

          The rule, and the number behind it
          number rulemeasured
          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.

          lua/cendre/palette.lua
          
              
          lua/plugins/colorscheme.lua
          
              
          ~/.config/ghostty/themes/cendre
          
              

          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.

          26 surfaces · every file generated, never hand-edited
          surfacelands incommand

          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.

          github

          Neovim 0.9 or newer, with termguicolors. :help cendre once it is installed.