Symptom
A light theme people like, ported to dark, feels visually noisy: cards, inputs, and boxes have outlines that "stick out" everywhere. It reads as boxy/busy even though the layout is identical to the light theme that feels calm.
Root cause
WCAG contrast is a ratio of relative luminances, not a lightness difference. The same HSL lightness offset (ΔL) that produces a soft divider on a light ground produces a screaming line on a dark ground, because the ratio is computed near the dark end of the luminance curve where equal steps are proportionally huge.
Concrete measured example (HSL, contrast = border vs. its background):
| Light | Dark (naive port) | |
|---|---|---|
| background L | 97% | 10% |
| soft border L | 85% (ΔL 12) | 40% (ΔL 30) |
| contrast | 1.32:1 (a whisper) | 3.06:1 (an alarm) |
3:1 is the WCAG floor for intentional non-text UI (focus rings, icon glyphs, control boundaries). A generic divider or card outline at 3:1 therefore reads with the same visual weight as a focus indicator — that is the "loud borders" feeling. The naive dark port kept a bigger ΔL (30 vs 12) trying to stay "visible," which made it worse.
A second, compounding trap: on a dark ground, box-shadows are nearly invisible, so surfaces stop separating by elevation. Whoever tunes it can't see the cards and cranks the outline instead of lifting the fill — encoding separation in the one channel that now shouts.
Fix
- Match contrast ratios, not lightness deltas. Decide the ratios you want (e.g. soft divider ~1.3:1, firm control edge ~1.6:1), then solve for the dark lightness that yields each ratio against the dark background. For a 10%-L ground that lands soft borders near L18-19 and firm edges near L24 — a much smaller ΔL than light uses, not a bigger one.
- Separate surfaces by fill elevation, not by hot outlines. Lift card/panel background a few L points above the page so the box reads from its fill; then the border can be soft (or transparent) because it is no longer doing the separation work alone.
- Keep the tier system consistent across schemes. If light defines two border weights (soft == firm-minus), reproduce exactly those two weights in dark. A common regression is dark inventing extra, hotter weights that the light scheme never had.
Compute it, don't eyeball it
import colorsys
def rel_lum(h,s,l):
r,g,b = colorsys.hls_to_rgb(h/360, l/100, s/100)
f = lambda c: c/12.92 if c<=0.03928 else ((c+0.055)/1.055)**2.4
R,G,B = map(f,(r,g,b)); return 0.2126*R+0.7152*G+0.0722*B
def contrast(a,b):
L1,L2=rel_lum(*a),rel_lum(*b); hi,lo=max(L1,L2),min(L1,L2)
return (hi+0.05)/(lo+0.05)
# solve dark border L for a target ratio against bg L=10:
bg=(40,5,10)
for L in range(14,42,2): print(L, round(contrast((30,4,L),bg),3))Verify the two schemes as a pair (both public states), not light-then-dark in isolation.