Módulo 8: Project Build Mercados Design System

Audita el contraste del sistema completo

Descripción

Esta es la lección que salda la deuda de las dos anteriores. En la lección 2 definiste color.on-primary con un override de tema que quizás te pareció innecesario —¿por qué no simplemente text-white?—. En la lección 3 lo usaste sin cuestionarlo. Ahora vas a medir por qué hacía falta, con el mismo contrastRatio del módulo 4: el algoritmo real de luminancia relativa de WCAG, que no opina, calcula. Vas a auditar los cuatro pares de color de Mercado —texto principal, texto secundario, texto del botón, texto del badge— en los dos temas, y vas a encontrar, en el camino, el bug exacto que color.on-primary existe para evitar.

El punto de partida es deliberadamente el error: reconstruir el Button del módulo 5 tal como quedó ahí, con text-white fijo, y medirlo en modo oscuro. Va a fallar. Después vas a aplicar el fix —cambiar el texto fijo por el token color.on-primary de la lección 2— y vas a medir que ahora pasa en los dos temas. Es el mismo patrón del proyecto del módulo 4: elección inicial (a veces razonable a simple vista), auditoría, número, fix. Solo que ahora el error no es hipotético — es el componente real de un módulo anterior de esta misma guía, con un bug real que la auditoría descubre.

Conexión con el módulo. Esta lección integra el módulo 4 completo —contrastRatio con el algoritmo de luminancia real, passesWCAG con los umbrales AA/AAA, la prueba de oro contra #000000/#ffffff— y lo aplica sobre el CSS que generaste en la lección 3. Es también el punto donde el capstone dialoga con el módulo 5: el Button que construyes en la próxima lección ya viene con el fix que aquí encuentras, no con el text-white original.

Una analogía: el control de calidad que encuentra el defecto antes del envío

Una fábrica que arma un producto en varias estaciones —una arma la carcasa, otra instala la electrónica, otra pinta— no confía en que "si cada estación hizo bien su parte, el producto final funciona". Al final de la línea hay una estación de control de calidad que prueba el producto ensamblado completo, no cada pieza por separado —porque hay defectos que solo aparecen en la combinación—: una carcasa bien hecha y una batería bien instalada pueden, juntas, generar un roce que nadie vio en la inspección individual. El control de calidad no rehace el trabajo de las estaciones anteriores; lo verifica con instrumentos —un medidor, no un vistazo—, y cuando encuentra un defecto, lo manda de vuelta a la estación responsable con el número exacto: "el par X no cumple, el ratio medido es Y". Esta lección es esa estación. El Button del módulo 5 se armó bien según sus propias reglas —variantes, base, defaultVariants— pero nadie, en esa línea de ensamblaje, midió si el texto se leía en los dos temas del sistema completo. Aquí lo medimos, con el instrumento correcto, y lo mandamos de vuelta con el número.

Ejemplo trabajado: el bug, medido, y el fix

Primero, la prueba de oro del algoritmo —esto siempre corre primero, para confiar en el instrumento antes de usarlo—:

