White or black? Deriving readable label text from any brand colour
Operators using our hosted sign-in page can set a brand colour. The validator checked that the value was six hex digits, which is all it was ever asked to check, and the page rendered color:#fff over background:var(--cp-brand).
Set your brand colour to #ffd700 and the label on the primary button ships at 1.40:1 against its fill. WCAG AA asks for 4.5:1 on normal-sized text. Our e2e fixture uses #3366cc, which lands at 5.4:1, so every screenshot we had ever taken passed.
We looked at rejecting light colours at authoring time and at darkening the fill until the label passed. Both are worse than the bug. The first turns a format validator into a policy engine that has to explain itself in a form error, and the second silently repaints a customer's brand. Keeping the fill and deriving the ink from it turns out to be provably correct anyway, so neither is necessary.
The derivation
Compute the fill's relative luminance L, then take the higher-contrast of pure white and pure black:
contrast(white, L) = 1.05 / (L + 0.05)
contrast(black, L) = (L + 0.05) / 0.05
One falls as L rises and the other climbs, so the worst case for taking the better of the two is where they cross:
1.05 / (L + 0.05) = (L + 0.05) / 0.05
(L + 0.05)² = 1.05 × 0.05
L + 0.05 = √0.0525 ≈ 0.22913
Both ratios come out at 4.5826:1 there, which clears AA for every valid sRGB fill under the WCAG formula. #ffd700 gets black at 14.97:1, #3366cc gets white at 5.37:1.
The part that surprised me is how little slack that leaves. Pure black looks harsh next to a soft brand fill and the obvious move is to reach for the neutral already in your token file, which for us is #111827. Run it through the same algebra and the worst case drops to about 4.13:1. It only fails across a band of mid-luminance fills, so a team whose own brand sits outside that band will never see it, and neither will their fixtures. There is a comment in our source saying not to substitute a softer ink, with the number, because somebody was going to try.
fn channel_to_linear(c: u8) -> f64 {
let c = f64::from(c) / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn relative_luminance(hex: &str) -> f64 {
let byte = |range: std::ops::Range<usize>| {
hex.get(range)
.and_then(|s| u8::from_str_radix(s, 16).ok())
.unwrap_or(0)
};
0.2126 * channel_to_linear(byte(1..3))
+ 0.7152 * channel_to_linear(byte(3..5))
+ 0.0722 * channel_to_linear(byte(5..7))
}
fn ink_for_fill(hex: &str) -> &'static str {
let l = relative_luminance(hex);
if 1.05 / (l + 0.05) >= (l + 0.05) / 0.05 {
"#ffffff"
} else {
"#000000"
}
}
The sRGB breakpoint is 0.04045. Plenty of copies of this snippet use 0.03928, which was the older value; no 8-bit channel falls between the two so it changes no output. The unwrap_or(0) is there so the function can't panic, not as a safety property. What makes it safe is that the caller only ever passes a BrandColor that has already been validated.
The focus ring
Fixing the button surfaced something worse. The focus indicator on the email and code inputs was derived from the brand colour too, sitting on top of outline: none. At the light end of the range a credential field had no visible focus ring at all, which means a keyboard user typing their sign-in code could not see where they were.
That one is not solvable by choosing between two constants, because the ring sits on the card background rather than on the brand fill, so the luminance argument above doesn't reach it. It got a floor keyed to our own text token instead, measuring between 13:1 and 17.7:1 against the card and the page background in both light and dark. The brand tint survives as the input's border colour and a soft halo. It is decoration now and never the only signal.
BrandColor was never the problem, incidentally. It validates format, which is all it should know about. The renderer took that correct value and paired it with a hardcoded #fff that happened to work for the colours in our fixtures.
This is one of the fixes behind Timonier's hosted sign-in page, which operators can brand with their own logo and colour. Whatever colour they pick, the label on it holds WCAG AA.