Módulo 4: Scales Color And Contrast

Proyecto: las escalas de Mercado y la auditoría de contraste

Descripción

Las siete lecciones anteriores te enseñaron a entender las escalas y el contraste por separado: la de espaciado (base 4px), la tipográfica (modular, base × ratio^n), la paleta de color por steps, el ratio de contraste y sus umbrales AA/AAA, el algoritmo real que lo calcula, y cómo elegir pares accesibles. Este proyecto te pone a definirlas juntas y verificarlas. Vas a construir, en un solo archivo de Node, las tres escalas de Mercado —espaciado, tipografía, y la paleta con sus roles— y luego auditar el contraste de los pares de color del product-card, marcando cuáles pasan AA/AAA y arreglando el que falla. En los módulos 2 y 3 fabricaste los tokens y las utilidades; aquí les das a esos tokens los valores coherentes de donde salen —las escalas— y la garantía de accesibilidad que un sistema debe tener —el contraste medido—.

El entregable tiene dos partes, y las dos importan. La parte 1 son las escalas: el espaciado derivado de la base 4px, la tipografía derivada del ratio 1.25, y los roles de color apuntando a steps de la paleta —los valores detrás de los tokens del módulo 2, ahora generados por reglas y no tecleados—. La parte 2 es la auditoría: correr contrastRatio sobre los pares del product-card, clasificarlos con passesWCAG, encontrar el que no llega a AA, y probar el fix subiendo de step. Juntas prueban que dominas el módulo: no solo qué son las escalas y el contraste, sino cómo definir las escalas de un componente real y garantizar que sus colores se leen.

Conexión con el módulo. Este proyecto es la síntesis de las siete lecciones. Deriva el espaciado (L2) y la tipografía (L3) de sus reglas, mapea roles a steps de la paleta (L4), y audita los pares con el algoritmo real (L6) contra los umbrales (L5), arreglando el que falla por la rampa (L7). Es también el cierre de la primera mitad de la guía: las escalas de coherencia y el contraste de accesibilidad que los componentes del módulo 5 y el dark mode del módulo 6 darán por sentados. Al terminar, la capa de tokens de Mercado tiene valores con origen y contraste probado.

Qué vas a construir

El entregable es un archivo de Node —scales.js— que, al correrse, imprime cinco bloques:

  1. La escala de espaciado (base 4px): unos pasos derivados con space(step).
  2. La escala tipográfica (base 16px, ratio 1.25): los tamaños derivados con fontSize(n).
  3. Los roles de color → step de la paleta: color.primary, color.surface, color.text, color.muted, cada uno apuntando a un step (la elección inicial, hecha en parte "a ojo").
  4. La auditoría de contraste del product-card: cada par frente-sobre-fondo con su ratio y su nivel (AA/AAA/FAIL).
  5. El fix del par que falla: el texto secundario de gray.400 a gray.500, medido antes y después.

No hay componentes React ni clases de Tailwind que escribir aquí: es puro modelo en Node, como todos los "Qué esperar" del módulo. El navegador no corre en un agente, así que mostramos lo que se escribiría y ejecutamos la lógica que lo valida. Los componentes que consumen estas escalas llegan en el módulo 5; aquí fabricas los valores y garantizas su contraste.

Una analogía: el plano de medidas y la prueba de legibilidad antes de imprimir el catálogo

Una imprenta que va a producir el catálogo de Mercado —cientos de miles de tarjetas de producto— no improvisa. Antes de encender la prensa hace dos cosas. Primero fija su plano de medidas maestro: el tamaño de cada margen, de cada cuerpo de texto, de cada separación, todo derivado de una retícula base para que las cientos de miles de copias salgan idénticas y coherentes —nadie mide "a ojo" en la prensa, porque un milímetro suelto se multiplica por cientos de miles—. Segundo, hace una prueba de legibilidad: imprime una tarjeta de muestra y la mira bajo distintas luces, con distintos ojos, para confirmar que el texto se lee —porque una vez que la prensa corra, un texto ilegible es un catálogo entero desperdiciado—.

