Por qué el widget de chat ignora el selector de modo oscuro de tu sitio

Engineering

Lanzaste el modo oscuro. Un visitante hace clic en el icono de la luna, toda la página se oscurece con una transición suave, y en la esquina inferior derecha un rectángulo blanco brillante sigue exactamente donde estaba. El chat de soporte.

Es un detalle pequeño, y es de los que la gente nota, porque es la única parte de la página que claramente no hiciste tú. Veamos por qué los widgets incrustados fallan en esto tan a menudo, por qué los arreglos obvios no funcionan y qué tiene que ofrecer un widget para que el tuyo no falle.

Hay dos modos oscuros, no uno

En un sitio web, «modo oscuro» significa una de dos cosas distintas.

  • La preferencia del sistema operativo. El visitante puso su portátil en modo oscuro. El navegador la expone con la media query prefers-color-scheme.

  • La configuración de tu sitio. Un selector en tu cabecera, guardado en localStorage o en una cookie, y aplicado normalmente como una clase dark o un atributo data-theme en <html>.

La mayoría de los sitios con selector ofrecen tres opciones —claro, oscuro y sistema— precisamente porque las dos no coinciden. Mucha gente usa el sistema en claro y aun así quiere una documentación oscura, un panel oscuro, cualquier cosa oscura por la noche.

Un widget de terceros puede ver la primera. No puede ver la segunda a menos que se la digas.

Por qué «auto» no basta

Casi todos los widgets incrustables tienen una opción de tema, y casi todos ofrecen auto:

HTML
<script src="https://widget.example.com/chat.js" data-theme="auto"></script>

auto significa «seguir prefers-color-scheme». Es justo lo correcto para un sitio sin selector, y por eso es un valor por defecto sensato. Y es justo lo incorrecto en cuanto tu sitio tiene su propio selector: un visitante con el sistema en claro que eligió oscuro en tu sitio ve una página oscura y un widget claro. La media query no puede ver la clase de tu elemento <html>, porque no mira tu página en absoluto.

Fijar data-theme="dark" a mano solo traslada el problema a los visitantes que eligieron claro.

Por qué no puedes arreglarlo con CSS

El siguiente instinto es meter la mano y cambiarle los estilos:

CSS
.dark .chat-panel { background: #111; color: #eee; }

En un widget bien hecho esto no hace nada, y es a propósito. Los widgets que viven en páginas ajenas se renderizan dentro de un shadow root, para que el CSS de la página anfitriona no pueda romperlos y el suyo no pueda romper la página anfitriona. Tu regla CSS nunca cruza ese límite.

Algunos widgets sí dejan pasar unas cuantas propiedades personalizadas de CSS, y es tentador fijarlas desde tu clase de modo oscuro. No lo hagas: esos nombres son detalles internos del widget, no su API. Cambian sin que aparezca nada en su registro de cambios, y tu modo oscuro se rompe sin avisar con una versión que ni llegaste a ver.

Por qué recargarlo es peor

Algunas documentaciones de widgets proponen la solución a lo bruto: destruir el widget y crearlo de nuevo con el tema nuevo.

JavaScript
Widget.destroy();
Widget.init({ ...config, theme: 'dark' });

Funciona, en el sentido de que los colores cambian. También desmonta todo lo que el visitante tenía a medias. El panel que tenía abierto se cierra. La respuesta que aún llegaba en streaming se corta a mitad de frase. Y muchas veces el selector se pulsa porque el visitante está leyendo algo de madrugada: el peor momento posible para tirar su conversación.

Lo que de verdad tiene que ofrecer un widget

El tema es cosa de la página anfitriona, así que la página anfitriona tiene que poder decirle al widget cuándo cambia. Para eso basta un método:

JavaScript
KookeeChat.setTheme('dark'); // 'light', 'dark' o 'auto'

El método es la parte fácil. Lo que lo hace útil es cómo se comporta:

  1. Se aplica en caliente. Sin remontar: un panel abierto sigue abierto y una respuesta en streaming sigue llegando. Solo cambian los colores.

  2. Funciona antes de que el widget haya cargado. Los scripts de widgets se cargan con async, y tu código de tema a menudo se ejecuta antes. Una llamada temprana debería quedar en cola, no perderse ni convertirse en un error.

  3. No le importa el orden. Tanto si tu llamada se ejecutó antes como después de que el widget leyera su propia configuración, gana la llamada explícita.

  4. auto sigue siendo una opción. «Sistema» es una opción real en tu selector, y el widget debería poder seguirla en directo.

Si estás evaluando un widget de chat o de soporte y tu producto tiene selector de tema, vale la pena comprobar esos cuatro puntos antes de decidirte. El primero es donde la mayoría de las implementaciones se quedan cortas sin que se note.

Cómo conectarlo a tu selector

La versión más sencilla vive dentro de la función a la que ya llama tu selector:

JavaScript
function applyTheme(choice) { // 'light' | 'dark' | 'system'
  const dark = choice === 'dark' || (choice === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
  document.documentElement.classList.toggle('dark', dark);
  localStorage.setItem('theme', choice);

  KookeeChat.setTheme(choice === 'system' ? 'auto' : choice);
}

Fíjate en que la última línea convierte system en 'auto', no en lo que system resuelva en ese momento. Si pasas el valor resuelto, el widget se queda congelado en ese instante: cuando el sistema del visitante cambia a oscuro al atardecer, tu página lo sigue y el widget no. Si pasas 'auto', el widget vigila el sistema por su cuenta.

Fijar el tema inicial sin parpadeo

setTheme() se encarga de los cambios. Para el primer pintado, dale al widget la elección que ya conoces, tan pronto como se ejecute tu propio script de tema:

JavaScript
const stored = localStorage.getItem('theme'); // 'light' | 'dark' | 'system' | null
KookeeChat.setTheme(stored === 'light' || stored === 'dark' ? stored : 'auto');

Si eso se ejecuta antes de que cargue el script del widget, el pequeño stub con cola del fragmento de instalación guarda la llamada hasta que llega el script, siempre que 'setTheme' esté en la lista de métodos del stub. Si renderizas la página en el servidor y allí ya conoces el tema, un atributo data-theme en la etiqueta script hace lo mismo sin nada de JavaScript.

Cuando el selector no es tuyo

En un tema de Shopify, un tema de WordPress o un sistema de diseño que gestiona el modo oscuro por ti, puede que no haya ninguna función de selector a la que añadirle una línea. Observa el resultado en su lugar:

JavaScript
const root = document.documentElement;
const sync = () => KookeeChat.setTheme(root.classList.contains('dark') ? 'dark' : 'light');

new MutationObserver(sync).observe(root, { attributes: true, attributeFilter: ['class', 'data-theme'] });
sync();

Ajusta la comprobación a la forma en que tu tema marque el modo oscuro: un atributo data-theme="dark" es tan habitual como una clase. Esto pasa el valor resuelto a propósito: cuando solo ves el resultado, seguir a la página tal cual es lo correcto, y el tema ya resuelve «sistema» por ti.

En React

Cuando el widget es un componente de React, el tema es una prop más, y pasarle tu configuración actual es todo el trabajo. Con next-themes:

tsx
const { theme } = useTheme(); // 'light' | 'dark' | 'system' | undefined

<KookeeChatWidget
  clientOptions={{ apiKey }}
  theme={theme === 'light' || theme === 'dark' ? theme : 'auto'}
/>;

Usa theme, no resolvedTheme. resolvedTheme es desconocido durante el renderizado en servidor y en el primer renderizado en el cliente, así que un widget que lo recibe empieza en claro y cambia un momento después para cada visitante en modo oscuro. theme, convertido de esta forma, cae en 'auto', que ya es lo correcto para la mayoría de ellos.

En resumen

auto sigue al sistema operativo, y un selector en tu sitio es, por definición, algo que el sistema operativo desconoce. Un widget que vive en tu página debería tomar el tema de tu página: en tiempo de ejecución, en caliente y sin perder lo que estaba haciendo el visitante.


El widget de chat con IA de Kookee ya lo hace. Desde la etiqueta script, llama a KookeeChat.setTheme(); en React, la prop theme de KookeeChatWidget se actualiza en directo. El widget de React es la única excepción a la regla del shadow root de más arriba: se renderiza en tu página, y las variables --kookee-* que lee están documentadas, así que fijarlas es seguro. En Personalizar la apariencia del widget de chat tienes las opciones de tema, y en Controlar el widget de chat desde JavaScript, la API completa.