↓ Skip to main content

The SP Night spec

Documented version: v1.4.0

This document exists to answer one question: when I port the theme to a new platform, which colour goes where? Without a written answer every port becomes a different interpretation of the theme — that is how the Dracula ports drifted apart from each other.

The same rules are published at sp-night.github.io/spec. This copy is the one the tool enforces.

The three layers
#

palette/sp_night.json     23 colours per flavour. São Paulo names.
        ↓
palette/roles.json        semantic roles → a palette key
        ↓
<app>.tmpl                asks for a role, never the raw palette

Hard rule: a mapping never references a palette colour directly.

✗  palette = 4={{ .C.marginal }}
✓  palette = 4={{ .R.ansi.blue }}

Why: moving syntax.keyword from marginal to temporal has to repaint Neovim, bat, fish and the website at once. A mapping with marginal written into it stays behind and nobody notices.

spn lint fails on the first form. It walks the parse tree, not the text, so a counter-example inside a comment is not a finding.

The exception is the variable lists — waybar.css, gtk.css, hyprland.conf — which publish the raw palette as @define-color / $var because the end user will want to write @sp_sodio in their own stylesheet. Those declare raw_palette: true in their frontmatter. Even there, everything the template itself styles still uses a role.

The palette
#

The four bands below are not headings in this document — they are the groups block of sp_night.json, and they partition the palette: every colour belongs to exactly one band, and the tool refuses a palette where one is missing, doubled or unknown.

They are data rather than Go because the website vendors the contract and publishes it. When the partition lived in the tool, the site had to re-type it, and a colour the palette declared but the site’s copy did not know about disappeared from the published palette with nothing failing anywhere. accents and vivo are the two bands the audit addresses by name; the rest of a band is its label and its members.

Surfaces — names neutral about lightness
#

These names describe function and depth, not a specific hex. That is what lets one mapping serve every flavour.

keyfunctionreference
vaothe deepest recess: float, popup, tab barthe free span of the MASP
lajethe default background — the plane everything sits ona concrete slab
concretopanels, cards, cursorline, statuslineexposed concrete
vidroselection, visual mode, active itemglass reflecting the street
fiacaoborders, dividers, indent guidesoverhead wiring

Text
#

keyuse
fg_vivobold default text, ansi.bright_white
fgmain text, identifiers, variables
fg_dimcomments, punctuation, secondary text
fg_mutedline numbers, guides, disabled text

Accents
#

keyANSIreference
brasaredthe MASP, brake lights
sodio—the sodium street lamp
taxiyellowtraffic light, taxi
ibiragreenIbirapuera, a green light
estaiada—the Estaiada bridge lit up
serenocyanthe dew before dawn
marginalbluethe Marginal, the metro
temporalmagentathe sky just before the rain

Bright ANSI
#

The six terminal brights (brasa_vivo, taxi_vivo, ibira_vivo, sereno_vivo, marginal_vivo, temporal_vivo) are the same colour with +0.06 Oklch lightness — hue and chroma untouched. Bold text in a terminal uses bright; if bright equals normal, that information disappears. They live in the palette rather than being derived in a template, so they pass the same contrast audit as everything else.

fg_vivo is that same lift applied to the text ramp, and it exists for the same reason. A target that separates default text from bold default text — Alacritty’s colors.primary.bright_foreground, kitty’s bold font colour — had nothing above ui.fg to point at, so a mapping had to write fg into both keys and claim a lift that was not there. ui.fg_bright names it, and ansi.bright_white resolves to it. ansi.white stays fg_dim: the ANSI table keeps the two ends of the ramp, and fg remains the terminal’s plain foreground.

For the text ramp +0.06 is the ceiling, not an inherited constant. fg in noite already sits at Oklch L 0.88; at +0.07 the blue channel clips and chroma starts to fall (0.028 → 0.023). At +0.06 all three flavours land the lift exactly with chroma intact — measured, which is what decided the value.

sodio is the signature colour: cursor, active border, active workspace, clock, title. If a port needs “the theme’s colour”, that is it — in a terminal or a bar. In an application widget (selection and focus in GTK/Qt/KDE) the accent is ui.accent_alt, blue: an orange system accent makes a whole file manager look like a warning. Tokyo Dark makes the same split, and its primary was always blue.

sodio and estaiada have no ANSI slot — the 16 slots already have owners. They exist for the interface, not the terminal.

Assignment rules
#

Syntax — the logic is: what the code does gets blue-cyan, what the code is gets purple-orange, literal data gets green-orange.

rolecolour
keyword, conditional, repeatmarginal
function, method, operatorsereno
type, namespacetemporal
constant, number, booleansodio
string, characteribira
parameter, macro, escapeestaiada
builtin, attributetaxi
tagbrasa
variable, property, fieldfg
comment, punctuationfg_dim

Variables stay fg on purpose. A theme where everything is coloured has no hierarchy — the eye needs a rest, and the ordinary identifier is the largest volume of text on screen.

Diagnostics and git follow the universal convention and must not be reinterpreted: error = red, warning = yellow, info = blue, hint = cyan, ok/added = green, modified = yellow, removed = red.

A diff has one pattern, in every target: added = ibira, modified and renamed = taxi, removed and conflict = brasa. All the structure — hunk, header, index, untracked — is neutral (fg_dim / fg_muted). A diff that also uses purple, blue and orange becomes a rainbow where only three states matter.

Surface temperature
#

Blue-yellow opposition is the strongest axis a theme has, and in a dark theme it does not get resolved in the accents: blue has intrinsically low luminance, so a blue that clears AA on concreto has to be light and desaturated — the opposite of a pole. Measured, repainting the accents moves the axis from 1.03 to 1.35 and costs the character of the whole palette.