Tu proyecto son esas dos cosas. La parte 1 es el plano de medidas: las escalas de las que sale cada espacio y cada tamaño, para que todos los product-card del storefront sean coherentes. La parte 2 es la prueba de legibilidad: la auditoría de contraste que confirma, con un número y no con un vistazo, que el texto de la tarjeta se lee para cualquiera. Y como toda buena prueba, encuentra un problema antes de la tirada: el texto secundario en gris claro que "se veía bien" pero falla —y lo arreglas subiendo un step, no rehaciendo el diseño—. Un impresor que fija su plano y hace su prueba imprime con confianza; uno que improvisa descubre el error en la copia número cien mil. Tú pruebas ejecutando, antes de que el product-card se repita en todo Mercado.

Especificación del proyecto

Tu scales.js debe cumplir esto:

Parte 1 — las tres escalas.

  • Espaciado: define SPACE_BASE = 4 y space(step) = step × SPACE_BASE; imprime unos pasos (p.ej. 1, 2, 4, 6, 8, 12).
  • Tipografía: define TYPE_BASE = 16, TYPE_RATIO = 1.25 y fontSize(n) = base × ratio^n (redondeado); imprime los pasos text-sm (−1) a text-2xl (3).
  • Color: define la paleta con los steps que usarás (blue.400/600, gray.50/400/500/900, white) y mapea los roles semánticos a steps: color.primary → blue.600, color.surface → white, color.text → gray.900, color.muted → gray.400 (la elección inicial). Imprime los roles con su hex.

Parte 2 — la auditoría de contraste.

  • Implementa contrastRatio(fg, bg) con el algoritmo real de WCAG (hex→RGB, linealizar, luminancia relativa, ratio) y passesWCAG(ratio, { large }) con los umbrales AA/AAA.
  • Audita al menos estos cuatro pares del product-card: card.text/card.bg, price/card.bg, button.txt/button.bg, muted.text/card.bg. Imprime ratio y nivel de cada uno, marcando el que falla.
  • Muestra el fix del par que falla: mide muted.text con gray.400 (falla) y con gray.500 (pasa).

Restricciones (las convenciones del módulo):

  • Todo identificador, token, step, hex y nivel en inglés; solo comentarios y textos en español.
  • Sin dependencias: puro JavaScript, corre con node scales.js.
  • Salida literal y reproducible.
  • Las escalas se derivan de sus reglas (no listas tecleadas); el contraste usa el algoritmo real (verificable: #000000/#ffffff = 21:1).

Solución de referencia

Aquí está una solución completa que cumple la especificación. Estúdiala después de intentarlo por tu cuenta; el valor del proyecto está en construirlo tú, no en leer la respuesta:

// L8 proyecto — las escalas de Mercado (espaciado, tipografia, color) + auditoria de contraste.

// ---------- 1) escala de espaciado: base 4px ----------
const SPACE_BASE = 4; // px
function space(step) { return step * SPACE_BASE; } // px

// ---------- 2) escala tipografica: base 16px, ratio 1.25 ----------
const TYPE_BASE = 16, TYPE_RATIO = 1.25;
function fontSize(n) { return Math.round(TYPE_BASE * Math.pow(TYPE_RATIO, n) * 100) / 100; } // px
const typeSteps = { 'text-sm': -1, 'text-base': 0, 'text-lg': 1, 'text-xl': 2, 'text-2xl': 3 };

// ---------- 3) paleta de color: rampas por steps 50..900 ----------
const palette = {
  blue: { 400: '#60a5fa', 600: '#2563eb' },
  gray: { 50: '#f9fafb', 400: '#9ca3af', 500: '#6b7280', 900: '#111827' },
  white: '#ffffff',
};

// ---------- 4) contraste WCAG (algoritmo real) ----------
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;

// ===== salida =====
console.log('=== 1) escala de espaciado (base 4px) ===');
console.log([1, 2, 4, 6, 8, 12].map((s) => 'space-' + s + '=' + space(s) + 'px').join('  '));

console.log('\n=== 2) escala tipografica (base 16px, ratio 1.25) ===');
for (const [name, n] of Object.entries(typeSteps)) console.log('  ' + name.padEnd(11) + fontSize(n) + 'px');

console.log('\n=== 3) roles de color -> step de la paleta (eleccion inicial) ===');
const roles = {
  'color.primary': palette.blue[600],
  'color.surface': palette.white,
  'color.text':    palette.gray[900],
  'color.muted':   palette.gray[400], // elegido "a ojo": se ve sutil. La auditoria dira si sirve.
};
for (const [role, hex] of Object.entries(roles)) console.log('  ' + role.padEnd(16) + hex);

console.log('\n=== 4) auditoria de contraste del product-card ===');
console.log('  ' + 'par'.padEnd(24) + 'ratio'.padEnd(10) + 'nivel');
console.log('  ' + '-'.repeat(42));
const pairs = [
  { name: 'card.text / card.bg',   fg: palette.gray[900], bg: palette.white,     large: false },
  { name: 'price / card.bg',       fg: palette.blue[600], bg: palette.white,     large: false },
  { name: 'button.txt / button.bg',fg: palette.white,     bg: palette.blue[600], large: false },
  { name: 'muted.text / card.bg',  fg: palette.gray[400], bg: palette.white,     large: false },
];
for (const p of pairs) {
  const ratio = contrastRatio(p.fg, p.bg);
  const level = passesWCAG(ratio, { large: p.large });
  const mark = level === 'FAIL' ? '  <-- FALLA' : '';
  console.log('  ' + p.name.padEnd(24) + (r2(ratio) + ':1').padEnd(10) + level + mark);
}

console.log('\n=== 5) el fix del par que falla: gray.400 -> gray.500 ===');
const before = contrastRatio(palette.gray[400], palette.white);
const after  = contrastRatio(palette.gray[500], palette.white);
console.log('  muted.text gray.400 (#9ca3af): ' + r2(before) + ':1  ' + passesWCAG(before, {}));
console.log('  muted.text gray.500 (#6b7280): ' + r2(after)  + ':1  ' + passesWCAG(after, {}));

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

=== 1) escala de espaciado (base 4px) ===
space-1=4px  space-2=8px  space-4=16px  space-6=24px  space-8=32px  space-12=48px

