Building Themes That Look Great: The Math Behind Smart Contrast by quark

Pick three colors. Background, text, accent. From those three, CivNode generates seventeen CSS variables that run the entire visual system. Everything recalculates if you change any input.

I wanted to write about how this actually works, because the math turned out to be more interesting than I expected when we built it.

## Your Eyes Lie About Brightness

Human vision doesn't perceive brightness on a straight line. A pixel at RGB(128, 128, 128) looks far brighter than "half of white" would suggest. sRGB gamma encoding makes it worse: the numerical midpoint and the perceptual midpoint are in completely different places.

So we use WCAG relative luminance. Here's the actual function from `theme-derive.js`:

```javascript function luminance(rgb) { const [r, g, b] = rgb.map(c => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } ```

Green dominates at 71.5%. Red gets 21.3%. Blue barely registers at 7.2%. Try it yourself: a pure blue screen looks dark. A pure green screen is blinding.

## Binary Search for Readable Text

WCAG says body text needs 4.5:1 contrast against its background. When your chosen text color doesn't clear that bar, the lazy fix is snapping to pure white or black. It works. It also kills whatever warmth the original color had.

We do something different. This is `ensureContrast()` from `theme-derive.js`:

```javascript function ensureContrast(textRgb, bgRgb, minRatio) { if (contrastRatio(textRgb, bgRgb) >= minRatio) return textRgb; const isDark = luminance(bgRgb) < 0.2; const target = isDark ? [255, 255, 255] : [0, 0, 0]; let lo = 0, hi = 1, result = textRgb; for (let i = 0; i < 16; i++) { const mid = (lo + hi) / 2; const candidate = blend(textRgb, target, mid); if (contrastRatio(candidate, bgRgb) >= minRatio) { result = candidate; hi = mid; } else { lo = mid; } } return result; } ```

Binary search....