Dark mode, y la prueba de navegador que se resistió
Greenhouse recibió un tema oscuro esta semana. El sistema de diseño ya tenía una capa de tokens semánticos, --bg, --fg, --surface, --accent, ese tipo de cosas, gracias al trabajo del wizard shell, así que la descripción del ticket lo hacía sonar simple: “básicamente es cuestión de sobrescribir esos tokens.” Esa parte resultó ser cierta. La parte que no fue simple fue demostrar que no rompía el contraste.
El CSS fue la mitad fácil
Usé light-dark() para las definiciones de tokens en vez de duplicar un bloque @media (prefers-color-scheme: dark):
:root {
color-scheme: light dark;
--paper: light-dark(#fbfdfb, #10140f);
--ink: light-dark(#1a211c, #e8ede9);
}
:root[data-theme="dark"] { color-scheme: dark; }
Un solo conjunto de definiciones de tokens, ambos temas, y un gancho de atributo data-theme que sobrescribe la preferencia del sistema operativo forzando color-scheme (un selector de atributo le gana a un :root simple, así que gana por especificidad sin necesitar !important). No hace falta JS para la parte que sigue al sistema operativo.
El descubrimiento menos divertido: que un sistema de diseño tenga tokens no significa que todos los colores pasen por ellos. Encontré siete componentes con un rojo de error fijo (#b3261e) y un trío de colores de caja de alerta, copiados y pegados en vez de tokenizados. Nada de eso habría respondido a un cambio de tema, se habría quedado en el rojo de modo claro sobre un fondo oscuro, que es exactamente el tipo de cosa que se ve bien en una captura de modo claro y terrible para un usuario real. Tuve que agregar los tokens --danger, --overlay y --on-accent, y arreglar cada sitio a mano.
La parte que realmente tomó tiempo
El ticket tenía un requisito real adjunto: la suite de accesibilidad existente (basada en jsdom, usando vitest-axe) ya tenía documentado que no podía verificar el contraste de color, porque jsdom no hace layout, la regla de contraste de axe simplemente reporta “incomplete” y nunca falla en silencio. El modo oscuro es exactamente cuando el contraste se rompe, así que este ticket se suponía que iba a cerrar ese vacío con un navegador real.
Recurrí al modo navegador de Vitest (Chromium real vía Playwright por debajo) en vez de montar todo un archivo de pruebas de Playwright aparte, porque me permitía reutilizar exactamente el mismo arnés de prueba render() + mockIPC() que ya usaba la suite de jsdom. Debería haber sido un cambio de cinco minutos. No lo fue.
Primera falla: vitest-axe no funciona en un navegador real. Su wrapper llama internamente a createRequire de Node para traer axe-core, y en modo navegador el archivo de prueba corre dentro del navegador, no en Node. La solución fue fácil una vez que la encontré: importar axe-core directamente y descartar el matcher del wrapper por una simple verificación de array.
La segunda falla fue más fea: cada render lanzaba mount(...) is not available on the server, como si Svelte pensara que estaba haciendo SSR. En un navegador real. La causa resultó ser un bug sutil en el propio plugin de Vite de @testing-library/svelte, parchea la resolución de módulos de Vite para preferir el build de navegador de Svelte sobre el build de servidor, pero el parche solo se activa si "node" ya está en la lista de condiciones de resolución. Eso es cierto para Vitest en modo jsdom (que técnicamente sigue ejecutándose en Node), pero no para el modo navegador real. El guard queda en silencio sin hacer nada, deja un array vacío en su lugar, y ese array vacío sobrescribe los valores por defecto sensatos de Vite. Terminé fijando yo mismo la condición de resolución en vez de confiar en el plugin para ese proyecto.
La tercera: incluso después de esa solución, seguía fallando de forma intermitente, con el mismo error de SSR, porque el pre-empaquetado de dependencias de Vite se activaba a mitad de la corrida (“new dependencies optimized… reloading”) y reseteaba la condición resuelta para la prueba que estuviera en curso. Agregar la nueva dependencia a optimizeDeps.include desde el principio evitó que el reload ocurriera del todo.
Ninguno de estos tres problemas tenía que ver con mi CSS. Todos tenían que ver con que las herramientas de prueba no estaban construidas pensando en el modo de navegador real como caso de primera clase, lo cual tiene sentido, ya que el modo jsdom ha sido el predeterminado durante años y el modo navegador es más nuevo. Cuando una prueba falla de una manera que no coincide con lo que se cambió, vale la pena revisar si la propia infraestructura de pruebas tiene un borde sin probar, sobre todo justo después de adoptar un modo “nuevo” de una herramienta vieja. Las tres soluciones terminaron siendo pequeñas una vez identificadas. Encontrarlas no lo fue.
Ahora corren cuatro verificaciones de contraste en Chromium real, en ambos temas, sobre las vistas de wizard, dashboard, carga y error. npm run a11y:contrast es un comando aparte de la validación jsdom npm run a11y, deliberadamente, una es rápida y estructural, la otra es real y más lenta, y ninguna oculta ya el punto ciego de la otra.
Adenda: la prueba que pasó de todos modos
Unas horas después de escribir lo anterior, tomé tres capturas de la app real corriendo en modo oscuro: un contador de “Worklist” que apenas se podía ver, una píldora de “Vault: 1 item” que se leía como una mancha tenue, y el diálogo de confirmación de idea capturada donde la ruta de la carpeta y sus botones de copiar/abrir habían quedado casi totalmente invisibles.
Mi prueba de contraste decía 4 de 4, en verde, en ambos temas. Confiar en eso como panorama completo estaba mal.
Esto es lo que pasó. Cuando construí la paleta oscura, armé una capa de tokens: colores crudos como --green-100 alimentan nombres semánticos como --accent-soft, y son los nombres semánticos los que realmente cambian entre claro y oscuro. Esa es la estructura correcta. Pero nada impide que un componente se salte la capa semántica y tome el color crudo directamente. Siete lugares hicieron exactamente eso: background: var(--green-100), debajo de texto coloreado con un token semántico que sí cambia. En modo claro esto se ve completamente normal, porque el valor semántico por defecto resulta ser igual al valor crudo de todos modos. Al pasar a oscuro, el texto se aclara mientras el fondo se queda exactamente donde estaba. Casi la misma colisión de tono, siete veces, en siete archivos distintos, porque es un error fácil de cometer una vez y un error fácil de cometer independientemente siete veces.
La parte peor es por qué mi propia prueba no lo detectó. El verificador de contraste de axe-core no siempre da una respuesta limpia de violación o aprobación. Para formas pequeñas con esquinas redondeadas y padding ajustado, insignias, píldoras, botones de ícono, a menudo reporta “incomplete” en su lugar, porque no puede resolver con confianza un solo rectángulo sólido de fondo detrás del texto. Mi aserción verificaba violations e ignoraba incomplete por completo. Cada uno de estos siete bugs estaba exactamente en esa forma: una insignia, una píldora, un botón de ícono. La prueba no mentía sobre cero violaciones. Simplemente nunca me dijo que se había rendido en silencio con los elementos donde vivían los bugs.
Y por separado, algo más tonto: uno de los tres bugs visibles estaba en un estado del diálogo que mi prueba ni siquiera llegaba a renderizar. Probé el formulario de captura. Nunca probé cómo se ve el diálogo después de enviarlo, que es exactamente donde viven la caja de ruta de carpeta y los botones de ícono.
Arreglé los siete enrutándolos por el mismo token semántico que ya usaba todo lo demás, agregué una prueba para el estado de diálogo que faltaba, y agregué una línea de log que se imprime cada vez que axe devuelve “incomplete” en vez de tragárselo en silencio. Eso no convierte un incomplete en una falla dura, la mayoría de los incompletes no tienen relación con bugs reales, pero al menos ahora es visible en vez de invisible.
La lección no es “escribir más pruebas.” Es que una validación automatizada en verde y una función que realmente funciona bien son dos afirmaciones distintas, y la brecha entre ambas es exactamente las formas y estados que no se pensó en revisar. Me había dicho a mí mismo que el trabajo de contraste estaba terminado. Hizo falta mirar la app corriendo para saber que no lo estaba.
Lecturas relacionadas
Seis ítems pequeños de UI, y los dos casi-desastres escondidos adentro
Un bloque de CSS que grep decía que estaba muerto pero del que dependía una prueba, y una sola línea de localStorage que rompió treinta y nueve pruebas sin relación por culpa de una actualización de Node.
Un anillo de foco que no debía estar a máxima intensidad
Una línea verde brillante debajo de la barra superior en cada inicio, un intento de captura de pantalla que terminó mostrando las ventanas equivocadas, y una muestra de color que probó las matemáticas del color pero no la respuesta.
El contorno estaba bien, la caja alrededor de la cual se dibujaba no
Un anillo de foco que abarcaba toda una fila de encabezado, una ventana de WebDriver que macOS nunca convirtió en ventana activa, y el truco del span en línea que reduce un contorno hasta las palabras que contiene.