← all demos

Every one of my 50 themes failed a contrast check

August 2026 · on measuring dark themes instead of squinting at them

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.

The first version of the checker was worse than useless

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.

What a colour is for decides how it should be checked

WCAG does not have one threshold. It has several, and they apply to different things:

RoleFloorWhy
body text7:1WCAG AAA. Prose you read for minutes at a time.
accents, links, syntax4.5:1WCAG AA. Still text, read in shorter bursts.
comments, muted, faint3.5:1Deliberately secondary, but still has to be readable.
borders, icons, checkboxes3:1WCAG 1.4.11, non-text contrast.
backgrounds, surfaces, hoversskippedNothing 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.

Fixing it: lift, don't redesign

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.

The part I did not expect. Later I ran the same checker over a component library I had written that morning, and it flagged --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.

Three things worth taking away

The checker is free

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.