Comprobador de contraste WCAG con sugerencia de color
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
- Cada línea:
texto; fondo, opcionalmente; tamaño(16pxpor defecto; también enpt, 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. - 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 conpxopt, o la cuarta parte no esnegrita:Línea N: tamaño no válido «x». - 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#rrggbben 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). - 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. - 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
| Texto | AA | AAA |
|---|---|---|
| Normal | 4,5:1 | 7:1 |
| Grande (≥ 24 px, o ≥ 18,66 px en negrita) | 3:1 | 4,5:1 |
| Nombre | Valor |
|---|---|
| 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.
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.
Ejemplo
#777; #fff #767676; white #ffffff; #0055AA; 16px orange; white; 24px rgb(255, 99, 71); #ffffff; 14pt; negrita #333; #555
#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
Solución explicada
Ver la solución completa
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.