// contrast.js — auditoria de contraste del sistema completo de Mercado.
function hexToRgb(hex) { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function linearize(ch) { const c = ch / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function relativeLuminance(hex) { const [r, g, b] = hexToRgb(hex).map(linearize); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
function contrastRatio(fg, bg) {
  const L1 = relativeLuminance(fg), L2 = relativeLuminance(bg);
  return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
}
function passesWCAG(ratio, { large } = {}) {
  const aa = large ? 3 : 4.5, aaa = large ? 4.5 : 7;
  if (ratio >= aaa) return 'AAA';
  if (ratio >= aa)  return 'AA';
  return 'FAIL';
}
const r2 = (x) => Math.round(x * 100) / 100;

// los tokens resueltos de la leccion 2 (resumidos: solo los valores que aqui hacen falta).
function resolveToken(name, theme) {
  const tokens = {
    component: {
      'card.bg': 'color.surface', 'card.text': 'color.text', 'card.text-muted': 'color.text-muted',
      'button.bg': 'color.primary', 'button.text': 'color.on-primary',
      'badge.bg': 'color.danger', 'badge.text': 'color.on-danger',
    },
    semantics: {
      'color.primary':    { light: 'blue.600', dark: 'blue.400' },
      'color.surface':    { light: 'white',    dark: 'gray.900' },
      'color.text':       { light: 'gray.900', dark: 'gray.50'  },
      'color.text-muted': { light: 'gray.500', dark: 'gray.400' },
      'color.danger':     { light: 'red.500',  dark: 'red.400'  },
      'color.on-primary': { light: 'white',    dark: 'gray.900' },
      'color.on-danger':  { light: 'gray.900', dark: 'gray.900' },
    },
    primitives: {
      'blue.600': '#2563eb', 'blue.400': '#60a5fa', 'white': '#ffffff',
      'gray.50': '#f9fafb', 'gray.400': '#9ca3af', 'gray.500': '#6b7280', 'gray.900': '#111827',
      'red.500': '#ef4444', 'red.400': '#f87171',
    },
  };
  if (name in tokens.component)  return resolveToken(tokens.component[name], theme);
  if (name in tokens.semantics)  return resolveToken(tokens.semantics[name][theme], theme);
  return tokens.primitives[name];
}

console.log('=== 0) prueba de oro del algoritmo ===');
console.log('  #000000/#ffffff = ' + r2(contrastRatio('#000000', '#ffffff')) + ':1 (debe ser 21)');
console.log('  #ffffff/#ffffff = ' + r2(contrastRatio('#ffffff', '#ffffff')) + ':1 (debe ser 1)');

// 1) el Button del modulo 5, TAL COMO QUEDO: bg-primary text-white (fijo).
console.log('\n=== 1) auditoria: el Button de M5 heredado tal cual (bg-primary text-white) ===');
const buttonTextWhiteFixed = '#ffffff'; // el "text-white" que M5 escribio, literal.
for (const theme of ['light', 'dark']) {
  const bg = resolveToken('button.bg', theme);
  const ratio = contrastRatio(buttonTextWhiteFixed, bg);
  console.log('  ' + theme.padEnd(6) + ' button.bg=' + bg + '  fg=white(fijo)  ' + r2(ratio) + ':1  ' + passesWCAG(ratio));
}

// 2) el fix: button.text -> color.on-primary (token con tema, de la leccion 2).
console.log('\n=== 2) el fix: button.text -> color.on-primary (token, no hex fijo) ===');
for (const theme of ['light', 'dark']) {
  const bg = resolveToken('button.bg', theme);
  const fg = resolveToken('button.text', theme);
  const ratio = contrastRatio(fg, bg);
  console.log('  ' + theme.padEnd(6) + ' button.bg=' + bg + '  button.text=' + fg + '  ' + r2(ratio) + ':1  ' + passesWCAG(ratio));
}

// 3) la auditoria completa: los 4 pares x 2 temas.
console.log('\n=== 3) auditoria completa de los pares de Mercado (los 2 temas) ===');
console.log('  ' + 'par'.padEnd(28) + 'ratio'.padEnd(10) + 'nivel');
console.log('  ' + '-'.repeat(48));
const pairs = [
  ['card.text / card.bg',       'card.text',      'card.bg'],
  ['card.text-muted / card.bg', 'card.text-muted', 'card.bg'],
  ['button.text / button.bg',   'button.text',     'button.bg'],
  ['badge.text / badge.bg',     'badge.text',      'badge.bg'],
];
for (const theme of ['light', 'dark']) {
  console.log('  -- tema ' + theme + ' --');
  for (const [name, fgTok, bgTok] of pairs) {
    const fg = resolveToken(fgTok, theme), bg = resolveToken(bgTok, theme);
    const ratio = contrastRatio(fg, bg);
    const level = passesWCAG(ratio);
    const mark = level === 'FAIL' ? '  <-- FALLA' : '';
    console.log('  ' + name.padEnd(28) + (r2(ratio) + ':1').padEnd(10) + level + mark);
  }
}

Qué esperar. Al correr node contrast.js, la salida es exactamente esta:

=== 0) prueba de oro del algoritmo ===
  #000000/#ffffff = 21:1 (debe ser 21)
  #ffffff/#ffffff = 1:1 (debe ser 1)

=== 1) auditoria: el Button de M5 heredado tal cual (bg-primary text-white) ===
  light  button.bg=#2563eb  fg=white(fijo)  5.17:1  AA
  dark   button.bg=#60a5fa  fg=white(fijo)  2.54:1  FAIL

=== 2) el fix: button.text -> color.on-primary (token, no hex fijo) ===
  light  button.bg=#2563eb  button.text=#ffffff  5.17:1  AA
  dark   button.bg=#60a5fa  button.text=#111827  6.98:1  AA

