Módulo 4: Scales Color And Contrast

Presentación del módulo: escalas, color y contraste

De "tokens con nombre" a "de dónde salen sus valores"

En el módulo 2 fabricaste los tokens de Mercado —color.primary, color.surface, space-4, text-lg— y en el módulo 3 los pusiste a trabajar como utilidades (bg-primary, p-4, text-lg). En los dos módulos un token o una utilidad entregaron un valor: p-4 dio 1rem, text-lg dio 1.125rem, bg-primary apuntó a #2563eb. Pero nunca preguntamos lo más básico: ¿por qué esos valores y no otros? ¿Por qué p-4 es 1rem y no 0.9rem? ¿Por qué el azul de marca es #2563eb y no #3b82f6? ¿Y cómo sabes que ese azul, con texto blanco encima, se puede leer?

Este módulo responde esas tres preguntas. Los valores de un buen sistema no se inventan uno por uno: salen de escalas —reglas numéricas que generan una serie coherente— y se validan contra un umbral de contraste medible. Vas a construir las tres escalas que dan coherencia a Mercado: la de espaciado (una base de 4px de la que salen todos los space-N), la tipográfica (una escala modular, base × ratio^n, de la que salen todos los text-*), y las paletas de color (rampas de steps 50–900, como las de Tailwind o Material). Y vas a aprender a medir el contraste con el algoritmo real de WCAG —contrastRatio(fg, bg)— para saber, con un número y no con una opinión, si un par de colores pasa el estándar de accesibilidad AA o AAA.

La idea que une todo el módulo es una sola: la coherencia y la accesibilidad de un sistema son consecuencias de números, no de gusto. Cuando cada espacio sale de una base, cada tamaño de un ratio y cada par de color pasa un umbral medido, la UI se ve coherente y es legible por construcción —no porque alguien la revisó a ojo y le pareció que "estaba bien"—.

Conexión con el módulo. Esta es la lección-mapa del módulo 4. No entra a fondo en ninguna pieza: instala la tesis (los valores del sistema salen de escalas, y el contraste es un número medible con un umbral), da el mapa de las ocho lecciones, y ejecuta un primer teaser que muestra los dos pilares —un valor de escala y un contraste medido—. La lección 2 construye la escala de espaciado (base 4px). La 3, la escala tipográfica modular. La 4 abre las paletas de color por steps. La 5 explica qué es el contraste WCAG y sus umbrales AA/AAA. La 6 implementa el algoritmo real de contrastRatio. La 7 lo aplica a elegir pares de color accesibles. Y la 8 te pone a definir las escalas de Mercado y auditar el contraste de su product-card.

Una analogía: los tallajes de ropa

Piensa en cómo una marca de ropa fabrica sus tallas. Podría, en teoría, cortar cada prenda a una medida distinta y arbitraria: esta camiseta con 47 cm de ancho, la siguiente con 49, la de más allá con 52.5. El resultado sería un caos: dos clientes con el mismo cuerpo comprarían prendas que les quedan distinto, y la marca no podría reponer stock sin volver a medir cada corte. Nadie hace eso. Lo que hacen todas las marcas es fijar una escala de tallas —S, M, L, XL— donde cada salto es consistente: de S a M crece siempre lo mismo, de M a L lo mismo otra vez. No es que no puedas tener una prenda ancha; es que su ancho sale de un peldaño conocido de la escala, no de un número suelto.

Un sistema de diseño hace exactamente esto con el espacio y el texto. La escala de espaciado es el tallaje de los huecos: no cualquier padding, sino space-1, space-2, space-4, space-8…, con un salto consistente (una base de 4px). La escala tipográfica es el tallaje de las letras: no cualquier tamaño, sino text-sm, text-base, text-lg, text-xl…, con un salto consistente (un ratio). Cuando necesitas un espacio "un poco más grande", no inventas 17px: subes un peldaño de la escala, igual que subes de M a L. Esa es la razón profunda de que un sistema se vea coherente —todo sale del mismo tallaje—.

Para el color la imagen cambia un poco, porque el color tiene dos ejes: el tono (azul, gris, rojo) y la claridad (qué tan claro u oscuro). Una paleta es una rampa de un tono en pasos de claridad —blue.50 casi blanco, blue.900 casi negro, con peldaños regulares en medio—. Y el contraste es lo que aún el tallaje no te dice: si pones una letra de un peldaño sobre un fondo de otro, ¿se lee? Ahí la analogía es la de leer un letrero. Un letrero en gris claro sobre fondo blanco no se ve —los dos colores están demasiado cerca en claridad—; uno en negro sobre blanco se lee desde lejos. El contraste mide justo esa distancia de claridad, y WCAG fija cuánta hace falta para que cualquiera lo lea. No es una opinión sobre si "se ve bien": es una distancia medible con un mínimo obligatorio.

