Programação Por Equipe DevBras

Capa do artigo: De tabela de cores a tema: oito variáveis CSS e um teste de contraste automático

De tabela de cores a tema: oito variáveis CSS e um teste de contraste automático

Troque dezenas de hexadecimais soltos por oito cores nomeadas pelo uso, com modo escuro, e um script em Node que reprova o build quando algum par de texto e fundo fica ilegível.

O CSS da Casa Pinho tinha 37 hexadecimais diferentes. Cada um tinha sido escolhido numa tabela de cores HTML, por alguém procurando "o cinza certo" ou "um azul mais bonito". O texto de apoio usava #999999 sobre branco, com contraste de 2,85:1, e os clientes mais velhos ligavam para perguntar o prazo que estava escrito na página. A resposta curta: pare de escolher cores soltas. Defina um conjunto pequeno de cores nomeadas pelo uso (texto, fundo, link, erro), declare os pares que precisam ser legíveis e deixe um script conferir o contraste de cada par a cada build.

Uma tabela de cores é um catálogo, como explica o guia sobre tabela de cor que não fecha tema. O tema é a decisão de quais cores vão juntas. Este guia mostra como transformar essa decisão em arquivo e em teste.

Por que mais uma tabela não resolve

A tabela responde "qual é o código deste azul". A pergunta que importa para quem lê é outra: "este texto se lê sobre este fundo". Contraste é propriedade de um par, não de uma cor. O mesmo cinza que funciona sobre branco some sobre o bege do cartão de produto.

Quando as cores estão espalhadas pelo CSS, ninguém sabe quais pares existem. Cada componente novo inventa o seu, e o modo escuro vira uma segunda coleção de hexadecimais escolhidos no olho. Sem uma lista de pares, não há o que testar. E o que não é testado volta a quebrar na próxima mudança de identidade visual.

Como o contraste é calculado

As diretrizes de acessibilidade WCAG definem o contraste como a razão entre a luminância relativa da cor mais clara e a da mais escura, cada uma somada a 0,05. A luminância pondera os canais como o olho percebe: o verde pesa muito mais que o azul. O resultado vai de 1:1 (mesma cor) a 21:1 (preto no branco).

Os mínimos do nível AA são 4,5:1 para texto comum e 3:1 para texto grande e para componentes de interface, como a borda de um campo. Na prática, a diferença entre passar e reprovar é pequena: #777777 sobre branco dá 4,48:1 e reprova, enquanto #767676 dá 4,54:1 e passa. É por isso que o olho não serve de juiz, e o script sim. Para entender por que o cinza claro é o caso mais frequente, veja cinza claro no texto.

Como montar o tema em quatro passos

  1. Conte as cores que existem hoje no CSS, para ter o tamanho do problema.
  2. Defina de seis a dez papéis de cor pelo uso, não pela aparência.
  3. Escreva os valores de cada papel no modo claro e no escuro num arquivo só.
  4. Rode um script que confere os pares e gera o CSS a partir desse arquivo.

Contar o que existe

grep -rohiE '#[0-9a-f]{6}|#[0-9a-f]{3}' css/ | sort -fu | wc -l

Na Casa Pinho, o resultado foi 37. Agrupando por função, sobraram oito papéis: fundo da página, fundo de cartão (superfície), texto principal, texto de apoio, link, erro, destaque de botão e o texto que vai sobre o destaque.

Um arquivo com os papéis

{
  "claro": {
    "fundo": "#ffffff",
    "superficie": "#f4f1ec",
    "texto": "#1f2328",
    "texto-suave": "#57606a",
    "link": "#0b5cad",
    "erro": "#b42318",
    "destaque": "#9a3412",
    "texto-sobre-destaque": "#ffffff"
  },
  "escuro": {
    "fundo": "#0d1117",
    "superficie": "#161b22",
    "texto": "#e6edf3",
    "texto-suave": "#9198a1",
    "link": "#4493f8",
    "erro": "#ff7b72",
    "destaque": "#fb923c",
    "texto-sobre-destaque": "#1b1f24"
  }
}

Repare que texto-sobre-destaque muda de branco para quase preto no modo escuro. O laranja do botão escuro é claro demais para texto branco. Esse é o tipo de detalhe que só aparece quando o par está escrito.

O script que testa e gera o CSS

// scripts/tema.js
const fs = require('fs');

