Pular para o conteúdo
Development

Dark mode, e o teste de navegador que reagiu

Por Victor Da Luz
cssaccessibilitytestingdev-loggreenhouse

O Greenhouse ganhou um tema dark essa semana. O design system já tinha uma camada de tokens semânticos - --bg, --fg, --surface, --accent, esse tipo de coisa - vinda do trabalho do wizard shell, então a descrição do chamado fazia parecer simples: “basicamente uma questão de sobrescrever esses tokens”. Essa parte acabou sendo verdade. A parte que não foi simples foi provar que isso não quebrava o contraste.

O CSS foi a metade fácil

Usei light-dark() para as definições de token em vez de um bloco @media (prefers-color-scheme: dark) duplicado:

:root {
  color-scheme: light dark;
  --paper: light-dark(#fbfdfb, #10140f);
  --ink: light-dark(#1a211c, #e8ede9);
}
:root[data-theme="dark"] { color-scheme: dark; }

Um único conjunto de definições de token, os dois temas, e um hook de atributo data-theme que sobrescreve a preferência do sistema operacional forçando color-scheme (um seletor de atributo vence um :root puro, então ganha em especificidade sem precisar de !important). Nenhum JS necessário para a parte que segue o sistema operacional.

A descoberta menos divertida: um design system com tokens não significa que toda cor passe por eles. Encontrei sete componentes com um vermelho de erro fixo (#b3261e) e um trio de cores de alert-box, copiados e colados em vez de tokenizados. Nada disso responderia a uma troca de tema - ficaria simplesmente vermelho de modo claro sobre um fundo escuro, que é exatamente o tipo de coisa que parece bem num screenshot de modo claro e terrível para um usuário real. Tive que adicionar os tokens --danger, --overlay e --on-accent e ir corrigir cada lugar na mão.

A parte que realmente tomou tempo

O chamado tinha um requisito real anexado: a suíte de acessibilidade existente (baseada em jsdom, usando vitest-axe) já documentava que ela não conseguia checar contraste de cor, porque o jsdom não faz layout - a regra de contraste do axe só reporta “incomplete” e silenciosamente nunca falha. Dark mode é exatamente quando o contraste regride, então esse chamado devia fechar essa lacuna com um navegador de verdade.

Recorri ao browser mode do Vitest (Chromium real via Playwright por baixo dos panos) em vez de montar um arquivo de teste Playwright totalmente separado, já que isso me deixava reusar exatamente o mesmo harness de teste render() + mockIPC() já usado pela suíte jsdom. Devia ser uma mudança de cinco minutos. Não foi.

Primeira falha: o vitest-axe não funciona num navegador de verdade. O wrapper dele chama o createRequire do Node internamente para importar o axe-core, e no browser mode o arquivo de teste roda dentro do navegador, não no Node. A correção foi fácil depois que encontrei - importar o axe-core direto e trocar o matcher do wrapper por uma checagem simples de array.

A segunda falha foi mais chata: todo render lançava mount(...) is not available on the server, como se o Svelte achasse que estava fazendo SSR. Num navegador de verdade. A causa acabou sendo um bug sutil no próprio plugin Vite do @testing-library/svelte - ele faz um patch na resolução de módulos do Vite para preferir o build de navegador do Svelte em vez do build de servidor, mas o patch só dispara se "node" já estiver na lista de resolve conditions. Isso é verdade para o Vitest em modo jsdom (que tecnicamente ainda executa no Node), mas não para o browser mode real. A guarda silenciosamente não faz nada, deixa um array vazio no lugar, e esse array vazio sobrescreve os defaults sensatos do Vite. Acabei simplesmente definindo a resolve condition eu mesmo em vez de confiar no plugin para esse projeto.

Terceira: mesmo depois dessa correção, ainda falhava de forma intermitente, com o mesmo erro de SSR, porque o pre-bundling de dependências do Vite disparava no meio da execução (“new dependencies optimized… reloading”) e resetava a condition resolvida para qualquer teste em andamento. Adicionar a nova dependência em optimizeDeps.include de antemão parou o reload de acontecer de vez.

Nenhum desses três problemas era sobre meu CSS. Todos eram sobre o tooling de teste não ter sido construído com o browser mode real como um caso de primeira classe - o que faz sentido, já que o modo jsdom é o padrão há anos e o browser mode é mais novo. Quando um teste falha de um jeito que não bate com o que você mudou, vale a pena checar se a própria infraestrutura de teste tem uma borda não testada, especialmente logo depois de adotar um modo “novo” de uma ferramenta antiga. As três correções acabaram sendo pequenas depois de identificadas. Encontrá-las não foi.

Quatro checagens de contraste agora rodam em Chromium real, nos dois temas, sobre as visões de wizard, dashboard, loading e error. npm run a11y:contrast é um comando separado do gate jsdom npm run a11y, de propósito - um é rápido e estrutural, o outro é real e mais lento, e nenhum dos dois esconde mais o ponto cego do outro.

Adendo: o teste que passou mesmo assim

Algumas horas depois de escrever o texto acima, tirei três screenshots do app real rodando em dark mode: um contador “Worklist” que você quase não conseguia ver, uma pill “Vault: 1 item” que parecia uma mancha fraca, e o diálogo de confirmação de ideia capturada onde o caminho da pasta e seus botões de copiar/abrir tinham ficado quase totalmente invisíveis.

Meu teste de contraste dizia 4 de 4, passando, nos dois temas. Foi um erro confiar nisso como o quadro completo.

Foi o seguinte. Quando construí a paleta dark, fiz uma camada de tokens: cores brutas como --green-100 alimentam nomes semânticos como --accent-soft, e são os nomes semânticos que realmente viram entre claro e escuro. Essa é a estrutura certa. Mas nada impede um componente de passar por cima da camada semântica e pegar a cor bruta direto. Sete lugares fizeram exatamente isso: background: var(--green-100), embaixo de texto colorido com um token semântico que vira de verdade. No modo claro isso parece completamente normal, porque o valor semântico padrão acaba sendo igual ao valor bruto de qualquer jeito. Vira para dark, e o texto fica claro enquanto o fundo continua exatamente onde estava. Quase colisão do mesmo tom, sete vezes, em sete arquivos diferentes, porque é um erro fácil de cometer uma vez e um erro fácil de cometer independentemente sete vezes.

A parte pior é por que meu próprio teste não pegou isso. O verificador de contraste do axe-core nem sempre te dá uma resposta limpa de violação-ou-aprovado. Para formas pequenas com cantos arredondados e padding apertado, badges, pills, botões de ícone, ele frequentemente reporta “incomplete” em vez disso, porque não consegue resolver com confiança um único retângulo sólido de fundo atrás do texto. Minha verificação checava violations e ignorava incomplete completamente. Cada um desses sete bugs estava sentado exatamente nesse formato: um badge, uma pill, um botão de ícone. O teste não estava mentindo sobre zero violações. Só nunca me contou que tinha silenciosamente desistido dos elementos onde os bugs viviam.

E separadamente, mais bobo ainda: um dos três bugs visíveis estava num estado de diálogo que meu teste nunca sequer renderizava. Testei o formulário de captura. Nunca testei como o diálogo fica depois que você o envia, que é exatamente onde ficam a caixa do caminho da pasta e os botões de ícone.

Corrigi os sete roteando eles pelo mesmo token semântico que tudo mais já usava, adicionei um teste para o estado de diálogo que faltava, e adicionei uma linha de log que imprime sempre que o axe volta com “incomplete” em vez de engolir isso silenciosamente. Isso não transforma incomplete numa falha dura, a maioria dos incompletes não tem relação com bugs reais, mas pelo menos agora fica visível em vez de invisível.

A lição não é “escreva mais testes”. É que um gate automatizado verde e uma feature de fato correta são duas afirmações diferentes, e a lacuna entre elas é exatamente as formas e estados que você não pensou em checar. Eu tinha dito para mim mesmo que o trabalho de contraste estava pronto. Foi preciso olhar o app rodando para saber que não estava.

Leitura relacionada

Development

A pasta que ficou parada

Uma revisão de código encontrou um invariante que a feature de adotar/importar do Greenhouse quebrava silenciosamente, e a correção que tornou tudo chato de novo.

Ler
Development

Ensinando um agente a testar cliques no meu app Tauri

O macOS não tem um WebDriver oficial do Tauri, um plugin da comunidade preenche essa lacuna, e a unificação de features do Cargo quase compilou o servidor de teste inteiro nos builds de release.

Ler

Você também pode achar útil

Proton

Proton Mail

E-mail criptografado de ponta a ponta, com arquitetura de acesso zero.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
RackNerd

RackNerd VPS

Hospedagem VPS econômica para serviços leves que funcionam continuamente.

Como afiliado da RackNerd, ganho com compras qualificadas.

Saiba mais
AdGuard

AdGuard para iOS

Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.

Como afiliado da AdGuard, ganho com compras qualificadas.

Saiba mais