Saltar al contenido
Development

Dark mode en una tarde cuando el CSS ya es tokens (y la falsa alarma que no lo fue)

Por Victor Da Luz
railscsstailwinddev-logblog-manager

Mi app blog-manager era solo de modo claro. Quería un modo oscuro real con un interruptor. Dos cosas hicieron esto interesante: fue genuinamente rápido gracias a una decisión que había tomado meses antes, y luego desperdicié la mayor parte del tiempo ahorrado “arreglando” algo que ya funcionaba.

La parte que fue fácil a propósito

Toda la app está estilizada a partir de tokens de propiedades personalizadas de CSS. Tailwind v4 permite definirlas en un bloque @theme:

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

Cada utilidad (bg-card, text-text-1, border-border) compila a var(--color-card) y similares. Lo cual significa que un tema oscuro completo es simplemente redefinir esas variables bajo una clase:

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

Al agregar la clase .dark a <html>, toda la app cambia. Sin tocar componentes, sin dark: en mil elementos. Elegí la paleta Tokyo Night porque es la que miro en mi editor todo el día, y mantuve el acento naranja quemado de mi marca, solo aclarado para que no se vea sucio sobre el azul oscuro.

Tres cosas no cambiaron gratis, y son las interesantes:

  • Componentes que usan un token de texto como fondo. Mi botón principal era background: var(--color-text-1) con texto claro. En modo claro eso da un botón casi negro. Al invertir los tokens, --color-text-1 se vuelve claro, así que el botón termina en texto claro sobre fondo claro: invisible. Cualquier truco de “usar el color de texto como relleno” se invierte mal. Tuve que sobrescribir esos casos a mano.
  • Colores fijos que no son tokens. Un puñado de fondos de ícono en tonos pastel y un par de matices de hover rgba(0,0,0,0.015) (invisibles sobre una superficie oscura). Algo menor, pero no se suman al cambio automáticamente.
  • El destello de luz al cargar. Si la clase oscura se aplica con JavaScript después de que la página se renderiza, aparece primero un destello blanco. La solución es un pequeño script en línea en <head>, antes de la hoja de estilos, que lee la preferencia guardada (o la del sistema operativo) y aplica la clase antes del primer render.

El interruptor en sí es un controlador de Stimulus de cinco líneas: cambia la clase, escribe en localStorage. Por defecto sigue la preferencia del sistema operativo, y después recuerda la elección hecha.

La parte en la que discutí con una función que sí funcionaba

Escribí una prueba de sistema (navegador headless real): iniciar sesión, hacer clic en el interruptor, verificar que el elemento <html> ahora tenga dark. Falló. Sin clase dark.

Así que empecé a depurar el interruptor. ¿Siquiera estaba cargando el controlador de Stimulus? Hice que el navegador imprimiera sus controladores registrados: ["hello", "postiz-schedule", "theme"]. Ahí estaba, registrado. Pero hacer clic no hacía nada. Me quedé mirando un controlador trivial de cinco líneas convencido de que estaba roto.

No lo estaba. Tres cosas sin relación entre sí estaban apiladas una sobre otra, y cada una me mandó por un camino equivocado:

  1. CSS desactualizado. Mis primeras capturas mostraban los íconos de sol y luna a la vez, y la página seguía clara. Eso no era el interruptor. El runner de pruebas estaba sirviendo una hoja de estilos compilada vieja que no contenía para nada mis reglas .dark nuevas, así que nada podía cambiar y no existía ninguna utilidad dark: para ocultar el ícono equivocado. Una recompilación arregló lo visual al instante. Pero para ese momento ya me había convencido de que el problema era el interruptor.
  2. Hice clic demasiado rápido. Los controladores cargan de forma asíncrona a través del import map. Mi prueba hacía clic en el botón unos 100ms después de que la página cargara, antes de que el controlador se hubiera conectado. Un humano nunca haría eso. La prueba fue el único “usuario” lo bastante impaciente como para perder esa carrera. Esperar a que el controlador se registrara antes de hacer clic hizo que pasara.
  3. El navegador headless prefiere el modo oscuro. Una vez que pasó, falló una aserción distinta: esperaba que la app arrancara en modo claro, y arrancó en oscuro. Resulta que Chrome headless reporta una preferencia oscura del sistema operativo, así que mi script anti-destello la estaba respetando correctamente. La suposición equivocada estaba en mi prueba, no en el código. (Un valor de localStorage persistido de una corrida anterior también estaba enturbiando esto, porque el navegador no limpia el almacenamiento local entre corridas de prueba.)

Cada falla era real, y ninguna era lo que yo estaba depurando. El interruptor funcionó desde el primer commit. Pasé 45 minutos demostrando la inocencia de una función de cinco líneas.

Lo que me llevo de esto

Diseñar los tokens una sola vez hace que cambiar de piel salga casi gratis. El modo oscuro de una tarde es la recompensa de una decisión aburrida tomada meses atrás: enrutar cada color a través de una variable CSS. El yo del futuro le agradece al yo del pasado más o menos una vez por trimestre.

Cuando la prueba falla, hay que sospechar de la prueba. Sobre todo en un navegador. Los assets desactualizados, la carga asíncrona y la configuración propia del entorno headless (tema del sistema, idioma, almacenamiento que sobrevive entre corridas) producen fallas que se ven exactamente como errores de la aplicación. Seguí “arreglando” la función porque la falla apuntaba hacia ella, cuando lo honesto era preguntarme qué estaba haciendo distinto el entorno de prueba respecto a un usuario real. La señal estaba justo ahí: el controlador estaba registrado y la función tenía tres líneas. Eso debería haberme redirigido de inmediato, y no lo hizo.

El modo claro se sigue enviando por defecto para quien tenga el sistema operativo configurado así. Yo, simplemente, ya no tengo que usarlo.

Lecturas relacionadas

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.

Leer