I generate dark themes — for terminals, editors, Obsidian, templates. I had just shipped fifty Obsidian themes, all built from the same palette file, all looking fine. Then I wrote a small contrast checker to validate them.
It failed four colours in all fifty themes. Faint text, code comments, checkbox borders, and in about forty of them the red used for errors. None of it looked wrong. All of it was below the floor.
My first pass did the obvious thing: take every colour in the theme, compute its contrast against the background, flag anything under 4.5:1. Running it on a good theme produced this:
FAIL Deep Field/theme.css (63 colours vs --background-primary-alt)
✗ --background-modifier-border 1.75:1 needs 4.5
✗ --background-modifier-hover 1.37:1 needs 4.5
✗ --background-secondary 1.12:1 needs 4.5
✗ --code-background 1.12:1 needs 4.5
✗ --checkbox-border-color 2.97:1 needs 4.5
… 9 more
Most of those "failures" are backgrounds. Asking whether a surface has enough contrast against another surface, at a text threshold, is not a question with a meaningful answer. The two real problems in that list were buried under nine that weren't problems at all.
A check that cries wolf gets switched off. That is the failure mode worth avoiding.
WCAG does not have one threshold. It has several, and they apply to different things:
| Role | Floor | Why |
|---|---|---|
| body text | 7:1 | WCAG AAA. Prose you read for minutes at a time. |
| accents, links, syntax | 4.5:1 | WCAG AA. Still text, read in shorter bursts. |
| comments, muted, faint | 3.5:1 | Deliberately secondary, but still has to be readable. |
| borders, icons, checkboxes | 3:1 | WCAG 1.4.11, non-text contrast. |
| backgrounds, surfaces, hovers | skipped | Nothing to compare against. |
Classify first, then check. The same fifty themes, checked this way, produced four real failures each instead of a wall of noise — and those four were worth fixing.
The failures had two causes. Some were mine: I derived faint text as
mix(muted, background, 0.45), which is simply too dark. Some were inherited: several
palette reds sit below 4.5:1 on a dark background, and always did.
You do not need to redesign a palette to fix either. You need each colour to clear its own floor against its own background — so blend it toward white until it does:
def lift(colour, bg, target):
if contrast(colour, bg) >= target:
return colour
toward = WHITE if luminance(bg) < 0.5 else BLACK
for i in range(1, 101):
cand = mix(colour, toward, i / 100)
if contrast(cand, bg) >= target:
return cand
return cand # as close as this hue can get
Hue survives. Character survives. The colour moves just far enough to be readable and stops. Run it at build time and a failing colour cannot ship.
After rebuilding all fifty with lifting applied: 1,952 colours checked, zero below floor.
--st-raised — an elevated surface, not
text. My skip list covered surface and background but not
raised, card, panel or sunken. The tool
was wrong, not the CSS. Using a checker on new work is how you find the checker's blind spots.
One Python file, no dependencies, exits non-zero in CI. Reads CSS custom properties, VS Code themes, palette JSON, TOML, btop themes and shell files.