It gets resolved in the background, which is not text and therefore has no contrast constraint. noite has a blue-violet laje at chroma 0.037; the accents stay warm. The tension between field and accent mass goes from 0.020 to 0.051 without changing a single accent.

Hence the split between the two dark flavours: noite is cold and saturated, garoa is flat grey (chroma 0.005). The garoa does not cool the city down, it washes it out — the distinction is saturation, not temperature.

The lesson of the robust themes (KDE)
#

What makes Breeze Dark and Catppuccin work in any app is not the palette — it is the discipline of the scheme:

  1. One single set of foregrounds, repeated literally across View, Window, Button, Tooltip, Complementary and Header. Nothing is derived per section; consistency is the absence of derivation.
  2. The Selection section is self-contained. Every foreground in it is chosen to read on the accent, and its BackgroundAlternate is a neighbour of the accent — never the general alternate. That substitution is what produced 1.11:1 in Dolphin.
  3. [WM] is dark and calm. A title bar never takes the accent.

And one none of them has: the tool parses the .colors file it has just built and fails the build if any fg/bg pair of any section falls below 4.5:1 (internal/audit/kde.go). The real Breeze has pairs at 2–3:1 in Selection; SP Night does not compile with one.

Contrast
#

spn check measures every text/surface pair of every flavour and fails the build when one falls short. The floor depends on what the surface is:

surfacefloorwhy
laje, vao, concreto4.5:1 (AA)where you read code for hours
vidro3.0:1selection: a transient state, you look at shape
fg_muted on any3.0:1, warningornament, not reading text
fiacao on laje/vao1.5:1, warninga border, not text

74 pairs per flavour. A gate that fails stops the build; a warning is reported.

fg_dim is the delicate case: comments need full AA on laje. An unreadable comment is the number-one issue of every popular dark theme, and always because nobody measured.

Separation between accents
#

Contrast against the background is not enough. Two accents can clear AA comfortably and still be confused with each other — that is how estaiada and sereno slipped past the first version of this audit: near-identical luminance on laje, separable by hue alone.

ΔE alone is not a usable threshold. In an eight-accent palette covering the hue circle, neighbouring hues land naturally at ΔE 0.07–0.09 in Oklab; demanding more of every pair would be demanding a palette with fewer colours. Measured across the flavours, the genuinely confusable pairs are not the ones with the smallest ΔE — they are the ones with the smallest ΔL.

The rule: a perceptually neighbouring pair (ΔE < 0.10) has to separate by lightness (ΔL ≥ 0.04). That is what keeps the two distinguishable in greyscale — and, for the same reason, for someone with a colour vision deficiency.

Colour vision deficiency
#

spn check -v also simulates protanopia, deuteranopia and tritanopia (Viénot 1999 matrices) and reports how many accent pairs sit too close for each.

This is a diagnostic, not a gate, for a concrete reason: separating eight accents under colour blindness, keeping the palette’s smoky character, and clearing AA on a dark background is an overdetermined system. A search over hue, chroma and lightness finds solutions with much better separation, but all of them get there by moving the theme somewhere else — yellow drops to olive, red becomes terracotta, the cyans hit the edge of the gamut and turn neon. No popular theme solves all three; Catppuccin, Tokyo Night and Dracula do not either.

The number exists so the choice is deliberate. If the priority ever changes, the tool already measures it.

About the ΔL rule
#

It has a blind spot worth knowing: it only examines pairs of neighbouring hue in normal vision. Under deuteranopia the pairs that collapse are the distant ones — red and green land in the same place. One does not cover the other, which is why both measurements coexist.

It is a warning, not a failure: these are the theme’s identity colours, and the decision to repaint them is the maintainer’s. The accents’ lightness ladder keeps the list empty today.

Run spn check before touching a colour.

Adding a colour
#

  1. Add it to colors in every flavour, and to meaning.
  2. Add it to the band it belongs to in groups.
  3. spn check, then go test ./....

Step 2 is not optional bookkeeping: a colour outside every band fails validation, because anything that walks the palette walks the bands. If it is text or an accent, decide the floor it has to clear — internal/audit names the keys it measures — and if it is a _vivo, it lifts its base by 0.06 Oklch lightness and the suite checks that it does.

Adding a flavour
#

  1. Add the block to palette/sp_night.json → flavors.
  2. spn check.

That is all — order comes from the JSON itself, so there is no second list in Go to keep in step.

A new flavour has to distinguish itself from the existing ones by hue, not only by lightness. garoa and noite are the example: the first version of garoa was noite with the blacks raised, and of ΔE 0.034 between the surfaces, 0.033 was ΔL — the same colour, lighter. Pulling the grey towards blue (Oklab B from −0.005 to −0.017) raised Δchroma 7× and dropped ΔL: they became two atmospheres rather than two brightnesses.

No mapping changes. If a mapping has to change to accommodate a new flavour, that is a sign it is using the raw palette where it should use a role.

Adding a target
#

  1. List it in registry/ports.yml.
  2. spn new <slug>.
  3. Write the mapping using .R.

The frontmatter accepts an empty matrix to render once — for an app that reads a single fixed config file. Formats that need a light block alongside the dark one mirror the dark side, since the theme is dark-only; .All reaches the other flavours: {{ (index .All "garoa").R.ui.bg }}.

For “text that reads on this accent”, use readable, which picks between two options by measured contrast instead of guessing:

"mOnPrimary": "{{ readable .C.vao .C.fg .R.ui.accent }}"

Helpers available in a mapping: nohash, rgb, rgbn, rgba, hexa, argb, sgrfg, sgrbg, mix, lighten, darken, contrast, readable, kebab, pad, repeat, tojson, upper, lower, r, g, b.