Guarda las tres imágenes: las escalas de espaciado y tipografía son tallajes —saltos consistentes, no medidas sueltas—; una paleta es una rampa de claridad de un tono; el contraste es qué tan legible es un color sobre otro, medido como la distancia de claridad entre ambos. Todo el módulo desarrolla estas tres.

Ejemplo trabajado: los dos pilares son números

El módulo tiene dos pilares —las escalas y el contraste— y los dos son, en el fondo, aritmética. Este teaser lo hace visible con el mínimo de código. Por un lado, un valor de la escala de espaciado: space-4 no es "16px porque quedó bien", es el paso 4 de una base de 4px, o sea 4 × 4 = 16px. Por el otro, un contraste real: el texto del product-card de Mercado es #111827 (casi negro) sobre #ffffff (blanco), y su legibilidad no es opinión —es un ratio que se calcula con el algoritmo de WCAG, que verás completo en la lección 6—.

// L1 intro — teaser: el modulo tiene DOS pilares que son numeros, no gustos.
// (1) el valor de una utilidad sale de una ESCALA; (2) el contraste es un RATIO medible.

// pilar 1 — la escala: space-4 no es "16px porque si", es paso 4 de una base de 4px.
const space = (step) => step * 4; // px

// pilar 2 — el contraste: un ratio que se calcula, no se opina (algoritmo real en L6).
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 luminance(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 = luminance(fg), L2 = luminance(bg);
  return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
}
const r2 = (x) => Math.round(x * 100) / 100;

console.log('=== dos pilares del modulo, ambos son numeros ===\n');
console.log('escala:    space-4 = ' + space(4) + 'px   (paso 4 x base 4px, no un valor inventado)');
console.log('contraste: #111827 sobre #ffffff = ' + r2(contrastRatio('#111827', '#ffffff')) + ':1  (texto de la card, medido)');

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

=== dos pilares del modulo, ambos son numeros ===

escala:    space-4 = 16px   (paso 4 x base 4px, no un valor inventado)
contraste: #111827 sobre #ffffff = 17.74:1  (texto de la card, medido)

Lee las dos líneas como el resumen del módulo. La primera dice que space-4 es 16px porque es el paso 4 de una base de 4px —no un número que alguien tecleó—. Cambia la base o el paso y el valor cambia de forma predecible; esa predecibilidad es la coherencia que persigues. La segunda dice que el texto de la tarjeta sobre su fondo blanco tiene un contraste de 17.74:1. Ese número no lo elegiste: salió del algoritmo. Y como el umbral AAA para texto normal es 7:1, 17.74 lo pasa con holgura —el texto de Mercado es legible, y ahora lo sabes, no lo supones—.

Fíjate en lo que no hay en el teaser: ningún juicio estético. No dice "este azul es bonito" ni "16px se ve bien". Dice "16px es el paso 4" y "el contraste es 17.74". Todo el módulo va a insistir en esa diferencia: un sistema no se apoya en que a alguien le parezca; se apoya en escalas que generan los valores y en umbrales que los validan.

Una pregunta para cargar por el resto del módulo: si #111827 sobre blanco da 17.74:1, ¿qué pasa con un gris claro como #9ca3af sobre el mismo blanco? ¿Se seguirá leyendo, o los dos colores estarán demasiado cerca en claridad? (Adelanto: da 2.54:1, y el mínimo AA para texto normal es 4.5:1 —así que falla, aunque "se vea bien". Ese es el par que auditarás y arreglarás en la lección 7 y en el proyecto.)

El mapa del módulo

Guarda esta ruta; es cómo cada lección construye una parte de la coherencia y la accesibilidad del sistema:

Idea                                       Lección   Concepto clave
─────────────────────────────────────────  ────────  ─────────────────────────────────────
La escala de espaciado                     L2        base 4px; space(step)=step*4; los huecos
                                                     salen de una serie, no de valores sueltos
La escala tipografica                      L3        escala modular base*ratio^n; los tamanos
                                                     saltan por un ratio constante (una octava)
Paletas de color y steps                   L4        rampa de un tono en steps 50..900; el
                                                     step numera la claridad, no el tono
Contraste y WCAG                           L5        el contraste es un numero; umbrales AA
                                                     (4.5/3) y AAA (7/4.5), normal vs grande
Calcular el contrastRatio                  L6        el algoritmo real: hex->RGB, linealizar,
                                                     luminancia relativa, ratio (+0.05)
Elegir pares accesibles                    L7        auditar pares con contrastRatio+passesWCAG;
                                                     arreglar el que falla subiendo de step
─────────────────────────────────────────  ────────  ─────────────────────────────────────
Proyecto: las escalas de Mercado           L8        definir las 3 escalas y auditar el
                                                     contraste del product-card (pasa/falla)

La frontera: qué NO entra en este módulo