const tema = JSON.parse(fs.readFileSync('tema.json', 'utf8'));
const PARES = [
  ['texto', 'fundo', 4.5],
  ['texto', 'superficie', 4.5],
  ['texto-suave', 'fundo', 4.5],
  ['texto-suave', 'superficie', 4.5],
  ['link', 'fundo', 4.5],
  ['erro', 'fundo', 4.5],
  ['texto-sobre-destaque', 'destaque', 4.5]
];

function luminancia(hex) {
  const [r, g, b] = [1, 3, 5].map((i) => {
    const c = parseInt(hex.slice(i, i + 2), 16) / 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;
}

function contraste(a, b) {
  const [claro, escuro] = [luminancia(a), luminancia(b)].sort((x, y) => y - x);
  return (claro + 0.05) / (escuro + 0.05);
}

let reprovou = false;
for (const [modo, cores] of Object.entries(tema)) {
  for (const [frente, fundo, minimo] of PARES) {
    const valor = contraste(cores[frente], cores[fundo]);
    const ok = valor >= minimo;
    if (!ok) reprovou = true;
    console.log((ok ? 'ok   ' : 'FALHA') + ' ' + modo + ' ' + frente + ' sobre ' + fundo + ': ' + valor.toFixed(2) + ':1');
  }
}

const bloco = (cores) => Object.entries(cores)
  .map(([nome, hex]) => '  --cor-' + nome + ': ' + hex + ';')
  .join('\n');
const css = ':root {\n' + bloco(tema.claro) + '\n}\n\n'
  + '@media (prefers-color-scheme: dark) {\n:root {\n' + bloco(tema.escuro) + '\n}\n}\n';
fs.writeFileSync('tema.css', css);

process.exit(reprovou ? 1 : 0);

O script aceita hexadecimal de seis dígitos, que é o formato do arquivo. Rodando com os valores acima, a saída começa assim:

ok    claro texto sobre fundo: 15.80:1
ok    claro texto-suave sobre superficie: 5.67:1
ok    claro texto-sobre-destaque sobre destaque: 7.31:1
ok    escuro texto-suave sobre superficie: 5.94:1
ok    escuro link sobre fundo: 6.11:1

Troque texto-suave pelo antigo #999999 e a linha vira FALHA claro texto-suave sobre fundo: 2.85:1, com código de saída 1. Coloque o script no pipeline e a cor ilegível não chega a produção.

Usar os papéis no CSS

body { background: var(--cor-fundo); color: var(--cor-texto); }
.cartao { background: var(--cor-superficie); }
.prazo { color: var(--cor-texto-suave); }
a { color: var(--cor-link); }
.botao-comprar { background: var(--cor-destaque); color: var(--cor-texto-sobre-destaque); }

Nenhum hexadecimal fica fora do tema.css gerado. Se alguém precisar de uma cor nova, ela entra no JSON com um nome de uso e, se for texto, com um par na lista. A tabela de cores volta a ser o que sempre foi: uma consulta para escolher o valor inicial.

Erros comuns

  • Nomear a cor pela aparência (--azul-claro): quando a identidade muda, a variável passa a mentir, e ninguém sabe onde ela é usada como texto.
  • Testar a cor sozinha: o mesmo cinza passa sobre branco e reprova sobre o bege do cartão, e a falha só aparece no componente.
  • Fazer o modo escuro invertendo as cores no olho: o destaque claro com texto branco fica ilegível, como no laranja do exemplo.
  • Arredondar a razão para cima: 4,48 não é 4,5, e o cinza que "quase passa" é exatamente o que some no sol.
  • Deixar hexadecimais soltos fora do tema: o teste confere o arquivo, mas a página usa outra cor, e a garantia vira enfeite.
  • Usar a mesma cor para link e texto: a pessoa não distingue onde clicar, a menos que o link tenha sublinhado.

Checklist

  • O número de cores distintas no CSS caiu para o conjunto do tema.
  • Cada cor tem nome de uso, não de aparência.
  • Todo par de texto e fundo usado no site está listado no script.
  • Todos os pares de texto comum passam de 4,5:1, nos modos claro e escuro.
  • O script roda no pipeline e reprova o build quando um par falha.
  • O CSS de cores é gerado a partir do arquivo do tema.
  • Links se distinguem do texto por mais que a cor.

Quantos hexadecimais diferentes existem hoje no seu CSS, e quantos deles você sabe dizer sobre qual fundo aparecem?

Avalie este artigo

Referência: http://www.cultura.ufpa.br/dicas/htm/htm-cor3.htm