Bhinish Dhiman

Cómo poner la verificación de contraste en CI para que falle el build

Traducido del original en inglés. Leer el original

Escribe tus tokens de color en una prueba que compruebe cada par de texto sobre superficie contra su requisito de contraste, y ejecútala en CI. Así, un cambio de token que rompe el contraste hace fallar el build como cualquier otra regresión, en lugar de publicarse y quedar a la espera de una auditoría.

El contraste se revisa cuando alguien se acuerda de revisarlo. Ese es todo el problema. Es un paso manual al final de un proceso, compite con una fecha de entrega y pierde.

La solución es convertirlo en una aserción automática, que es como se maneja cualquier otro tipo de regresión.

Por qué falla la revisión manual

Llega demasiado tarde. El contraste suele revisarse cerca del final, cuando el color ya sostiene el diseño y cambiarlo implica reabrir decisiones cerradas.

No sobrevive a las refactorizaciones. Alguien aclara un gris un paso en el archivo de tokens, seis meses después de la auditoría, y nada lo detecta.

No escala con los temas. Claro y oscuro duplican la superficie a revisar, y el lado oscuro es justo donde WCAG 2 es menos fiable.

Nadie vuelve a revisar un sistema de diseño. Un solo token con contraste insuficiente se propaga a todos los productos que lo usan.

Cada uno de estos puntos es la señal de algo que debería ser una prueba.

Qué comprobar

No todos los pares posibles, sino los que de verdad publicas. En la mayoría de los sistemas es una lista corta:

  • Texto de cuerpo sobre el fondo de la página
  • Texto de cuerpo sobre una tarjeta o superficie elevada
  • Texto atenuado o secundario sobre ambos
  • Color de enlace sobre ambos
  • Etiqueta del botón sobre el fondo del botón, para cada variante
  • Color del borde sobre el fondo que lo rodea, si el borde identifica un control

Multiplica por el número de temas. Entre doce y veinte aserciones cubren la mayoría de los sistemas de diseño.

Un ejemplo práctico

import { describe, expect, it } from 'vitest';
import { contrastRatio } from './wcag';

const LIGHT = {
  ink: '#10141c',
  inkSoft: '#2a313d',
  muted: '#5a6473',
  paper: '#f7f8fa',
  card: '#ffffff',
  accent: '#006ad5',
};

const PAIRS = [
  { name: 'body on paper', fg: LIGHT.inkSoft, bg: LIGHT.paper, required: 4.5 },
  { name: 'body on card', fg: LIGHT.inkSoft, bg: LIGHT.card, required: 4.5 },
  { name: 'muted on paper', fg: LIGHT.muted, bg: LIGHT.paper, required: 4.5 },
  { name: 'heading on paper', fg: LIGHT.ink, bg: LIGHT.paper, required: 3 },
  { name: 'button label', fg: '#ffffff', bg: LIGHT.accent, required: 4.5 },
];

describe('design tokens pass contrast', () => {
  for (const p of PAIRS) {
    it(`${p.name} clears ${p.required}:1`, () => {
      const ratio = contrastRatio(p.fg, p.bg);
      expect(ratio, `${p.name}: got ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(p.required);
    });
  }
});

El mensaje de error importa. expected 4.21 to be greater than or equal to 4.5 pone a alguien a buscar. muted on paper: got 4.21:1 le dice exactamente qué token cambiar.

Agrega la aserción APCA para todo lo pequeño

WCAG 2 no ve la tipografía, así que un ratio aprobatorio en una etiqueta de 12px no prueba nada. Si te importa la experiencia de lectura y no solo la cifra de cumplimiento, comprueba también APCA, y exige umbrales distintos a cada rol de texto.

APCA pide Lc 90 para columnas de texto de cuerpo y Lc 60 para contenido y subtítulos. Aplicar el umbral del cuerpo a una etiqueta de 13px la obligaría a ser casi negra y aplastaría tu jerarquía, lo que no ayuda a nadie. Comprueba el estándar que de verdad corresponde a cada rol.

Lo que encontramos al hacerlo

Agregamos esto a nuestro propio sitio después de construir la herramienta, y esperábamos que pasara. Encontró siete fallas de APCA en elementos de interfaz que pasaban WCAG 2 en todas partes. La peor era el color de los enlaces en modo oscuro, con Lc 37 frente a un requisito de 75: realmente difícil de leer, e invisible para cualquier comprobación basada en ratios.

Dos de las correcciones fueron tipográficas y no de color. Nuestras etiquetas de 11px no podían aprobar con ningún contraste, así que pasaron a 13px con peso 500. Las etiquetas de nuestros botones no podían aprobar en blanco sobre el color de acento a 15px, así que pasaron a 17px con peso 600.

Ninguna de esas correcciones es algo a lo que llegarías retocando un valor hex.

Calcula la corrección en lugar de adivinarla

Cuando una aserción falla, la tentación es oscurecer el color hasta que pase. Así es como las paletas se van enturbiando con el tiempo.

Es mejor calcular el cambio mínimo: fija el tono y el croma, y busca en el eje de luminosidad el valor más cercano que supere el umbral. Obtienes el cambio visual más pequeño que resuelve el problema en lugar de uno arbitrario, y el color sigue siendo reconocible.

Generamos así cada valor corregido de nuestra propia paleta, en lugar de elegirlos a ojo. Varios de los cambios quedaron por debajo de ΔE 1: imperceptibles, y aun así la diferencia entre fallar y aprobar.

La parte cultural

La razón por la que esto funciona no es técnica. Es que un build que falla es una señal compartida e inequívoca, mientras que una revisión de accesibilidad es la opinión de una persona compitiendo con una fecha de lanzamiento.

Una vez que el contraste es una prueba, nadie tiene que defenderlo. Simplemente sale en rojo, y lo rojo se arregla.

Nuestro verificador de contraste es la mitad interactiva de todo esto: cuando una prueba falla, te da el color más cercano que aprueba para que lo pegues de vuelta en el archivo de tokens.