Módulo 4: Scales Color And Contrast

Calcular el contrastRatio

Descripción

La lección 5 te dio los umbrales; esta te da la máquina que produce el número que se compara con ellos. Vas a implementar contrastRatio(fg, bg) con el algoritmo real de WCAG —el mismo que usan las herramientas de accesibilidad y que la ley exige—, en cuatro pasos: parsear el hex a canales RGB, linealizar cada canal (deshacer la corrección gamma de sRGB), calcular la luminancia relativa (donde el verde pesa siete veces más que el azul, porque así ve el ojo), y sacar el ratio con la fórmula (claro + 0.05) / (oscuro + 0.05). Y lo vas a verificar contra valores conocidos: #000000 sobre #ffffff debe dar exactamente 21:1, #ffffff sobre sí mismo 1:1. Al terminar, el contraste deja de ser algo que una web calcula por ti: lo calculas tú, y sabes por qué cada paso está.

Conexión con el módulo. Es el corazón técnico del módulo. Implementa el número que la lección 5 definió y que la 7 usará para auditar pares reales. Retoma el eje de claridad de la lección 4 y lo hace honesto: allá usamos el promedio de canales como proxy (que mentía sobre la claridad percibida); aquí lo reemplazamos por la luminancia relativa, la medida real. En el proyecto (L8), este contrastRatio es el que audita el product-card.

Una analogía: por qué el ojo no cuenta la luz "en crudo"

Imagina que quieres saber qué tan brillante se ve una pantalla. Podrías medir la energía física que emite —los fotones que salen— con un sensor. Pero eso no te diría cómo la percibe una persona, por dos razones que el algoritmo de WCAG corrige, una en cada uno de sus dos pasos centrales.

La primera: el ojo no es igual de sensible a todos los colores. Es muchísimo más sensible al verde que al azul —un verde y un azul que emiten la misma energía física, el verde se ve mucho más brillante—. Por eso la luminancia pondera: 0.2126·R + 0.7152·G + 0.0722·B. El verde aporta el 71% del brillo percibido, el rojo el 21%, el azul apenas el 7%. Es como pesar ingredientes en una receta donde cada uno cuenta distinto: no sumas gramos iguales, ponderas por su efecto.

