Apuntes DAM
Volver al inicio

Comprobador de contraste WCAG con sugerencia de color

Ejercicio de JavaScriptMedioUnos 55 minutos

Programa la herramienta de accesibilidad que todo diseñador usa: lee colores en hexadecimal, rgb() o por nombre, calcula la luminancia relativa y la relación de contraste de WCAG, decide si cumple AA y AAA según el tamaño del texto y, si no, busca el color más parecido que sí cumple.

  • Contraste y luminancia relativa (WCAG)
  • Niveles AA y AAA
  • Texto normal y grande
  • Expresiones regulares
  • Colores hexadecimales y rgb()
  • Pruebas de accesibilidad

Enunciado

Un texto gris claro sobre blanco queda elegante y no se lee. Las pautas WCAG lo miden con la relación de contraste entre los colores del texto y del fondo, de 1:1 (iguales) a 21:1 (negro sobre blanco): el nivel AA exige 4,5:1 para el texto normal y 3:1 para el grande, y el AAA, 7:1 y 4,5:1. Es una de las comprobaciones que hacen automáticamente Lighthouse, axe o WAVE en las pruebas de accesibilidad.

La fórmula: cada canal (0-255) se pasa a 0-1 y se linealiza (c ≤ 0,04045 ? c / 12,92 : ((c + 0,055) / 1,055)^2,4); la luminancia es 0,2126 R + 0,7152 G + 0,0722 B (el ojo es mucho más sensible al verde); y el contraste, (L_clara + 0,05) / (L_oscura + 0,05).

Qué tiene que hacer el programa

  1. Cada línea: texto; fondo, opcionalmente ; tamaño (16px por defecto; también en pt, 1 pt = 4/3 px) y ; negrita. Los colores: #rgb, #rrggbb, rgb(r, g, b) (0-255) o uno de los nombres de la tabla, sin distinguir mayúsculas.
  2. Si hay menos de 2 o más de 4 partes, o un color no es válido: Línea N: color no válido «x» (x es la línea entera si faltan partes, o el primer color que falla). Si el tamaño no es un número con px o pt, o la cuarta parte no es negrita: Línea N: tamaño no válido «x».
  3. El texto es grande desde 24 px, o desde 18,66 px si es negrita. Cada combinación válida escribe #texto sobre #fondo (tamaño[ negrita], texto normal|grande): R:1 · AA sí|no · AAA sí|no, con los colores en #rrggbb en minúsculas, el tamaño tal como venía y R con dos decimales y coma (se redondea para mostrarlo, pero se compara sin redondear).
  4. Si no cumple AA, se añade · prueba #xxxxxx (R:1): el color del texto oscurecido (si es más oscuro que el fondo o igual) o aclarado (si es más claro) en el menor porcentaje entero k (de 1 a 100) que cumpla AA; si en esa dirección no se llega, en la contraria. Oscurecer: round(c × (1 − k/100)); aclarar: round(c + (255 − c) × k/100). Si no hay ninguno: · sin sugerencia.
  5. Al final: Combinaciones: N; cumplen AA: M (N cuenta las líneas válidas).

Entrada

Una combinación por línea: texto; fondo[; tamaño][; negrita].

Datos de referencia

Mínimos de WCAG 2
TextoAAAAA
Normal4,5:17:1
Grande (≥ 24 px, o ≥ 18,66 px en negrita)3:14,5:1
Colores por nombre
NombreValor
black#000000
white#ffffff
red#ff0000
green#008000
blue#0000ff
gray#808080
silver#c0c0c0
navy#000080
maroon#800000
purple#800080
orange#ffa500
yellow#ffff00

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Grises y colores de marca

Entrada

#777; #fff
#767676; white
#ffffff; #0055AA; 16px
orange; white; 24px
rgb(255, 99, 71); #ffffff; 14pt; negrita
#333; #555

Salida por consola

#777777 sobre #ffffff (16px, texto normal): 4,48:1 · AA no · AAA no · prueba #767676 (4,54:1)
#767676 sobre #ffffff (16px, texto normal): 4,54:1 · AA sí · AAA no
#ffffff sobre #0055aa (16px, texto normal): 7,29:1 · AA sí · AAA sí
#ffa500 sobre #ffffff (24px, texto grande): 1,97:1 · AA no · AAA no · prueba #cc8400 (3,06:1)
#ff6347 sobre #ffffff (14pt negrita, texto grande): 2,95:1 · AA no · AAA no · prueba #fc6246 (3,01:1)
#333333 sobre #555555 (16px, texto normal): 1,69:1 · AA no · AAA no · prueba #cacaca (4,55:1)
Combinaciones: 6; cumplen AA: 2

