README.md

August 6, 2026 · View on GitHub

cendre

Dark colorscheme for Neovim. One wood fire, taken apart.

Every pigment hue was computed from something measurable in a burning log: Planck's law for the coals, published emission wavelengths for everything else, pushed through the CIE 1931 colour matching functions into OKLCH. Lightness and chroma are chosen, because a spectral line sits far outside sRGB and has to be brought into gamut anyway. Nothing was nudged toward a nicer number.

Dark only, on purpose. Every hue, ratio and surface is laid out at cendretheme.com.

{ "Aejkatappaja/cendre" }

Neovim 0.9 or newer, with termguicolors.

Go code in cendre, with git signs, diagnostics and a statusline

Diagnostics, git signs, diff and statusline draw from the semantic family. An error never wears the same red as a keyword.

29 surfaces, one palette

Neovim is where this starts, not where it stops. Ghostty, kitty, WezTerm, foot, Alacritty, Konsole, tmux, bat, delta, lazygit, Yazi, eza, fzf, btop, Starship, Helix, Zed, Obsidian, Firefox, KDE Plasma and the rest, with the path each one installs to: cendretheme.com/#surfaces.

Nothing under extras/ or assets/ is written by hand, nor the favicon and share card under docs/. All of it is rendered from lua/cendre/palette.lua, so a colour cannot be right in the editor and stale in a terminal, in this README, or on the site:

nvim --headless --noplugin -u NONE -c "set rtp+=." -c "luafile scripts/extras.lua" -c q

Every surface ships at all three depths. A reader on soft wants soft in their terminal too, so each directory holds cendre, cendre-medium and cendre-soft. The unsuffixed name is the depth the plugin defaults to, so the editor and everything around it match with no second decision.

Anything that stores a theme name rather than taking it from its filename gets that name qualified per depth, or installing two of them collides on one entry in Zed, bat, Obsidian and the rest. The test asserts that.

Ghostty, for example:

theme = cendre

Inside the editor, these get 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.

The 16 ANSI slots are also exported as vim.g.terminal_color_*, so :terminal matches. Slot 5 is potassium's 404 nm line, derived the same way as the pigments, and it exists because ANSI wants six hues while the editor needs five.

Three depths, one palette

The pigments are the identity, so they never move. Only the ground under them does, at three depths. A screenshot at one depth is recognisably the same theme as a screenshot at another, which is the whole point.

depthbg0Ltextcommentdimmest pigment
hard#1713110.19112.89:13.32:14.77:1
medium#1d19170.21712.18:13.14:14.51:1
soft#231f1d0.24311.41:12.94:14.22:1

hard is the default. It is deeper than any theme this was measured against, and is the reason the ramp above it has to be finely stepped. soft is lifted to where the dark themes people keep for years actually sit.

Switch at runtime, no restart:

:CendreBackground soft
:CendreBackground hard

Install

lazy.nvim:

{
  "Aejkatappaja/cendre",
  lazy = false,
  priority = 1000,
  config = function()
    require("cendre").setup({
      background = "hard", -- "hard" | "medium" | "soft"
      italic_virtual_text = false,
    })
  end,
},
{
  "LazyVim/LazyVim",
  opts = { colorscheme = "cendre" },
}

Without LazyVim, just vim.cmd.colorscheme("cendre") after setup.

lualine picks it up with options.theme = "cendre", or leave "auto".

:help cendre covers the options, the commands and the role map in full.

Options

optiondefaulteffect
background"hard"ground depth: hard, medium or soft
transparentfalsestrips Normal, floats and the statusline; plugin windows follow
dim_inactivefalsethe window you are not in drops to bg_deep
italic_virtual_textfalseitalics on inlay hints, ghost text, git blame, the dashboard footer
italic_commentstrueitalics on comments, independently of the above
on_colorsnoopfunction(colors) mutates the palette before highlights
on_highlightsnoopfunction(highlights, colors) gets the last word

The background is painted by default. The ash bed is a colour the theme derived, so it may as well be on screen.

dim_inactive reuses bg_deep, the ground floats and sidebars already stand on, rather than inventing a shade for it: the theme already says "not the buffer you are editing" with that colour. The trade is that a file tree and a background split then share one ground, which is why it is off by default. SignColumn and FoldColumn stay at editor brightness regardless, since Neovim has no inactive variant for either.

The two italic switches are independent, not one overriding the other, so italic_virtual_text = false on its own leaves comments italic. Set both to false for no italics. italic is the old name for italic_virtual_text, kept as an alias that warns, and removed in 2.0. Italic and @markup.italic are outside both, because italic is their entire definition: strip it and *emphasis* renders as body text.