=== 3) auditoria completa de los pares de Mercado (los 2 temas) ===
  par                         ratio     nivel
  ------------------------------------------------
  -- tema light --
  card.text / card.bg         17.74:1   AAA
  card.text-muted / card.bg   4.83:1    AA
  button.text / button.bg     5.17:1    AA
  badge.text / badge.bg       4.71:1    AA
  -- tema dark --
  card.text / card.bg         16.98:1   AAA
  card.text-muted / card.bg   6.99:1    AA
  button.text / button.bg     6.98:1    AA
  badge.text / badge.bg       6.41:1    AA

Lee el bloque 1 como el hallazgo real de esta lección. En modo claro, text-white fijo sobre button.bg (#2563eb, blue.600) da 5.17:1 — pasa AA sin problema, y por eso nadie lo notó en el módulo 5: se veía bien, porque en el único tema que probablemente se miró (claro, el default de la mayoría de los navegadores y capturas de pantalla), funcionaba. Pero en modo oscuro, button.bg resuelve a #60a5fa (blue.400, un azul mucho más claro, elegido justamente para resaltar sobre el fondo casi negro del tema) y el mismo white fijo da 2.54:1FALLA, ni siquiera cerca del umbral de 4.5. El texto del botón, en el tema oscuro, es casi ilegible. Es exactamente el bug que la lección 2 te pidió tomar "con fe": ahora tienes el número que lo prueba.

El bloque 2 es el fix, medido. Cambiar button.text de white fijo a color.on-primary (el token de la lección 2, que resuelve white en claro y gray.900 en oscuro) da 5.17:1 en claro —igual que antes, porque el valor no cambió ahí— y 6.98:1 en oscuro —AA, muy por encima del umbral—. El fix no fue "usar un gris más oscuro al azar": fue reconocer que el color de texto correcto depende del tema, exactamente como el fondo, y dejar que el sistema de tokens lo resuelva en vez de fijarlo.

El bloque 3 es la auditoría completa, y confirma que el resto del sistema ya estaba bien —cero fallas en los ocho pares medidos—. card.text/card.bg da AAA en los dos temas (el par de mayor contraste, texto principal sobre superficie principal). card.text-muted/card.bg da AA en los dos —y aquí se confirma por qué la lección 2 necesitó steps distintos por tema (gray.500 en claro, gray.400 en oscuro): si hubieras usado el mismo gray.400 en los dos temas, en claro habría dado el 2.54:1 que ya viste fallar en el proyecto del módulo 4—. Y badge.text/badge.bg da AA en los dos con el mismo gray.900 —confirmando la asimetría que la lección 2 adelantó: on-danger no se invierte como on-primary, y ahora sabes por qué: el rojo de color.danger (red.500 claro, red.400 oscuro) tiene luminosidades parecidas en los dos temas, así que el mismo texto oscuro funciona en ambos, mientras que el azul de color.primary cambia de luminosidad mucho más entre temas y necesita que el texto se invierta.

Profundización: por qué la auditoría se hace sobre el sistema, no sobre cada módulo aislado

Si hubieras auditado el Button dentro del módulo 5, probablemente solo lo habrías probado en el tema que tenías activo en ese momento —y el bug se habría escondido, porque en claro pasa—. El bug de text-white en modo oscuro es precisamente el tipo de defecto que el control de calidad de fábrica existe para atrapar: uno que cada pieza por separado parece resolver bien, y que solo aparece al combinar piezas de módulos distintos —el Button del módulo 5 con el theming del módulo 2, verificados juntos—. Es la razón de fondo por la que este capstone existe como módulo separado, y no como "ya lo vimos, no hace falta repetirlo": un sistema de diseño no se termina de probar módulo por módulo, se prueba ensamblado.

Errores comunes

Auditar solo el tema que se tiene abierto en el navegador. Qué pasa: se revisa el contraste del Button en modo claro, se ve bien, y se da por aprobado sin cambiar a modo oscuro. Por qué pasa: el navegador (o el sistema operativo) suele abrir en claro por default, y cambiar de tema es un paso extra que se salta. Cómo detectarlo: tu auditoría tiene la mitad de las filas que debería —un tema, no dos—. Cómo corregirlo: cada par de color del sistema se audita en los dos temas, siempre, sin excepción —como el bloque 3 de esta lección—. Un color que pasa en claro no dice nada sobre si pasa en oscuro; son dos pares de hex completamente distintos, aunque compartan el nombre del token.

Fijar el color de texto "porque ya se ve bien" en vez de dejarlo resolver por token. Qué pasa: se escribe text-white (o cualquier hex fijo) directamente en un componente, sin pasar por un token con override de tema. Por qué pasa: es el camino más corto, y si el componente se prueba una sola vez en un solo tema, "funciona". Cómo detectarlo: buscas en tu código clases de color fijas (text-white, bg-[#111827]) en componentes que también usan tokens de tema (bg-primary) — la mezcla es la señal de que alguien fijó "a mano" lo que debía resolver el sistema. Cómo corregirlo: todo color que interactúa con un fondo temático (texto sobre un botón, sobre un badge, sobre cualquier superficie que cambia de valor por tema) necesita su propio token on-X, medido en los dos temas — el patrón exacto de color.on-primary y color.on-danger.

Confundir "pasa en un tema" con "el sistema es accesible". Qué pasa: se reporta "la auditoría de contraste está aprobada" después de medir un solo par, o un solo tema, sin cubrir la matriz completa. Por qué pasa: un solo número que pasa se siente como suficiente evidencia. Cómo detectarlo: tu reporte de auditoría tiene menos de ocho filas (cuatro pares × dos temas) para un sistema con cuatro pares de color y dos temas. Cómo corregirlo: la auditoría completa es la matriz completa — cada par, en cada tema, sin saltarse ninguna combinación. Es el mismo principio que la ficha de responsive+dark del módulo 6: la cobertura completa es la que da la garantía, no una muestra.

Ejercicios

Ejercicio 1 — Audita un quinto par. Mercado agrega un enlace "Ver más" en color.primary sobre el fondo color.surface (no sobre color.muted). Usando resolveToken y contrastRatio de esta lección, audita ese par (color.primary como texto, color.surface como fondo) en los dos temas.

Ver solución
for (const theme of ['light', 'dark']) {
  const fg = resolveToken('color.primary', theme);
  const bg = resolveToken('color.surface', theme);
  const ratio = contrastRatio(fg, bg);
  console.log(theme, fg, bg, r2(ratio), passesWCAG(ratio));
}
// light #2563eb #ffffff 5.17 AA
// dark  #60a5fa #111827 6.98 AA

Fíjate en algo interesante: estos números son idénticos a los del bloque 2 (button.text / button.bg) — porque son, matemáticamente, el mismo par de colores invertido (blue.600 sobre blanco en vez de blanco sobre blue.600), y contrastRatio es simétrico —contrastRatio(fg, bg) === contrastRatio(bg, fg), porque toma el máximo y el mínimo de las dos luminancias, no le importa cuál es "el texto" y cuál "el fondo"—. Un enlace en color.primary sobre color.surface es tan legible como el texto de un botón en color.on-primary sobre color.primary invertido, en este caso particular.

Ejercicio 2 — Explica la asimetría sin correr código. Con los ratios ya medidos en el "Qué esperar" (badge.text/badge.bg = 4.71 en claro y 6.41 en oscuro, ambos con on-danger = gray.900), explica en una frase por qué el ratio en oscuro es mayor que en claro, aunque el color de texto sea el mismo en los dos.

Ver solución

Porque el fondo cambia de luminancia entre temas, aunque el texto no cambie. En claro, badge.bg = red.500 (#ef4444); en oscuro, badge.bg = red.400 (#f87171) — y red.400 es más claro que red.500 (mayor luminancia). Con el mismo texto oscuro (gray.900) encima, un fondo más claro da más contraste, no menos —el ratio depende de la diferencia de luminancia entre las dos superficies, y red.400 está más lejos de gray.900 en la escala de luminancia que red.500—. La lección: el ratio no es una propiedad fija del color de texto, es una propiedad de la pareja — cambiar cualquiera de los dos lados cambia el número, aunque el otro se mantenga igual.

Ejercicio 3 — Encuentra el par que fallaría. Sin ejecutar nada: si Mercado decidiera usar color.text-muted (en vez de color.on-primary) como color de texto del Button, ¿pasaría o fallaría en modo oscuro? Usa los valores ya conocidos: button.bg dark = #60a5fa, color.text-muted dark = #9ca3af (gray.400).

Ver solución

Fallaría, y por mucho. gray.400 (#9ca3af) y blue.400 (#60a5fa) son dos colores de luminosidad media-alta — ninguno es claramente "oscuro" ni claramente "claro" — así que la diferencia de luminancia entre ellos es chica, y un ratio chico significa contraste bajo. (El valor real, si lo corres, da alrededor de 1.6:1 — muy por debajo del mínimo de 3:1 incluso para texto grande.) La lección: color.text-muted fue diseñado para leerse sobre color.surface (un fondo neutro, blanco o casi negro) — no es un color de texto "universal" que sirva sobre cualquier fondo. Cada token on-X/text-X se audita contra el fondo específico con el que va a convivir; no se reutiliza un color de texto que funcionó en otro contexto sin volver a medir.

Resumen y siguiente paso

En esta lección encontraste un bug real con el instrumento correcto: el Button del módulo 5, con text-white fijo, pasa contraste en modo claro (5.17:1, AA) pero falla en modo oscuro (2.54:1, FAIL) — y lo arreglaste enrutando el color por el token color.on-primary de la lección 2, que da 6.98:1 en oscuro. Auditaste los cuatro pares de color de Mercado en los dos temas —ocho mediciones, cero fallas— y confirmaste, con números, las dos asimetrías que la lección 2 solo había insinuado: on-primary se invierte entre temas porque color.primary cambia mucho de luminosidad; on-danger no se invierte porque color.danger no cambia tanto. Con la estación de control de calidad entendiste por qué esta auditoría tenía que pasar por el sistema ensamblado, no por cada módulo aislado — el bug solo aparece en la combinación.

Antes de avanzar deberías poder: explicar por qué text-white fijo puede pasar en un tema y fallar en otro; ejecutar contrastRatio y passesWCAG sobre cualquier par de tokens de Mercado; y decir, sin mirar la tabla, cuáles de los ocho pares dan AAA y cuáles dan AA (pista: el único AAA es el texto principal sobre la superficie principal, en los dos temas).

La lección 5 construye el Button —esta vez con el fix ya incorporado, bg-primary text-on-primary— y el ProductCard, los dos como componentes con variantes, reutilizando el mismo variants() del módulo 5 para dos componentes distintos. El color que aquí verificaste que se lee es, a partir de la próxima lección, el color que tus componentes van a usar por defecto.

Recursos

  • WCAG 2.1, "Contrast (Minimum)" — w3.org/WAI/WCAG21/Understanding/contrast-minimum.html. El umbral AA (4.5:1 texto normal, 3:1 texto grande) que esta auditoría aplica sobre los ocho pares. En inglés.
  • WebAIM, "Contrast Checker" — webaim.org/resources/contrastchecker. Verifica ahí mismo el par que falla (#ffffff sobre #60a5fa = 2.54:1) y el que lo arregla (#111827 sobre #60a5fa = 6.98:1). En inglés.
  • web.dev, "Learn Accessibility: Color contrast" — web.dev/learn/accessibility/color-contrast. El porqué del umbral, más allá del número — a quién beneficia el contraste alto y en qué condiciones. En inglés.
  • shadcn/ui, "Theming" — ui.shadcn.com/docs/theming. Los pares primary/primary-foreground de producción, ya con el mismo patrón de token con override que color.on-primary. En inglés.