La segunda, más sutil: los valores de color que escribimos (el ff de #ffffff, o el 128 de un gris medio) no son proporcionales a la luz que representan. Están codificados con gamma —una curva que sRGB aplica para aprovechar mejor los bits en los tonos oscuros, donde el ojo distingue más—. Un valor de canal de 128 (la mitad de 255) no emite la mitad de la luz de 255; emite bastante menos. Antes de ponderar, hay que deshacer esa curva para volver a la luz "lineal" —a la energía real—. Es como una foto que viene con un filtro aplicado: antes de medir los colores verdaderos, quitas el filtro.

Así que el algoritmo hace, en orden: quita el filtro gamma (linealiza), pesa los canales por sensibilidad del ojo (luminancia), y recién entonces compara las dos luminancias como un ratio. Cada paso corrige una forma en que "el número crudo del color" difiere de "el brillo que el ojo percibe".

Guarda la imagen: el valor de un color no es la luz que emite; el algoritmo primero deshace la codificación gamma (linealiza), luego pondera los canales como el ojo (verde 71%, rojo 21%, azul 7%), y así obtiene la luminancia real que el contraste compara.

Ejemplo trabajado: el algoritmo real, verificado

Aquí está contrastRatio completo, en los cuatro pasos, sin librerías. Léelo con la analogía en mente: linearize deshace la gamma, relativeLuminance pondera, contrastRatio compara. Y lo verificamos contra valores que tienen que salir exactos —si #000000 sobre #ffffff no da 21, el algoritmo está mal—.

// L6 — el algoritmo REAL de contraste de WCAG. Cuatro pasos, sin librerias.

// 1) hex -> canales RGB en 0..255.
function hexToRgb(hex) {
  const n = parseInt(hex.slice(1), 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

// 2) linealizacion sRGB: normaliza el canal a 0..1 y deshace la correccion gamma.
function linearize(channel255) {
  const c = channel255 / 255;
  return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}

// 3) luminancia relativa: cuanta luz emite el color a ojo humano (verde pesa mas).
function relativeLuminance(hex) {
  const [r, g, b] = hexToRgb(hex).map(linearize);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

// 4) ratio de contraste: (mas claro + 0.05) / (mas oscuro + 0.05).
function contrastRatio(fg, bg) {
  const L1 = relativeLuminance(fg);
  const L2 = relativeLuminance(bg);
  const lighter = Math.max(L1, L2);
  const darker  = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

// redondeo a 2 decimales para leer el ratio como "N.NN:1".
const r2 = (x) => Math.round(x * 100) / 100;

console.log('=== verificacion contra valores conocidos ===\n');
console.log('#000000 sobre #ffffff : ' + r2(contrastRatio('#000000', '#ffffff')) + ':1  (debe ser 21)');
console.log('#ffffff sobre #ffffff : ' + r2(contrastRatio('#ffffff', '#ffffff')) + ':1  (debe ser 1)');
console.log('#808080 sobre #ffffff : ' + r2(contrastRatio('#808080', '#ffffff')) + ':1  (gris medio, intermedio)');
console.log('#767676 sobre #ffffff : ' + r2(contrastRatio('#767676', '#ffffff')) + ':1  (el gris limite de AA)');

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== verificacion contra valores conocidos ===

#000000 sobre #ffffff : 21:1  (debe ser 21)
#ffffff sobre #ffffff : 1:1  (debe ser 1)
#808080 sobre #ffffff : 3.95:1  (gris medio, intermedio)
#767676 sobre #ffffff : 4.54:1  (el gris limite de AA)

Lee las cuatro líneas como la prueba de que el algoritmo es correcto —no un modelo pedagógico, el real—. La primera es la verificación de oro: negro puro sobre blanco puro da exactamente 21:1, el máximo posible del contraste. Si tu implementación diera 20.9 o 21.3, algún paso estaría mal (una constante de gamma incorrecta, un ponderador cambiado). Da 21 clavado, así que los cuatro pasos están bien. La segunda: blanco sobre blanco da 1:1, el mínimo —dos colores idénticos, luminancias iguales, (L+0.05)/(L+0.05)=1—. Entre esos dos extremos vive todo el resto.

La tercera línea desarma una intuición común. El gris #808080 es "el gris del medio" en valor de canal (128, la mitad exacta de 255). Uno esperaría que su contra blanco diera algo cercano a la mitad del rango, tipo 10:1. Pero da 3.95:1 —mucho más cerca del extremo bajo—. ¿Por qué? Por la gamma: 128 de canal no es la mitad de la luz; linealizado da una luminancia bastante menor a 0.5, así que el gris "medio" está, en luz real, más cerca del blanco de lo que su número sugiere, y contrasta poco con él. Este es exactamente el punto de la analogía: el valor del color engaña; la luminancia no. Un #808080 sobre blanco falla AA para texto normal (3.95 < 4.5), aunque "128 sobre 255" suene a mucho contraste.

La cuarta es un valor que conviene memorizar: #767676 sobre blanco da 4.54:1 —el gris más claro que aún pasa AA para texto normal—. Es un número de referencia real (lo verás citado en herramientas): si necesitas texto gris sobre blanco que pase AA, no puede ser más claro que #767676. Cualquier gris con un valor de canal mayor (más claro) falla.

Una pregunta para asegurar que entendiste la gamma: ¿por qué el algoritmo tiene esa rama c <= 0.03928 ? c/12.92 : ...? (Porque la curva de sRGB no es una potencia pura: en los tonos muy oscuros (canal ≤ 0.03928 normalizado) usa un tramo lineal (c/12.92), y en el resto la potencia ((c+0.055)/1.055)^2.4. Es la definición exacta de sRGB —un tramo recto cerca del negro, una curva en el resto—. Omitir la rama lineal daría luminancias ligeramente mal para colores oscuros, y #000000 sobre #ffffff ya no daría 21 clavado. Por eso está: es lo que hace al algoritmo el real y no una aproximación.)

Profundización: de dónde sale cada constante, y el +0.05

Ninguna de las constantes del algoritmo es arbitraria; cada una viene de la especificación de sRGB o de WCAG. Vale la pena saber qué es cada una para no "limpiarlas" por error:

  • / 255: normaliza el canal de 0..255 (como viene en el hex) a 0..1, que es donde la fórmula de gamma opera.
  • 0.03928 y 12.92: el umbral y la pendiente del tramo lineal de sRGB cerca del negro. Por debajo de ese punto, la codificación es una recta (c/12.92).
  • 0.055, 1.055, 2.4: los parámetros de la curva de sRGB para el resto del rango. El 2.4 es el exponente de gamma; el 0.055 es un pequeño desplazamiento para empalmar con el tramo lineal.
  • 0.2126, 0.7152, 0.0722: los coeficientes de luminancia —cuánto contribuye cada canal al brillo percibido—. Suman 1 (0.2126 + 0.7152 + 0.0722 = 1.0). Vienen del estándar de video Rec. 709, que modela la sensibilidad del ojo. El verde domina; el azul casi no cuenta.
  • + 0.05: el detalle más curioso. Se suma a ambas luminancias antes de dividir. Modela la luz ambiental —el reflejo del entorno sobre la pantalla, que nunca deja que el negro sea perfectamente negro en la vida real—. Sin él, un negro perfecto (luminancia 0) sobre cualquier cosa daría un ratio infinito (división por cero). El 0.05 acota el ratio máximo a (1+0.05)/(0+0.05) = 21, que es por qué el techo del contraste es 21 y no infinito. Ese 21 de la primera línea de verificación sale del +0.05.

Entender esto te protege del error más común al implementar contraste: copiar el algoritmo "casi bien". Si alguien omite la rama lineal, o pone 2.2 en vez de 2.4 (la gamma "genérica" en vez de la de sRGB), o se olvida el +0.05, el algoritmo casi funciona —da números plausibles— pero no coincide con las herramientas oficiales, y un par que debería pasar AA podría reportarse como fallido o viceversa. La verificación contra 21:1 es tu red: si ese caso no da 21 clavado, algo de esto está mal.

El pipeline de un canal, del hex a la luz:

  "#808080"  --hexToRgb-->  128   (valor de canal, 0..255)
                             │  / 255
                             ▼
                           0.502   (normalizado, 0..1  — "medio" aparente)
                             │  linearize (deshace gamma)
                             ▼
                           0.216   (luminancia del canal — mucho menos que 0.5!)
                             │  ponderar con G=0.7152, etc.
                             ▼
        relativeLuminance('#808080') = 0.216   (gris, los 3 canales iguales)
                             │  contra L(blanco)=1.0
                             ▼
              (1.0 + 0.05) / (0.216 + 0.05) = 3.95 : 1

Errores comunes

Usar el promedio de canales (o 2.2 de gamma) en vez del algoritmo real. Qué pasa: se calcula "claridad" como (r+g+b)/3, o se usa una gamma genérica de 2.2, y los ratios no coinciden con las herramientas oficiales. Por qué pasa: el promedio es intuitivo y 2.2 es la gamma "de memoria". Cómo detectarlo: #000000 sobre #ffffff no da exactamente 21:1, o un par cerca del umbral cae del lado equivocado. Cómo corregirlo: usa la fórmula exacta —linealización de sRGB con su tramo lineal y su exponente 2.4, y los coeficientes 0.2126/0.7152/0.0722—. El promedio ignora que el ojo pesa el verde siete veces más que el azul (por eso mentía en la lección 4); la gamma 2.2 está cerca pero no es sRGB. "Casi el algoritmo" no sirve cuando el veredicto legal (pasa/no pasa AA) depende del segundo decimal.

Olvidar el + 0.05 (o ponerlo en un solo lado). Qué pasa: se calcula L1 / L2 sin el +0.05, y un negro puro da división por cero (o Infinity), o los ratios salen inflados. Por qué pasa: el +0.05 parece un "detalle" que se puede omitir. Cómo detectarlo: tu ratio máximo no es 21 sino un número enorme o Infinity, o los ratios no coinciden con las herramientas. Cómo corregirlo: el +0.05 va en ambas luminancias, antes de dividir, siempre. Modela la luz ambiental y es lo que hace que el techo sea 21 (no infinito). Es parte de la fórmula oficial, no un adorno.

Confundir cuál es fg y cuál bg (y creer que importa para el ratio). Qué pasa: se duda si pasar primero el texto o el fondo. Por qué pasa: la función recibe (fg, bg) y uno asume que el orden cambia el resultado. Cómo detectarlo: no hay un error visible —y ese es el punto—. Cómo corregirlo: para el ratio, el orden no importa: el algoritmo toma max y min de las dos luminancias, así que contrastRatio(a, b) === contrastRatio(b, a). Texto blanco sobre fondo azul da el mismo ratio que azul sobre blanco (lo verás en la lección 7: ambos 5.17:1). Lo que importa es medir contra el fondo real del texto (el error de la lección 5), no cuál nombras fg. El contraste es simétrico; la legibilidad depende de que el par sea el correcto.

Ejercicios

Ejercicio 1 — Traza el pipeline. Sin correr nada, responde sobre relativeLuminance('#ffffff') (blanco puro):

  • (a) ¿Qué da hexToRgb('#ffffff')?
  • (b) ¿Qué da linearize(255)?
  • (c) ¿Cuánto vale la luminancia relativa del blanco, y por qué?
Ver solución
  • (a) [255, 255, 255] —los tres canales al máximo—.
  • (b) 1. Normalizado: 255/255 = 1. Como 1 > 0.03928, va por la curva: ((1 + 0.055)/1.055)^2.4 = (1)^2.4 = 1. El blanco máximo linealiza a 1.
  • (c) 1.0. Los tres canales linealizados valen 1, así que 0.2126·1 + 0.7152·1 + 0.0722·1 = 1.0 (los coeficientes suman 1). El blanco tiene luminancia máxima, 1 —tiene sentido: es el color que más luz emite—. Por eso contrastRatio(negro, blanco) = (1+0.05)/(0+0.05) = 21.

Ejercicio 2 — Predice el veredicto. El ejemplo mostró que #767676 sobre blanco da 4.54:1. Sin correr nada, y sabiendo que un gris más claro tiene menos contraste con el blanco: ¿un texto #808080 (que da 3.95:1) sobre blanco pasa AA para texto normal? ¿Y para texto grande?

Ver solución
  • Texto normal: no pasa. El umbral AA normal es 4.5:1, y 3.95 < 4.5. Falla —aunque "128 sobre 255" parezca mucho contraste, la gamma revela que no lo es—.
  • Texto grande: pasa AA. El umbral AA grande es 3:1, y 3.95 ≥ 3. Un titular en #808080 sobre blanco es aceptable; un párrafo en el mismo gris, no.

Este es el cruce de las lecciones 5 y 6: la 6 produce el número (3.95), la 5 lo compara con el umbral que corresponde al tamaño. #808080 es el ejemplo perfecto de un gris que "parece medio" pero está por debajo del mínimo para texto normal.

Ejercicio 3 — Por qué se verifica. Un compañero implementó su propio contrastRatio y reporta que #000000 sobre #ffffff le da 18.4:1. Sin correr nada, responde: (a) ¿cómo sabes con certeza que su algoritmo está mal? (b) ¿qué dos errores del algoritmo podrían producir un valor así de desviado?

Ver solución
  • (a) Porque negro puro sobre blanco puro tiene que dar exactamente 21:1 —es el máximo definido del contraste, fijado por el +0.05 en la fórmula—. Cualquier valor distinto de 21 para ese par prueba que la implementación se desvía del algoritmo real. Es el caso de verificación de oro justamente porque su respuesta correcta es conocida y exacta.
  • (b) Dos candidatos típicos: (1) usar una gamma incorrecta (2.2 genérica en vez de 2.4 de sRGB, u omitir el tramo lineal c/12.92), lo que corre la luminancia del negro o el blanco; (2) olvidar o duplicar el +0.05, o aplicarlo en un solo lado, lo que cambia el techo del ratio. Cualquiera de los dos da un número "plausible pero mal". La lección: sin verificar contra 21:1, un algoritmo casi-correcto pasa desapercibido —y reporta pares como accesibles cuando no lo son—.

Resumen y siguiente paso

En esta lección implementaste el algoritmo real de contraste de WCAG en cuatro pasos: hex → RGB, linealización de sRGB (deshacer la gamma, con su tramo lineal y su exponente 2.4), luminancia relativa (ponderando verde 71%, rojo 21%, azul 7%), y ratio con (claro + 0.05)/(oscuro + 0.05). Con el ojo que no cuenta la luz "en crudo" viste por qué cada paso existe: el valor del color está codificado con gamma y el ojo pesa los canales distinto, así que hay que linealizar y ponderar antes de comparar. Y lo verificaste contra valores de oro: #000000 sobre #ffffff dio 21:1 clavado, #ffffff sobre sí mismo 1:1, y el gris "medio" #808080 solo 3.95:1 —la prueba de que el valor del color engaña y la luminancia no—.

Antes de avanzar deberías poder: nombrar los cuatro pasos del algoritmo y qué corrige cada uno; explicar de dónde sale el 21 como techo (el +0.05); y decir por qué verificar contra #000/#fff = 21:1 prueba que la implementación es la real.

Ya tienes la máquina que produce el número y sabes el umbral que debe superar (lección 5). La lección 7 los junta para lo que de verdad importa: elegir pares de color accesibles. Vas a auditar los pares reales del product-card de Mercado —el texto sobre la tarjeta, el botón, el precio— con contrastRatio y passesWCAG, encontrar el que falla (un gris demasiado claro), y arreglarlo subiendo de step en la rampa. Es el algoritmo de esta lección puesto a trabajar sobre los colores de las lecciones 2–4.

Recursos

  • W3C, "Relative luminance" (definición WCAG 2.1) — w3.org/WAI/GL/wiki/Relative_luminance. La fórmula exacta que implementaste, con sus constantes; la fuente de autoridad para verificar cada paso. En inglés.
  • W3C, "Contrast ratio" (definición) — w3.org/TR/WCAG21/#dfn-contrast-ratio. La definición normativa del ratio (L1+0.05)/(L2+0.05), incluido de dónde sale el +0.05. En inglés.
  • MDN, "Understanding relative luminance" — vía developer.mozilla.org, "WCAG color contrast". La explicación de por qué el ojo pesa los canales distinto; el fundamento de la analogía. En inglés.
  • WebAIM, "Contrast Checker" — webaim.org/resources/contrastchecker. La herramienta de referencia contra la que verificar tus ratios; prueba #000000/#ffffff y confirma tu 21:1. En inglés.