With transparent = true, plugin windows go through too, without being listed one by one: which-key, the Snacks picker, Noice and fzf-lua link their windows to NormalFloat, so stripping that strips all of them, including plugins released after this README. The completion menu is the exception and stays painted, since a transparent popup over code is not readable. That covers Pmenu and blink.cmp's own windows, which read neither Pmenu nor NormalFloat.

require("cendre").setup({
  on_highlights = function(hl, c)
    hl.Comment = { fg = c.bg5 }
  end,
})

The cursor stays your terminal's

Cursor, lCursor, CursorIM and TermCursor are all set to ember, and outside :terminal Neovim never asks for them. Its default 'guicursor' names a highlight group only for terminal mode:

n-v-c-sm:block,i-ci-ve:ver25,r-cr-o:hor20,t:block-blinkon500-blinkoff500-TermCursor

With no group on the other parts, the terminal draws its own cursor in its own colour. So a black cursor on this theme is your terminal's setting, not a missing highlight.

The theme does not set 'guicursor', because that option carries the cursor's shape and blink rather than its colour, and those are yours.

Two ways to change it. Install the terminal theme for whatever you run, and the cursor matches in the editor and in the shell, since every file under extras/ sets it. Or hand the cursor to Neovim by naming the group on the other modes:

vim.opt.guicursor = "n-v-c-sm:block-Cursor,i-ci-ve:ver25-Cursor,"
  .. "r-cr-o:hor20-Cursor,t:block-blinkon500-blinkoff500-TermCursor"

Palette

Ground, hue 43°, which is wood ash under a 1300 K flame. Ash is spectrally flat, so it lands within a degree of the bare flame itself. Shown at hard, the default:

tokenhexLuse
bg_deep#0f0c0a0.156floats, sidebars
bg0#1713110.191editor
bg1#201b190.227cursorline
bg2#2a24220.266statusline
bg3#362f2c0.312splits
bg4#463e3a0.371hover
bg5#5a504c0.440float edges

Ink, shared by every depth. Ratios against bg0 at hard:

tokenhexratiouse
fg#e6d5c212.89:1body text
fg_dim#a093846.15:1operators, delimiters
comment#73665b3.32:1comments
gutter#4e46412.00:1line numbers, inlay

comment is deliberately under AA. Every dark theme people keep for years puts comments below that line, most around 3.3:1, and a comment as loud as the code it explains is noise.

The five pigments, in order of lightness, which is also order of distance from the fire:

namehueLsourcerole
brass61.4°0.838sodium D 589 nmfunctions, methods, calls
ember43.8°0.754blackbody 1300 Kproperties, fields, params, UI
sap123.4°0.721C₂ Swan 563 nmevery literal value
cinder25.9°0.665CaOH band 622 nmkeywords, control flow, storage
frost227.4°0.600C₂ Swan 474 nmtypes, classes, constructors

Arc 202°, smallest gap 17.6°, chroma 0.072 to 0.115. A fire does not offer well-spaced warm hues: cinder, ember and brass sit within 36° of each other because that is where calcium, soot and sodium actually are. They stay legible on lightness instead, which is also why brass is the brightest thing on screen and frost the dimmest.

Semantic colours (error warn ok hint info) are a separate family, all carrying more chroma than any pigment, so a diagnostic never wears the same red as a keyword. Chroma is held inside 0.125 to 0.160: unbounded, the optimiser that placed them produced neon.

Each hue traced from its spectrum to its hex: cendretheme.com/#derivation.

Rules it obeys

  1. Hue comes from the source, separation is bought with lightness.
  2. One role, one pigment. No token depends on bold or italic.
  3. A declared name is not a role. Variables and constant names stay in fg, and only the value a constant holds takes a pigment.
  4. Punctuation is not a token. Operators, commas and brackets take fg_dim.
  5. Diagnostics are their own family, separate from the pigments.
  6. Nothing readable under 4.5:1, with three exceptions that are stated rather than hidden: comment everywhere, and frost and error on soft.

Test

nvim --headless --noplugin -u NONE -c "set rtp+=." -c "luafile test/smoke.lua"

Loads every depth through nvim_set_hl, which throws on a missing palette key, then asserts that the switches behave, that :CendreBackground swaps the live ground and rejects junk, that the pigments never drift between depths, that no pigment collides with a diagnostic, that every published contrast ratio is what the code actually measures, that the role map lands where this README says, that no readable token leans on bold or italic, and that every committed file under extras/, assets/ and docs/ matches a fresh render of the palette. Exits non-zero on failure.

Discussions

Four threads are open, and none of them is a bug report:

Licence

MIT.