Modo escuro em widgets incorporados: como seguir o seletor de tema do seu app

widget

Você lança o modo escuro. O seletor fica no cabeçalho, lembra a escolha, e o app inteiro troca de tema sem falhas. Aí alguém abre o widget de feedback no canto, e ele continua de um branco que cega.

Ou, de um jeito mais sutil: ele está escuro, mas só porque o notebook da pessoa está. Quem escolheu o modo escuro no seu app num computador configurado no claro recebe um widget claro, e quem escolheu o claro num computador escuro recebe o contrário.

Esbarrei exatamente nisso no próprio widget do BugDump esta semana. A correção é pequena, mas o raciocínio vale para todo widget de terceiros que você incorpora: botões de chat, botões de feedback, banners de cookies, beacons de ajuda. Todos precisam responder à mesma pergunta, qual tema eu uso?, e a maioria procura a resposta no lugar errado.

Três lugares de onde o tema pode vir

Quando alguém diz «o tema do usuário», pode estar falando de três coisas diferentes:

  1. A preferência do sistema operacional. O que o navegador informa por meio de prefers-color-scheme. É definida uma vez nas configurações do sistema e vale para todos os sites.

  2. A configuração do app. O que o usuário escolheu no seu seletor: claro, escuro ou «sistema». Fica guardada num cookie, no localStorage ou na conta da pessoa.

  3. O que o app de fato renderiza. Em geral, uma classe ou um atributo no <html>, derivado da configuração e, quando a configuração é «sistema», do sistema operacional.

Seu app vive na segunda e na terceira. A maioria dos widgets incorporados só conhece a primeira, porque é a única que eles conseguem ler sem a sua ajuda.

Por que prefers-color-scheme é o sinal errado

Um widget que se estiliza com a media query prefers-color-scheme faz um palpite razoável: a maioria das pessoas que quer modo escuro já pediu isso ao sistema operacional. Para um site sem configuração de tema própria, o palpite está certo.

Ele deixa de estar certo no momento em que o seu app ganha um seletor. A media query informa a preferência do sistema operacional e mais nada. Uma página não consegue mudar o que ela informa, então, por mais cuidado que o seu app tenha ao aplicar o tema, um widget que lê a media query nunca fica sabendo.

A diferença só aparece para usuários cuja configuração no app difere da do sistema, e é exatamente por isso que ela passa pelos testes: desenvolvedores costumam testar com as duas iguais.

Por que o widget não pode simplesmente ler a sua página

A alternativa óbvia é o widget olhar para a sua página: procurar uma classe dark no <html>, observá-la com um MutationObserver e acompanhar.

Isso funciona para sites que seguem exatamente essa convenção e falha em silêncio para todos os outros. A estratégia de classe do Tailwind coloca dark no elemento raiz. Outros sites usam data-theme="dark", um atributo data-mode, uma classe no <body> ou um conjunto de propriedades CSS customizadas sem marcador nenhum. Alguns renderizam escuro por padrão e marcam o claro. Um widget que chuta vai errar para alguém, e de um jeito difícil de diagnosticar.

A página sabe qual é o próprio tema. O design confiável é a página dizer isso.

Um contrato em duas partes que funciona

A solução a que cheguei, e que eu sugeriria a quem constrói um widget incorporável, tem duas partes.

1. Um tema inicial, declarado de antemão. O código de incorporação aceita o tema como configuração:

HTML
<script
  src="https://bugdump.com/sdk/latest.js"
  async
  data-api-key="your-api-key"
  data-theme="dark"
></script>

Se o seu servidor sabe a escolha do usuário, renderize-a direto na tag. Assim o widget já tem as cores certas na primeira pintura, antes de qualquer JavaScript seu rodar. A configuração «sistema» do app corresponde a auto, que devolve a decisão ao sistema operacional: o único caso em que a media query é o sinal certo.

2. Um setter para cada mudança depois disso.

JavaScript
bugdump('setTheme', 'dark'); // 'light', 'dark' or 'auto'

Chame-o a partir do seu seletor. O widget troca na hora, inclusive um painel que já está aberto.

Nenhuma das partes basta sozinha. Configuração sem setter foi o que eu lancei antes: correta no carregamento e desatualizada depois da primeira troca, até recarregar a página. Setter sem configuração significa que o widget renderiza num tema padrão e depois troca de forma visível quando o seu código o alcança.

Ligando isso a um framework

No React, um componente que não renderiza nada resolve:

tsx
import { useEffect } from 'react';

export function WidgetThemeSync({ theme }: { theme: 'light' | 'dark' | 'system' }) {
  useEffect(() => {
    window.bugdump?.('setTheme', theme === 'system' ? 'auto' : theme);
  }, [theme]);

  return null;
}

Dois detalhes separam o «funciona» do «quase funciona».

Sincronize a configuração, não a cor resolvida. Muitos provedores de tema expõem as duas coisas: a escolha do usuário (light, dark, system) e a cor aplicada no momento. O valor aplicado muitas vezes é calculado depois da primeira renderização, então por um instante ele pode guardar um valor provisório, normalmente claro. Sincronizar esse valor empurra o provisório para o widget, que pisca em claro para todo usuário do modo escuro a cada carregamento de página. A configuração está correta desde a primeira renderização.

Deixe «sistema» continuar «sistema». Dá vontade de resolver por conta própria e passar light ou dark. Passe auto, e o widget acompanha sozinho as mudanças do sistema operacional, sem nenhum evento para ligar do seu lado.

Notas para quem cria widgets

Se você está do outro lado, construindo algo que outras pessoas incorporam, algumas escolhas simplificaram a implementação para mim.

Coloque o tema num elemento só e deixe o CSS fazer o resto. A paleta do meu widget é um conjunto de propriedades CSS customizadas definidas no shadow host, e o tema é uma classe nesse host. Trocar de tema é trocar uma classe: sem nova renderização, sem perder estado, e um painel aberto se atualiza no lugar.

Audite cada raiz que você monta. O editor de anotações de capturas de tela era montado à parte, direto em document.body, com shadow root próprio e uma cópia própria da paleta. Ele nunca via a classe de tema do host e continuava, discretamente, seguindo o sistema operacional. Enquanto tudo seguia o sistema, ninguém notou; no momento em que os temas explícitos passaram a funcionar, ele virou a única superfície clara numa interface escura. Se o seu widget monta modais, tooltips ou overlays fora da raiz principal, cada um deles também precisa do tema.

Deixe o setter ser chamado cedo sem risco. Widgets incorporados costumam carregar com async, então a primeira chamada de setTheme da página pode facilmente chegar antes do script. Um pequeno stub de fila de comandos, o padrão que os snippets de analytics usam há anos, guarda as chamadas até o SDK iniciar e as executa em ordem.

Não faça da reinicialização a resposta. «Destruir e reinicializar com novas opções» tecnicamente dá conta de mudanças em tempo de execução, mas joga fora tudo o que o widget guardava: logs em buffer, um formulário aberto, uma mensagem escrita pela metade. O tema é a opção que os usuários mais mudam com a página aberta. Ele merece um setter.

Como isso funciona no BugDump

A partir do SDK 0.0.39, o widget recebe um tema inicial por theme ou data-theme e o troca no lugar com setTheme(), pela fila de comandos da tag script ou pela instância do npm. O editor de capturas de tela segue o mesmo tema.

A entrada do registro de alterações traz os detalhes, e o artigo Como combinar o widget com o tema do seu app tem exemplos prontos para copiar em React, Vue e JavaScript puro.

Leia também