Como eu confiro contraste em OKLCH
O script que converte os tokens do site para luminância sRGB e confere os limites de contraste do WCAG.
Neste post
A paleta do site foi montada em OKLCH. Esse espaço facilita o ajuste de luminosidade entre os tokens, mas não é o espaço usado pelo WCAG para calcular contraste.
Quando mudo uma cor, rodo um script que converte os valores para luminância sRGB e calcula a razão entre cada par importante. Assim eu consigo escolher a aparência em OKLCH e conferir a acessibilidade com a fórmula do WCAG.
Do OKLCH ao contraste do WCAG#
No OKLCH, o componente L representa luminosidade perceptual. Isso ajuda a manter uma paleta escura consistente, porque mover o L produz uma mudança visual mais previsível.
O critério 1.4.3 do WCAG usa outra medida: a luminância relativa em sRGB linear. Para texto AA, a razão precisa chegar a 4.5:1. O valor de L ajuda no design, mas não pode ser usado diretamente para provar que um par passa nesse limite.
O script faz quatro passos:
- OKLCH para OKLab: transforma croma e ângulo nos eixos a e b.
- OKLab para sRGB linear: aplica as constantes de Ottosson, passa pelos componentes LMS e chega a r, g, b.
- sRGB linear para luminância: a soma ponderada do WCAG,
0.2126 r + 0.7152 g + 0.0722 b. - Luminância para razão:
(hi + 0.05) / (lo + 0.05), com o maior dos dois em cima.
Conversão para sRGB linear#
A parte central do script é esta função:
function oklchToLinear(L, C, H) {
const a = C * Math.cos(H * DEG);
const b = C * Math.sin(H * DEG);
const lp = L + 0.3963377774 * a + 0.2158037573 * b;
const mp = L - 0.1055613458 * a - 0.0638541728 * b;
const sp = L - 0.0894841775 * a - 1.291485548 * b;
const l = lp * lp * lp;
const m = mp * mp * mp;
const s = sp * sp * sp;
return {
r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
};
}Os coeficientes foram publicados por Björn Ottosson, autor do OKLab, e fazem parte da definição do espaço.
O detalhe da curva de gama#
Os valores r, g, b devolvidos pela função já estão em sRGB linear. Esse detalhe
muda a forma de calcular a luminância.
Muitas implementações começam decodificando a gama de cada canal com
((c + 0.055) / 1.055) ^ 2.4. Isso é necessário quando a entrada está em sRGB
codificado, como um #rrggbb. Aqui a conversão de Ottosson já entregou valores
lineares. Repetir o decode aplicaria a correção duas vezes e produziria uma razão
errada.
Por isso, o script usa r, g, b direto na fórmula e aplica apenas um clamp em
[0, 1] para limitar valores fora do gamut:
function luminance([L, C, H]) {
const { r, g, b } = oklchToLinear(L, C, H);
return 0.2126 * clamp01(r) + 0.7152 * clamp01(g) + 0.0722 * clamp01(b);
}O comentário no topo do arquivo explica esse ponto para evitar que alguém adicione o decode em uma manutenção futura.
Conferindo os tokens do site#
Os tokens ficam em arrays [L, C, H] que espelham o :root de globals.css. O
foreground usa [0.92, 0.012, 85] e o background, [0.17, 0.006, 70]. Depois da
conversão, esse par chega a 15.10:1, acima do limite de 4.5:1 para texto.
Antes de testar a paleta, o script roda um self-check com valores de referência.
Entre eles estão foreground / background em aproximadamente 15.1 e
border / background em aproximadamente 1.51. Se uma mudança quebrar a conversão,
o script para antes de avaliar os tokens.
Os limites configurados são 4.5:1 para texto e 3.0:1 para elementos não textuais,
como o anel de foco e a borda de um input ativo. O token border é marcado como
decorativo e fica isento pela SC 1.4.11. Hoje o resultado mostra 13 pares obrigatórios
aprovados e 1 par isento.
O vermelho que falhou#
O script encontrou um problema no token destructive, usado para erros. Com L em
0.585, o contraste contra destructive-foreground era 4.06:1. A cor parecia boa
na tela, mas ficava abaixo do mínimo para texto AA.
Baixei o L para 0.52 e rodei a checagem de novo. A razão subiu para 5.35:1. Esse
foi um ajuste pequeno que eu provavelmente não faria apenas olhando para os dois
vermelhos.
Quando eu rodo a checagem#
O script não roda em todo CI porque os tokens mudam pouco e normalmente entram em
um PR de design. Quando mexo na paleta, executo
node scripts/check-contrast.mjs e confiro o resultado junto com o diff. É uma
checagem pequena, mas evita decidir acessibilidade apenas no olho.