Pular para o conteúdo
Development

Dark mode numa tarde quando seu CSS já é tokens (e o falso alarme que não era)

Por Victor Da Luz
railscsstailwinddev-logblog-manager

Meu app blog-manager era só modo claro. Eu queria um dark mode de verdade, com um toggle. Duas coisas tornaram isso interessante: foi genuinamente rápido por causa de uma decisão que eu tinha tomado meses antes, e depois eu desperdicei a maior parte do tempo economizado “consertando” algo que já funcionava.

A parte que foi fácil de propósito

O app inteiro é estilizado a partir de tokens CSS custom-property. O Tailwind v4 deixa você defini-los num bloco @theme:

@theme {
  --color-page: #f5f3ee;
  --color-card: #ffffff;
  --color-text-1: #18181a;
  /* ... */
}

Toda utility (bg-card, text-text-1, border-border) compila para var(--color-card) e afins. O que significa que um tema dark inteiro é só redefinir essas variáveis sob uma classe:

.dark {
  --color-page: #1a1b26;
  --color-card: #1f2335;
  --color-text-1: #c0caf5;
  /* Tokyo Night the rest */
}

Adicione uma classe .dark na <html> e o app inteiro vira. Sem tocar em componentes, sem dark: em mil elementos. Fui de paleta Tokyo Night porque é a que eu fico olhando no meu editor o dia inteiro, e mantive o acento laranja-queimado da minha marca, só clareado para não ficar sujo em cima do azul escuro.

Três coisas não viraram de graça, e são as interessantes:

  • Componentes que usam um token de texto como background. Meu botão primário era background: var(--color-text-1) com texto claro. No modo claro isso é um botão quase preto. Vira os tokens e --color-text-1 fica claro, então o botão vira texto-claro-sobre-fundo-claro: invisível. Qualquer truque de “usar a cor do texto como preenchimento” inverte errado. Tive que sobrescrever esses casos na mão.
  • Cores fixas que não são tokens. Um punhado de fundos de ícone em tom pastel e alguns tons de hover rgba(0,0,0,0.015) (invisíveis numa superfície escura). Pequeno, mas não acompanham a virada.
  • O flash de claro no carregamento. Se a classe dark é aplicada por JavaScript depois que a página renderiza, você recebe um flash branco primeiro. A correção é um scriptzinho inline no <head>, antes da stylesheet, que lê a preferência salva (ou a do sistema operacional) e define a classe antes do primeiro paint.

O toggle em si é um controller Stimulus de cinco linhas: vira a classe, escreve no localStorage. Usa a preferência do seu sistema operacional por padrão, e depois lembra da sua escolha.

A parte em que discuti com uma feature que funcionava

Escrevi um teste de sistema (navegador headless de verdade): fazer login, clicar no toggle, verificar que o elemento <html> agora tem dark. Falhou. Sem a classe dark.

Então comecei a debugar o toggle. O controller Stimulus estava sequer carregando? Fiz o navegador imprimir seus controllers registrados: ["hello", "postiz-schedule", "theme"]. Lá estava ele, registrado. Mas clicar nele não fazia nada. Fiquei encarando um controller trivial de cinco linhas convencido de que estava quebrado.

Não estava. Três coisas sem relação estavam empilhadas uma sobre a outra, e cada uma me mandou por um caminho errado:

  1. CSS desatualizado. Meus primeiros screenshots mostravam os ícones de sol e lua ao mesmo tempo, e a página continuava clara. Aquilo não era o toggle. O test runner estava servindo uma stylesheet compilada antiga que não continha minhas novas regras .dark de jeito nenhum, então nada podia virar e nenhuma utility dark: existia para esconder o ícone errado. Um rebuild consertou o visual na hora. Mas eu já tinha me convencido de que o toggle era o problema.
  2. Cliquei rápido demais. Controllers carregam de forma assíncrona pelo import map. Meu teste clicava no botão cerca de 100ms depois que a página carregava, antes do controller ter conectado. Um humano nunca faria isso. O teste era o único “usuário” impaciente o bastante para perder essa corrida. Esperar o controller se registrar antes de clicar fez o teste passar.
  3. O navegador headless prefere dark. Uma vez que passou, uma verificação diferente falhou: eu esperava que o app iniciasse em modo claro, e ele iniciou em dark. Acontece que o Chrome headless reporta uma preferência de sistema operacional dark, então meu script anti-flash estava honrando isso corretamente. A suposição do meu teste estava errada, não o código. (Um valor de localStorage persistido de uma execução anterior também estava confundindo as coisas, porque o navegador não limpa o local storage entre execuções de teste.)

Cada falha era real, e nenhuma delas era a coisa que eu estava debugando. O toggle funcionava desde o primeiro commit. Passei 45 minutos provando a inocência de uma função de cinco linhas.

O que aprendi com isso

Projete seus tokens uma vez e reskinning fica quase de graça. O dark mode feito numa tarde é a recompensa de uma decisão chata tomada meses atrás, de rotear toda cor por uma variável CSS. O eu-do-futuro agradece o eu-do-passado mais ou menos uma vez por trimestre.

Quando o teste falha, suspeite do teste. Especialmente num navegador. Assets desatualizados, carregamento assíncrono, e as próprias configurações do ambiente headless (tema do sistema, locale, storage que sobrevive entre execuções) produzem falhas que parecem exatamente bugs de aplicação. Fiquei “consertando” a feature porque a falha apontava para ela, quando o certo era perguntar o que o ambiente de teste estava fazendo diferente de um usuário real. O sinal estava bem ali: o controller estava registrado e a função tinha três linhas. Isso devia ter me redirecionado na hora, e não redirecionou.

O modo claro continua sendo o padrão para quem tem o sistema operacional pedindo por ele. Só que eu não preciso mais usar.

Leitura relacionada

Development

"Looks the same to me"

A UI redesign, a review comment that named the exact property I never touched, and what it takes to actually fix what someone reports instead of what's around it.

Ler

Você também pode achar útil

Proton

Proton VPN

VPN comercial com filtragem NetShield e interruptor de desligamento automático.

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

Saiba mais
NordPass

NordPass

Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.

Como afiliado da NordPass, ganho com compras qualificadas.

Saiba mais
Proton

Proton Pass

Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.

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

Saiba mais