Errores y casos límite

Entrada

black; white
#abc; #abc
#12345; #fff
navy; rgb(300, 0, 0)
red; white; grande
red; white; 18px; cursiva
solo un color
yellow; WHITE; 30px

Salida por consola

#000000 sobre #ffffff (16px, texto normal): 21,00:1 · AA sí · AAA sí
#aabbcc sobre #aabbcc (16px, texto normal): 1,00:1 · AA no · AAA no · prueba #444b52 (4,50:1)
Línea 3: color no válido «#12345»
Línea 4: color no válido «rgb(300, 0, 0)»
Línea 5: tamaño no válido «grande»
Línea 6: tamaño no válido «cursiva»
Línea 7: color no válido «solo un color»
#ffff00 sobre #ffffff (30px, texto grande): 1,07:1 · AA no · AAA no · prueba #999900 (3,04:1)
Combinaciones: 3; cumplen AA: 1

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Leer el color

Una expresión regular por formato. En #rgb cada cifra se duplica (#f80 es #ff8800); parseInt(x, 16) convierte de hexadecimal.

javascript
let m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/.exec(t);
if (m) return m.slice(1).map((c) => parseInt(c, 16));
2. La fórmula

Linealiza cada canal, pondera con 0,2126, 0,7152 y 0,0722, y en el contraste pon siempre la luminancia mayor arriba.

3. Buscar la sugerencia

Prueba k = 1, 2, 3… y quédate con el primero que llega al mínimo: así el color cambia lo menos posible. #777777 sobre blanco da 4,48:1 y el 1 % más oscuro, #767676, ya da 4,54:1.

4. Formatear

toString(16).padStart(2, "0") da cada canal en dos cifras hexadecimales.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

🟨JavaScriptComprobador de contraste WCAG con sugerencia de colorMedio

Ejemplo