Saber la frontera te evita mezclar lo que otras lecciones y guías cubren:

  • El uso de estas escalas y colores en componentes con variantes (el Button con size="lg", el patrón cva) es el módulo 5. Aquí construimos las escalas y medimos el contraste; aplicarlas a un componente configurable por props es allá.
  • El modo oscuro —cómo las paletas dan sus valores claros y oscuros, la variante dark:— es el módulo 6. Aquí definimos las rampas de color; cambiar de tema con ellas es allá. (En este módulo el contraste se mide en tema claro; el mismo algoritmo aplica al oscuro en el módulo 6.)
  • Configurar Tailwind con estas escalas (theme.extend.spacing, theme.extend.fontSize, theme.extend.colors) fue el módulo 3. Aquí construimos y justificamos los valores que esa config declara; cómo se declaran ya lo viste. Los enlazaremos, no los re-enseñamos.
  • La accesibilidad a fondo (todo ARIA, foco, teclado, testing con lectores de pantalla) se ve en su guía. Aquí cubrimos una base que el sistema debe garantizar —el contraste de color medido—, porque es la que sale de las escalas y paletas de este módulo. El resto se menciona y remite.

Errores comunes

Creer que los valores del sistema son cuestión de gusto. Qué pasa: alguien mira space-4 = 16px o text-lg = 20px y piensa "son números que alguien eligió porque le gustaron". Por qué pasa: el resultado se ve como una lista de valores, y no se ve la regla que los generó. Cómo detectarlo: cuando necesitas un valor nuevo, inventas uno (padding: 17px) en vez de subir un peldaño de la escala. Cómo corregirlo: cada valor de una escala sale de una fórmula —space(4) = 4 × 4px, fontSize(1) = 16 × 1.25—. No eliges el valor; eliges el peldaño, y la fórmula da el valor. Todo el módulo insiste en esto: la coherencia es consecuencia de que los valores salgan de una regla, no de que cada uno se vea bien por separado.

Tratar el contraste como una opinión estética. Qué pasa: se elige un par de colores porque "se ve bien" o "es elegante", sin medirlo, y se asume que si al diseñador le gusta, es legible. Por qué pasa: el color se siente subjetivo, así que su legibilidad también parece subjetiva. Cómo detectarlo: en tu proceso no hay ningún número —nadie corrió contrastRatio, nadie miró si pasa AA—. Cómo corregirlo: el contraste no es opinión; es un ratio que el algoritmo de WCAG calcula (lección 6), con umbrales fijos (lección 5). Un gris claro sobre blanco puede "verse elegante" y ser ilegible para millones de personas con baja visión —y el número lo dice antes de que lo sufra un usuario—. Medir no reemplaza al buen gusto; lo acota: entre todos los pares que se ven bien, solo algunos pasan, y el número te dice cuáles.

Confundir "es mi marca" con "es accesible". Qué pasa: se defiende un par de colores de baja legibilidad porque "es el azul de la marca" o "así se ve en el logo". Por qué pasa: la identidad visual pesa, y parece que la accesibilidad la contradice. Cómo detectarlo: hay un color de marca que falla AA y nadie propone ajustarlo. Cómo corregirlo: un color de marca puede vivir en la paleta como un step (blue.600), pero qué step usas para texto sobre qué fondo es una decisión de contraste, no de identidad. El azul de Mercado es #2563eb; como texto sobre blanco da 5.17:1 (pasa AA), pero como texto sobre un fondo azul claro fallaría. La marca elige el tono; el contraste elige en qué combinación es seguro usarlo. La lección 7 vive en esa distinción.

Ejercicios

Ejercicio 1 — Escala o valor suelto. Para cada caso, di si el valor sale de una escala (un peldaño de una serie con salto consistente) o es un valor suelto (inventado, fuera de serie):

  • (a) padding: 16px, tomado de space-4 en una base de 4px.
  • (b) padding: 17px, tecleado a mano porque "16 se veía apretado".
  • (c) font-size: 20px, tomado de text-lg en una escala de base 16 y ratio 1.25.
  • (d) font-size: 19px, elegido "a ojo" para que "quepa en una línea".
Ver solución
  • (a) Escala. 16px = 4 × 4px es el paso 4 de la base. Sale de la serie; es coherente con el resto de los espacios.
  • (b) Valor suelto. 17px no es ningún paso de una base de 4px (los pasos serían 12, 16, 20…). Es exactamente el tipo de valor que rompe la coherencia: la próxima vez que alguien necesite ese espacio, no sabrá que "17" era el número, y usará 16 o 18. Un caos que crece.
  • (c) Escala. 20px = 16 × 1.25 es el paso 1 de la escala modular. Sale del ratio; salta lo mismo que todos los demás tamaños.
  • (d) Valor suelto. 19px no es 16 × 1.25^n para ningún n entero. Como (b), es un peldaño inventado que romperá la serie.