=== 2) escala tipografica (base 16px, ratio 1.25) ===
  text-sm    12.8px
  text-base  16px
  text-lg    20px
  text-xl    25px
  text-2xl   31.25px

=== 3) roles de color -> step de la paleta (eleccion inicial) ===
  color.primary   #2563eb
  color.surface   #ffffff
  color.text      #111827
  color.muted     #9ca3af

=== 4) auditoria de contraste del product-card ===
  par                     ratio     nivel
  ------------------------------------------
  card.text / card.bg     17.74:1   AAA
  price / card.bg         5.17:1    AA
  button.txt / button.bg  5.17:1    AA
  muted.text / card.bg    2.54:1    FAIL  <-- FALLA

=== 5) el fix del par que falla: gray.400 -> gray.500 ===
  muted.text gray.400 (#9ca3af): 2.54:1  FAIL
  muted.text gray.500 (#6b7280): 4.83:1  AA

Lee la salida como el entregable que es, en sus dos partes.

La parte 1 (bloques 1–3) es tu plano de medidas. El espaciado (space-4 = 16px) sale de la base 4px; la tipografía (text-lg = 20px) sale del ratio 1.25 —cada tamaño es 1.25 veces el anterior: 20/16, 25/20, 31.25/25, todos exactamente 1.25—; y los roles de color apuntan a steps de la paleta (color.primary → #2563eb, que es blue.600). Ninguno de estos valores está tecleado a mano: todos se derivan de una regla o apuntan a un step. Ese es el punto del módulo —los valores del sistema tienen origen, no son gustos—. Fíjate en color.muted = #9ca3af (gray.400): se eligió "a ojo" porque se ve sutil. La parte 2 dirá si esa elección sobrevive a la medición.

La parte 2 (bloques 4–5) es tu prueba de legibilidad, y encuentra el problema antes de la tirada. Tres de los cuatro pares pasan: el nombre del producto (gray.900/blanco) da 17.74:1 AAA, y el precio y el botón (blue.600 con blanco, en cualquier orden porque el ratio es simétrico) dan 5.17:1 AA. Pero el cuarto —el texto secundario muted.text en gray.400— da 2.54:1 y FALLA AA. El gris que "se veía bien" no se lee. El bloque 5 lo arregla como el sistema permite: subir de step, de gray.400 a gray.500 (#6b7280), que da 4.83:1 y pasa AA —sin inventar un color, sin tocar el fondo, solo moviéndose por la rampa—. Ese gray.500 se vuelve el valor real de color.muted para texto.

Junta las dos partes y tienes la primera mitad del sistema de Mercado cerrada: las escalas que dan coherencia a cada espacio, tamaño y color (parte 1) y el contraste que garantiza que la UI se lee (parte 2). Es la teoría del módulo convertida en el plano y la prueba de un componente real, ejecutados.

Extensiones (opcionales, para ir más lejos)

Si quieres exprimir el proyecto, prueba estas ampliaciones —cada una refuerza una lección del módulo—:

  • Verifica el algoritmo (L6). Antes de la auditoría, agrega la prueba de oro: contrastRatio('#000000', '#ffffff') debe dar exactamente 21 y contrastRatio('#ffffff', '#ffffff') exactamente 1. Si no dan clavado, tu algoritmo se desvió del real. Es la red de seguridad de todo el bloque de contraste.
  • Audita el texto grande (L5). Agrega un par de texto grande (un text-2xl, large: true) y muestra cómo un ratio que falla para texto normal puede pasar para grande. Confirma que passesWCAG usa el umbral correcto según large.
  • Mide contra el fondo equivocado (L5/L7). Mide muted.text gray.500 contra blue.600 en vez de contra blanco y verás 1.07:1 —invisible—. Demuestra por qué el fondo real del texto es el que cuenta, no el del body.
  • Emite las escalas como config de Tailwind (L2/L3 + M3). Genera, desde space y fontSize, el objeto theme.extend.spacing y theme.extend.fontSize que Tailwind leería. Verás la escala que construiste conectándose con la config del módulo 3.

Ninguna es necesaria para cumplir el proyecto; todas son buen entrenamiento para el resto de la guía.

Errores comunes

Teclear los valores de las escalas en vez de derivarlos. Qué pasa: se escribe space-4 = 16px y text-lg = 20px como literales, sin la fórmula. Por qué pasa: es más rápido y "el resultado es el mismo". Cómo detectarlo: tu código tiene una lista de valores en vez de una función step × base o base × ratio^n. Cómo corregirlo: deriva las escalas de sus reglas. El valor literal hoy coincide, pero pierdes lo que hace a una escala una escala: cambiar la base (o el ratio) recalcula todo de golpe, y agregar un peldaño es evaluar la fórmula, no buscar en una lista. Una escala tecleada es una lista de valores sueltos disfrazada —justo lo que el módulo existe para reemplazar—.

Aprobar el color por cómo se ve y saltarse la auditoría. Qué pasa: se define color.muted = gray.400 porque se ve elegante y no se corre la parte 2. Por qué pasa: en el monitor del diseñador, 2.54:1 "se lee". Cómo detectarlo: tu proyecto tiene las escalas pero no la auditoría, o la auditoría no marca ningún fallo cuando gray.400 debería fallar. Cómo corregirlo: la mitad del entregable es la auditoría —un sistema sin contraste medido no está terminado—. gray.400 sobre blanco es el fallo canónico (2.54), y solo aparece si mides. Corre la parte 2 siempre; el número encuentra lo que el ojo aprueba.

Inventar la salida en vez de ejecutarla. Qué pasa: se escribe el "Qué esperar" a mano, calculando los ratios mentalmente. Por qué pasa: parece que uno "ya sabe" qué va a dar. Cómo detectarlo: tu salida reportada no coincide carácter por carácter con la real —un ratio con el decimal cambiado, un nivel mal clasificado—. Cómo corregirlo: corre node scales.js de verdad y pega su salida literal. Todo el módulo se para sobre la honestidad de "esto es lo que la máquina imprimió". Un ratio mal calculado a mano es exactamente el tipo de error que el contraste medido existe para eliminar; no lo reintroduzcas en la verificación —y menos en un número del que depende la accesibilidad—.

Rúbrica de autoevaluación

Marca cada punto; si todos están, dominaste el módulo:

  • Espaciado derivado. space(step) = step × 4 genera la serie; los valores no están tecleados.
  • Tipografía modular. fontSize(n) = 16 × 1.25^n genera los tamaños; cada uno es 1.25× el anterior.
  • Roles → steps. Los roles de color apuntan a steps de la paleta (color.primary → blue.600), no a hex sueltos.
  • Contraste real. contrastRatio implementa el algoritmo de WCAG (linealización sRGB, luminancia, +0.05) y da 21:1 para negro/blanco.
  • Auditoría completa. Los cuatro pares del product-card se miden y clasifican; el par muted (gray.400) sale como FAIL.
  • Fix por la rampa. El par que falla se arregla subiendo de step (gray.400 → gray.500), no inventando un color; gray.500 pasa AA.
  • Salida literal. Corriste node scales.js de verdad y la salida coincide con lo que reportas —no inventaste el output—.

Resumen y cierre del módulo

Con este proyecto cerraste el módulo 4 haciendo, no solo leyendo. Definiste las tres escalas de Mercado —espaciado derivado de la base 4px, tipografía derivada del ratio 1.25, y la paleta de color con roles apuntando a steps— y auditaste el contraste del product-card: tres pares pasan (el nombre AAA con 17.74:1, el precio y el botón AA con 5.17:1) y uno falla (el texto secundario gray.400 con 2.54:1), que arreglaste subiendo a gray.500 (4.83:1, AA). Con el impresor que fija su plano de medidas y hace su prueba de legibilidad antes de la tirada viste por qué se hace así: derivas los valores de reglas para que las copias sean coherentes, y mides el contraste para garantizar que se leen —encontrando el problema antes de que se multiplique por todo el storefront—.

Da un paso atrás y mira lo que aprendiste en las ocho lecciones. Sabes que el espaciado sale de una base pequeña y sus múltiplos (L2), que la tipografía sale de una escala modular geométrica (L3), y que el color es una paleta de rampas en steps de claridad (L4). Sabes que el contraste es un número con umbrales fijos —AA 4.5/3, AAA 7/4.5— que el sistema debe garantizar (L5), que se calcula con un algoritmo real de cuatro pasos verificable contra 21:1 (L6), y que elegir pares accesibles es medir el par y, si falla, moverse por la rampa (L7). Los valores que en los módulos 2 y 3 solo consumías ahora tienen origen y garantía: cada espacio, cada tamaño y cada par de color de Mercado sale de una regla y pasa (o se arregla para pasar) un umbral medido.

Lo que no hiciste todavía —a propósito— es aplicar estas escalas y colores a un componente configurable. Definiste los valores y probaste su contraste, pero no construiste un Button que reciba size="lg" y elija el text-lg de tu escala, ni un ProductCard con variantes. Eso empieza ahora. El módulo 5 — Componentes con variantes toma estas escalas y las pone dentro de componentes que se configuran por props (el patrón cva): un Button cuyo size elige un paso de tu escala de espaciado y tipografía, cuyo variant elige un par de color que —ya lo sabes— debe pasar contraste. Las escalas que definiste aquí empiezan a vivir dentro de los componentes que las consumen.

Recursos

  • WebAIM, "Contrast Checker" — webaim.org/resources/contrastchecker. Verifica los ratios de tu auditoría (gray.900/blanco = 17.74, blue.600/blanco = 5.17, gray.400/blanco = 2.54); la herramienta de referencia. En inglés.
  • W3C, "Contrast (Minimum)" (WCAG 2.1, 1.4.3) — w3.org/WAI/WCAG21/Understanding/contrast-minimum.html. El umbral AA que tu auditoría aplica; la referencia normativa. En inglés.
  • Tailwind CSS, "Theme" — tailwindcss.com/docs/theme. Cómo las escalas que definiste (spacing, fontSize, colors) se declaran en la config para generar utilidades —el puente de vuelta al módulo 3—. En inglés.
  • Type Scale — type-scale.com. Verifica tu escala tipográfica: base 16, ratio 1.25, y compara los tamaños con los que imprimió tu proyecto. En inglés.