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. Sixteen iterations of bisection. It finds the smallest possible nudge toward white or black that passes WCAG. You get text that's readable but still carries the character of the color you chose. Three text levels come out of this, each targeting different WCAG thresholds: ```javascript const textPrimary = ensureContrast(textRgb, bgRgb, 7.0); // strictest AAA const textSecondary = ensureContrast(blend(textRgb, bgRgb, 0.35), bgSecondary, 4.5); const textMuted = ensureContrast(blend(textRgb, bgRgb, 0.60), bgSurface, 3.0); ``` ## Pearl and Ink This is the part that does the magic: ```javascript export function harmonicPalette(bgHex) { const hsl = hexToHSL(bgHex); return [ { name: 'Gold', hex: '#c9a84c' }, { name: 'Complement', hex: hslToHex((hsl.h + 180) % 360, clamp(hsl.s, 20, 55), clamp(hsl.l, 45, 70)) }, { name: 'Warm', hex: hslToHex((hsl.h + 35) % 360, clamp(hsl.s, 20, 50), clamp(hsl.l, 45, 65)) }, { name: 'Cool', hex: hslToHex((hsl.h + 325) % 360, clamp(hsl.s, 20, 50), clamp(hsl.l, 45, 65)) }, { name: 'Accent', hex: hslToHex((hsl.h + 120) % 360, clamp(hsl.s, 15, 40), clamp(hsl.l, 50, 70)) }, { name: 'Pearl', hex: hslToHex(hsl.h, 5, hsl.l > 50 ? 88 : 78) }, { name: 'Ink', hex: hslToHex(hsl.h, 8, hsl.l > 50 ? 18 : 28) }, ]; } ``` Pearl takes the background's hue, strips saturation to 5%, and pushes lightness way up. Ink does the opposite. Both carry a ghost of whatever color they sit on. Here's why that matters: (right click and open image in new tab to see a larger image)  Pure white text on a warm brown background fights the warmth. Pearl on that same background reads as off-white with the faintest brown tint. Your brain registers "readable" without the cold jolt of #ffffff. Same idea on the blue: Pearl picks up a barely-there blue tint instead of ignoring the surface. Here's how Pearl and Ink adapt across four different background hues:  ## Golden Angle Thread Colors When we need distinct colors for tags or threads, we use the golden angle: 137.508 degrees of hue rotation per step. Same geometry sunflower seeds use to pack efficiently. ```javascript export function threadColors(n, bgHex) { const bg = hexToHSL(bgHex); const isDark = bg.l < 50; const colors = []; const goldenAngle = 137.508; for (let i = 0; i < n; i++) { const hue = (bg.h + 60 + i * goldenAngle) % 360; const sat = clamp(35 + (i % 3) * 10, 25, 60); const lit = isDark ? clamp(55 + (i % 4) * 5, 50, 75) : clamp(40 - (i % 4) * 5, 25, 45); colors.push(hslToHex(hue, sat, lit)); } return colors; } ```  Consecutive colors stay maximally distant regardless of how many you generate. Five? Well-separated. Twelve? Still well-separated. The math doesn't degrade. ## Three Colors, Seventeen Variables The whole derivation from `deriveThemeVars()`:  Four background tones come from blending accent into background at 4%, 8%, 14%. Three text levels target WCAG 7.0, 4.5, and 3.0. An accent hover variant shifts 15% lighter or darker depending on whether the background is dark or light. Borders pull from the elevated background tone. Switch themes and every one of these seventeen values recalculates. Three choices carry enough information to derive an entire coherent palette. You can try this yourself: go to **Settings → Theme** and double-click any preset to customize it. The color pickers change three values. Everything else follows.