Engineering/July 29, 2026/5 min read
Two faces, one palette
Adding a dark theme to a near-monochrome site should be a palette swap. It was — but only after every literal colour in the codebase was made to admit it was a decision.
By GASPEROHLAB
Contents
This site is built on one idea: paper and ink, and a single red rationed to four places. That makes it an unusually good candidate for a dark theme — there is almost no colour to re-derive — and an unusually annoying one, because when a design has this few colours, the ones it does have get written inline without anyone noticing.
The tokens were already there. Background, foreground, muted, faint, border, accent: six variables doing nearly all the work. What wasn't tokenised were the tints — the two-percent black wash under a hovered row, the barely-there fill inside a text input, the flat black filter that flattens partner logos to a single ink value. Each of those is a colour decision wearing the costume of a utility class.
A tint is a decision, not a value
Two percent black over warm paper is a faint grey wash. Two percent black over a near-black sheet is nothing at all. The fix isn't to write a dark variant beside every one of them — it's to name what the value means and let the theme answer:
:root[data-theme="light"] {
--hover-tint: rgba(22, 22, 26, 0.022);
--logo-filter: brightness(0);
--grain-blend: multiply;
}
:root[data-theme="dark"] {
--hover-tint: rgba(236, 236, 239, 0.035);
--logo-filter: brightness(0) invert(1);
--grain-blend: screen;
}The grain layer is the clearest case. It's one fractal-noise SVG multiplied over the page to give the flat sheet some tooth. Multiply over near-black is very nearly a no-op, so the dark theme screens instead — and at a lower opacity, because texture is far louder against dark than against paper.
The accent had to split in two
The one genuinely hard part was the red. On paper, #c4302a clears AA as type and also works as a button fill under white text. On a dark sheet, no single red does both jobs: light enough to read as small text and it becomes far too weak a fill for white labels; dark enough to hold white text and it barely separates from the background.
So the dark theme has two reds. The fill sits at #cf3a33 — 4.9:1 under white text, 3.9:1 against the sheet, which clears both thresholds with nothing to spare. Small accent type gets #ff7a70, at 7.5:1. There was already an --accent-text token separate from --accent, added for a different reason entirely; it turned out to be exactly the seam the dark theme needed.
Every colour in the codebase is now a token or a mistake. There is no third category.
No flash, and no JavaScript required
Reading the stored theme in an effect means painting the wrong one first, which on a dark-theme page is a full white flash — worse than shipping no dark theme at all. So a small blocking script in the head stamps data-theme on the root element before the body paints. It's the one place an inline script genuinely earns its keep.
That script leaves readers without JavaScript unthemed, which is why none of this uses Tailwind's dark: variant. Everything resolves through custom properties instead, so a plain prefers-color-scheme block can serve those readers the correct face with no script involved at all. A utility-variant approach couldn't have done that — and the constraint made the implementation smaller, not larger.