Entrada (lo que se escribe por teclado)
#777; #fff
#767676; white
#ffffff; #0055AA; 16px
orange; white; 24px
rgb(255, 99, 71); #ffffff; 14pt; negrita
#333; #555
Salida esperada
#777777 sobre #ffffff (16px, texto normal): 4,48:1 · AA no · AAA no · prueba #767676 (4,54:1)
#767676 sobre #ffffff (16px, texto normal): 4,54:1 · AA sí · AAA no
#ffffff sobre #0055aa (16px, texto normal): 7,29:1 · AA sí · AAA sí
#ffa500 sobre #ffffff (24px, texto grande): 1,97:1 · AA no · AAA no · prueba #cc8400 (3,06:1)
#ff6347 sobre #ffffff (14pt negrita, texto grande): 2,95:1 · AA no · AAA no · prueba #fc6246 (3,01:1)
#333333 sobre #555555 (16px, texto normal): 1,69:1 · AA no · AAA no · prueba #cacaca (4,55:1)
Combinaciones: 6; cumplen AA: 2
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
javascript
1const lineas = require("fs").readFileSync(0, "utf8").split("\n");
2
3const NOMBRES = {
4  black: "#000000", white: "#ffffff", red: "#ff0000", green: "#008000", blue: "#0000ff", gray: "#808080",
5  silver: "#c0c0c0", navy: "#000080", maroon: "#800000", purple: "#800080", orange: "#ffa500", yellow: "#ffff00",
6};
7
8const hex = ([r, g, b]) => "#" + [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("");
9const coma = (x) => x.toFixed(2).replace(".", ",");
10
11/** "#777", "#1a2b3c", "rgb(10, 20, 30)" o un nombre → [r, g, b], o null si no es válido. */
12function leerColor(texto) {
13  const t = texto.trim().toLowerCase();
14  if (NOMBRES[t]) return leerColor(NOMBRES[t]);
15  let m = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(t);
16  if (m) return m.slice(1).map((c) => parseInt(c + c, 16));
17  m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/.exec(t);
18  if (m) return m.slice(1).map((c) => parseInt(c, 16));
19  m = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/.exec(t);
20  if (m && m.slice(1).every((c) => Number(c) <= 255)) return m.slice(1).map(Number);
21  return null;
22}
23
24/** Luminancia relativa de WCAG: cada canal se linealiza (se quita la corrección gamma) y se pondera. */
25function luminancia([r, g, b]) {
26  const lin = (c) => {
27    const s = c / 255;
28    return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
29  };
30  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
31}
32
33function contraste(a, b) {
34  const la = luminancia(a), lb = luminancia(b);
35  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
36}
37
38/** Oscurece (k < 0) o aclara (k > 0) un color un k % hacia el negro o el blanco. */
39function ajustar([r, g, b], k) {
40  return [r, g, b].map((c) => Math.round(k < 0 ? c * (1 + k / 100) : c + ((255 - c) * k) / 100));
41}
42
43/** El color de texto más parecido que llega al mínimo: primero en la dirección que aleja del fondo. */
44function sugerir(texto, fondo, minimo) {
45  const signo = luminancia(texto) <= luminancia(fondo) ? -1 : 1;
46  for (const s of [signo, -signo]) {
47    for (let k = 1; k <= 100; k++) {
48      const c = ajustar(texto, s * k);
49      if (contraste(c, fondo) >= minimo) return c;
50    }
51  }
52  return null;
53}
54
55function main() {
56  let total = 0, aa = 0;
57  lineas.forEach((bruta, i) => {
58    const linea = bruta.trim();
59    if (!linea) return;
60    const partes = linea.split(";").map((p) => p.trim());
61    const [t, f, tam = "16px", peso = ""] = partes;
62    const texto = leerColor(t ?? ""), fondo = leerColor(f ?? "");
63    if (partes.length < 2 || partes.length > 4 || !texto || !fondo) {
64      const malo = partes.length < 2 ? linea : !texto ? t : f;
65      console.log(`Línea ${i + 1}: color no válido «${malo}»`);
66      return;
67    }
68    const m = /^(\d+(?:\.\d+)?)(px|pt)$/.exec(tam);
69    if (!m || (peso && peso !== "negrita")) {
70      console.log(`Línea ${i + 1}: tamaño no válido «${!m ? tam : peso}»`);
71      return;
72    }
73    const px = m[2] === "pt" ? (Number(m[1]) * 4) / 3 : Number(m[1]);
74    const negrita = peso === "negrita";
75    // texto grande: 18 pt (24 px) o 14 pt (18,66 px) en negrita
76    const grande = px >= 24 || (negrita && px >= 18.66);
77    const [minAA, minAAA] = grande ? [3, 4.5] : [4.5, 7];
78    const r = contraste(texto, fondo);
79    total++;
80    let salida = `${hex(texto)} sobre ${hex(fondo)} (${tam}${negrita ? " negrita" : ""}, texto ${grande ? "grande" : "normal"}): ${coma(r)}:1 · AA ${r >= minAA ? "sí" : "no"} · AAA ${r >= minAAA ? "sí" : "no"}`;
81    if (r >= minAA) aa++;
82    else {
83      const s = sugerir(texto, fondo, minAA);
84      salida += s ? ` · prueba ${hex(s)} (${coma(contraste(s, fondo))}:1)` : " · sin sugerencia";
85    }
86    console.log(salida);
87  });
88  console.log(`Combinaciones: ${total}; cumplen AA: ${aa}`);
89}
90
91main();

El verde pesa diez veces más que el azul en la luminancia: por eso el amarillo (rojo y verde) sobre blanco apenas se distingue y el azul puro sobre negro, que parece intenso, tiene poco contraste.

Linealizar es deshacer la corrección gamma de sRGB: los valores 0-255 no son proporcionales a la luz emitida, y para comparar luminancias hay que pasar a luz «física».

El texto grande necesita menos contraste porque los trazos gruesos se distinguen mejor. Por eso un mismo naranja puede valer para un titular y no para un párrafo.

Este cálculo es el de WCAG 2. La futura WCAG 3 propone APCA, que tiene en cuenta además la polaridad (texto claro sobre oscuro no se percibe igual que al revés) y el grosor de la fuente.

Para ir más allá

  • Lee una hoja de estilos sencilla (selector { color: ...; background: ...; font-size: ... }) y comprueba cada regla.
  • Simula cómo ve los colores una persona con daltonismo (protanopía) y vuelve a calcular el contraste.
  • Sugiere también un color de fondo, y quédate con el cambio más pequeño de los dos.

Dónde se explica