La pregunta guía: ¿puedes escribir el valor como base × algo con la regla de la escala? Si sí, es un peldaño; si tuviste que teclearlo a mano, es un valor suelto.

Ejercicio 2 — Predice el pilar. El teaser imprimió que #111827 sobre #ffffff da 17.74:1. Sin correr nada, responde: si en vez del texto casi negro (#111827) usaras blanco puro (#ffffff) sobre el mismo fondo blanco, ¿qué contraste esperarías, y por qué?

Ver solución

Esperarías 1:1 —el contraste mínimo posible—. La razón: el ratio de contraste mide la distancia de claridad entre dos colores. Blanco sobre blanco son el mismo color: distancia cero, texto completamente invisible. El algoritmo lo confirma: con las dos luminancias iguales, (L + 0.05) / (L + 0.05) = 1. Es el otro extremo de la escala de contraste, cuyo máximo es 21:1 (negro puro sobre blanco puro). Todo par de colores cae entre 1:1 (idénticos, ilegible) y 21:1 (opuestos, máxima legibilidad). Lo verás medido en la lección 6.

Ejercicio 3 — Ubica la pieza en su capa. El módulo 1 te dio las cuatro capas (tokens → utilidades → componentes → patrones), y los módulos 2 y 3 construyeron las dos primeras. Para cada afirmación sobre este módulo 4, di si es verdadera o falsa y por qué:

  • (a) "Este módulo construye una quinta capa nueva."
  • (b) "Las escalas de este módulo dan los valores que los tokens del módulo 2 entregan."
  • (c) "El contraste se decide mirando si el par de colores se ve bien."
Ver solución
  • (a) Falsa. No hay una capa nueva. Este módulo baja dentro de la capa de tokens (módulo 2) y responde de dónde salen sus valores: las escalas y las paletas. Es el "por qué esos números" de la capa más baja, no una capa aparte.
  • (b) Verdadera. Es el corazón del módulo. El token space-4 del módulo 2 entregaba 1rem; este módulo construye la escala (base 4px) que genera ese 1rem. Igual con text-lg (escala modular) y color.primary (un step de una paleta). Los tokens nombran; las escalas dan el valor detrás del nombre.
  • (c) Falsa. El contraste se decide con un número —el contrastRatio— comparado con un umbral fijo (AA 4.5, AAA 7 para texto normal). "Se ve bien" es insuficiente y a veces engañoso: un gris claro puede verse elegante y fallar el umbral. La lección 5 fija los umbrales; la 6, cómo se mide.

Resumen y siguiente paso

En esta lección instalaste la tesis del módulo 4: los valores de un sistema salen de escalas —reglas numéricas que generan una serie coherente— y su accesibilidad se valida con el contraste, un número medible con un umbral fijo. Con los tallajes de ropa viste la distinción que estructura el módulo: un buen sistema no usa medidas sueltas (17px, "el azul que se veía bien"), sino peldaños de una escala (space-4, blue.600) y pares de color que pasan un umbral medido. Y lo comprobaste ejecutando: space(4) dio 16px como el paso 4 de una base de 4px, y #111827 sobre #ffffff dio 17.74:1 —un contraste que pasa AAA, medido y no opinado—.

Antes de avanzar deberías poder: explicar por qué space-4 es 16px (no un valor inventado); decir qué mide un ratio de contraste (la distancia de claridad entre dos colores); y ubicar este módulo como el que da los valores de la capa de tokens, no una capa nueva.

La lección 2 baja al primer pilar: la escala de espaciado. Vas a construir space(step) sobre una base de 4px, ver la serie completa (space-1 a space-12), y entender por qué una base pequeña y consistente le da a toda la UI un ritmo de espacios que se siente ordenado sin que nadie lo note. Es la escala que las utilidades p-4, gap-2 y m-8 del módulo 3 estaban leyendo todo el tiempo.

Recursos

  • web.dev, "Color and contrast accessibility" — web.dev/articles/color-and-contrast-accessibility. La introducción de Google a por qué el contraste importa y cómo se mide; el marco del pilar de accesibilidad de este módulo. En inglés.
  • Tailwind CSS, "Colors" — tailwindcss.com/docs/colors. La paleta por steps 50–950 que este módulo toma como modelo de rampa de color; míra sus rampas antes de la lección 4. En inglés.
  • Type Scale — type-scale.com. Una herramienta que genera una escala tipográfica modular a partir de una base y un ratio; el pilar de la lección 3, en vivo. En inglés.
  • web-fundamentals-html-css — módulo 3, "La cascada y la especificidad". El prerequisito de CSS sobre el que se apoya toda la guía; tenlo a mano si necesitas repasar cómo el navegador aplica los valores que estas escalas producen.