Pular para o conteúdo
MAIB

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.

4 min de leitura
  • design-system
  • a11y
pten
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.

Compartilhar