Korean temples and palaces have been painted with the same mineral pigment
system — dancheong (단청) — for about 1,500 years. Deep indigo woodwork,
cinnabar red, ochre gold. Mineral pigments read vivid but never neon, which
turns out to be exactly what you want to stare at for eight hours a day.
I turned that palette into a theme family for VS Code and 12 other apps.
This post is about the part I actually think is worth stealing: the build
refuses to ship any color that fails a WCAG contrast target.
The problem with
picking colors by eye
Every theme author has done this: nudge a hex value until it "looks right"
on your monitor, at your brightness, in your lighting. Then someone opens it
on a glossy laptop in daylight and your comments are invisible.
Contrast is measurable, so I made it a build gate instead of a taste call.
One palette file, thirteen apps
The entire theme family lives in a single palette.json. Variants (Night,
Dawn, Storm, Mist) each define background, foreground tiers, and accent roles.
A build script generates every port from it — VS Code, JetBrains, Neovim,
Sublime, Obsidian, iTerm2, Alacritty, Kitty, Ghostty, Windows Terminal, Warp,
tmux, Zed. 4 variants × 13 apps = 52 files, byte-identical colors everywhere.
Change one hex value, run the build, and every port updates. No drift.
The gate
WCAG contrast is ~20 lines of Python, straight from the spec:
def rel_lum(h: str) -> float:
def f(c: float) -> float:
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = (f(c) for c in hex_to_rgb(h))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast(a: str, b: str) -> float:
la, lb = rel_lum(a), rel_lum(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
Every text role is checked against every background it actually ships
on — the editor, the sidebar, inputs, the terminal, and the selection
highlight. 222 pairs per build:
pairs = [("body/base", v.fg["base"], bg["base"], 7.0)] # AAA
for b in ("surface", "overlay", "highlight", "selection"):
pairs.append((f"body/{b}", v.fg["base"], bg[b], 4.5)) # AA
for b in ("base", "surface", "overlay"):
pairs.append((f"muted/{b}", v.fg["muted"], bg[b], 4.5)) # UI text
pairs.append(("comment/base", v.fg["subtle"], bg["base"], 4.5)) # AA
pairs.append(("comment/sel", v.fg["subtle"], bg["selection"], 3.0))
for name, color in v.accent.items(): # syntax
pairs.append((f"{name}/base", color, bg["base"], 4.5))
pairs.append((f"{name}/sel", color, bg["selection"], 3.0))
# … plus ANSI colors, diagnostics, git decorations, the cursor
for label, fg_c, bg_c, need in pairs:
if contrast(fg_c, bg_c) < need:
problems.append(f"{label} below {need}:1")
If problems is non-empty, the build exits. There is no --force flag on
purpose. A theme that fails contrast isn't "stylistically different" — it's
broken for someone.
Confession: v1 of this gate had holes
The first version of the gate checked 11 colors per variant — against one
background. It said "all OK" while the shipped theme had line numbers at
1.83:1 and comments that vanished to 1.9:1 the moment you selected them.
The selection highlight is a background; so is the sidebar; so is the
input field. A gate that only knows about bg.base is a gate with a
one-pixel view of your theme.
An adversarial audit (I pointed review agents at my own product and told
them to break it) found 19 shipped pairs the old gate never saw. Expanding
the gate to all 222 real pairs forced a palette retune — comments moved up
to AA (4.5:1), selection backgrounds got darker so text stays ≥3:1 while
selected. The lesson: a gate is only as honest as its pair inventory.
Tuning by binary search, not by eye
Here's the inversion that made the palette coherent: instead of picking a
color and checking its contrast, pick the contrast and solve for the color.
Take the hue and saturation from the pigment reference, then binary-search
lightness until the pair lands on the target ratio (e.g. exactly 7:1 for body
text). Every variant's colors sit at chosen contrast levels instead of
accidental ones.
This caught a real design bug: the first pass of Storm (the high-contrast
variant) produced accent colors darker than the regular dark variant,
because I'd reused the same 4.5:1 target. High contrast has to mean a higher
target — raising it to 7.5:1 fixed the whole variant in one rebuild. Storm's
body text now measures 14.6:1.
What I'd tell other theme authors
- Make contrast a build gate, not a review comment. The math is 20 lines.
- Enumerate every background text can sit on — selection, sidebar, inputs, terminal. Checking against just the editor background is how invisible line numbers ship with a green checkmark.
- Solve for lightness given a contrast target — hue and saturation carry the identity; lightness is a free variable.
- Generate every port from one source file. Hand-synced ports always drift.
- Different contrast tiers are a feature. Comments at 4.5:1 stay quieter than 7:1 body text but never disappear — not even mid-selection.
Links
- GitHub (MIT — VS Code extension + terminal ports): https://github.com/art220/dancheong
- VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=dancheong.dancheong-theme
- All-13-apps PRO bundle: https://dancheong.gumroad.com/l/dancheong-pro
It's week one. If a color fails on your setup, open an issue — the fix is one
palette edit and a rebuild away.
Top comments (0)