Módulo 8: Project Build Mercados Design System

Haz el catálogo responsive y dark

Descripción

El ProductCard y el Button de la lección anterior tienen sus clases resueltas, su color verificado — y por ahora, viven en una sola pantalla imaginaria, de un solo tamaño y un solo tema. Esta lección les da lo que el módulo 6 enseñó: una sola cadena de clases que responde al ancho de la pantalla (el catálogo, de 1 a 4 columnas) y al tema (el Button, que ya no necesita ni un dark: porque su color vive en tokens). Vas a correr resolveClasses sobre el grid del catálogo, y vas a ver algo que las lecciones anteriores no mostraron todavía: qué pasa cuando cambias de tema, el Button no necesita ningún cambio en su class="" — el mismo bg-primary text-on-primary que escribiste en la lección 5 resuelve a colores distintos, porque los tokens debajo cambiaron, no las clases.

Este es el momento en que el sistema completo —tokens, utilidades, contraste, variantes— se encuentra con la dimensión que le faltaba: el contexto de uso real (una pantalla de un ancho específico, un usuario con un tema específico). Todo lo anterior se probó en abstracto; aquí se prueba en las seis combinaciones que un usuario real de Mercado puede encontrarse.

Conexión con el módulo. Esta lección integra el módulo 6 completo — los prefijos responsive mobile-first, la variante dark: frente al theming por tokens, y la combinación de ambos ejes sin duplicar el componente — aplicado al catálogo y al Button que construiste en la lección 5. Es la penúltima capa antes de las primitivas accesibles de la lección 7, y la que deja el sistema listo para la corrida end-to-end de la lección 8.

Una analogía: la vidriera de la tienda, de día y de noche, angosta y ancha

Piensa en la vidriera de una tienda física de Mercado. El mismo aparador —los mismos productos, la misma disposición de fondo— tiene que verse bien en dos circunstancias que varían de forma completamente independiente. Una es el ancho de la calle: en una calle angosta, la vidriera muestra pocos productos en primer plano, bien espaciados; en una avenida ancha, con más espacio visual disponible, puede mostrar una fila completa. La otra es la hora del día: de día, la luz natural entra y la iluminación interior es sutil; de noche, se encienden luces cálidas que cambian el tono de toda la vidriera sin mover un solo producto de lugar. Un escaparatista competente no arma dos vidrieras —una angosta y una ancha, una diurna y una nocturna—; arma una vidriera cuyo diseño responde a las dos circunstancias por separado. El ancho de la calle no afecta qué luces se encienden de noche; la hora del día no afecta cuántos productos entran en la fila. Dos ejes, perpendiculares, una sola vidriera — exactamente lo que vas a construir en esta lección para el catálogo de Mercado.

Ejemplo trabajado: el catálogo por ancho, el Button por tema, y la ficha completa

// catalog.js — el catalogo de Mercado, responsive + dark, sin duplicar ni el catalogo ni el Button.
const BREAKPOINTS = { sm: 640, md: 768, lg: 1024, xl: 1280 };

function propertyOf(base) {
  if (['flex', 'inline-flex', 'grid', 'block'].includes(base)) return 'display';
  if (base.startsWith('grid-cols-')) return 'grid-cols';
  if (base.startsWith('gap-')) return 'gap';
  if (base.startsWith('p-')) return 'padding';
  if (base.startsWith('bg-')) return 'background';
  if (/^text-(xs|sm|base|lg|xl|2xl|3xl)$/.test(base)) return 'font-size';
  if (base.startsWith('text-')) return 'text-color';
  if (base.startsWith('rounded')) return 'border-radius';
  return base;
}
function parseClass(raw) {
  const parts = raw.split(':');
  const base = parts.pop();
  let bp = null, dark = false;
  for (const p of parts) { if (p in BREAKPOINTS) bp = p; else if (p === 'dark') dark = true; }
  return { raw, base, bp, dark };
}
function resolveClasses(classList, { viewport, theme }) {
  const parsed = classList.split(/\s+/).filter(Boolean).map(parseClass);
  const active = parsed.filter(c =>
    (c.bp === null || viewport >= BREAKPOINTS[c.bp]) && (!c.dark || theme === 'dark'));
  const winners = new Map();
  for (const c of active) {
    const prop = propertyOf(c.base);
    const score = (c.bp ? BREAKPOINTS[c.bp] : 0) * 2 + (c.dark ? 1 : 0);
    const cur = winners.get(prop);
    if (!cur || score > cur.score) winners.set(prop, { raw: c.raw, score });
  }
  const keep = new Set([...winners.values()].map(w => w.raw));
  return parsed.filter(c => keep.has(c.raw)).map(c => c.raw);
}

// tokens (de la leccion 2) para button.bg / button.text.
function resolveToken(name, theme) {
  const tokens = {
    component: { 'button.bg': 'color.primary', 'button.text': 'color.on-primary' },
    semantics: {
      'color.primary':    { light: 'blue.600', dark: 'blue.400' },
      'color.on-primary': { light: 'white',    dark: 'gray.900' },
    },
    primitives: { 'blue.600': '#2563eb', 'blue.400': '#60a5fa', 'white': '#ffffff', 'gray.900': '#111827' },
  };
  if (name in tokens.component) return resolveToken(tokens.component[name], theme);
  if (name in tokens.semantics) return resolveToken(tokens.semantics[name][theme], theme);
  return tokens.primitives[name];
}

// la UNA cadena del catalogo: layout responsive por prefijos. El color lo llevan las cards, no el grid.
const catalogGrid = 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-4 p-4';

// 1) el catalogo por breakpoint.
console.log('=== 1) el catalogo: columnas por breakpoint (layout, sin color: eso lo llevan las cards) ===\n');
for (const viewport of [360, 700, 1200]) {
  const active = resolveClasses(catalogGrid, { viewport, theme: 'light' });
  const cols = active.find(c => c.includes('grid-cols'));
  console.log(`viewport ${String(viewport).padStart(4)}px -> ${cols}`);
}

// 2) el Button primary en ambos temas: CERO dark: en su class="", el color sale del token.
console.log('\n=== 2) el Button primary en ambos temas: el color sale del TOKEN, cero dark: ===\n');
for (const theme of ['light', 'dark']) {
  console.log('  tema ' + theme.padEnd(6) + ' -> button.bg=' + resolveToken('button.bg', theme) + '  button.text=' + resolveToken('button.text', theme));
}

// 3) la ficha completa: 3 anchos x 2 temas.
console.log('\n=== 3) ficha: 3 anchos x 2 temas, layout del catalogo + color del Button ===\n');
for (const viewport of [360, 700, 1200]) {
  for (const theme of ['light', 'dark']) {
    const active = resolveClasses(catalogGrid, { viewport, theme });
    const cols = active.find(c => c.includes('grid-cols'));
    console.log(`${String(viewport).padStart(4)}px ${theme.padEnd(5)} -> ${cols.padEnd(16)} button.bg=${resolveToken('button.bg', theme)}  button.text=${resolveToken('button.text', theme)}`);
  }
}

Y así se ve el catálogo real, con su tailwind.config y sin un solo dark: en el marcado —esto se muestra—:

// Catalog.jsx — el catalogo de Mercado. Layout responsive por prefijos, color por tokens (via Button/Card).
function Catalog({ products }) {
  return (
    <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-4 p-4">
      {products.map((product) => (
        <li key={product.id}>
          <ProductCard product={product} />
        </li>
      ))}
    </ul>
  );
}
// ProductCard.jsx trae su propio bg-surface text-foreground (leccion 5).
// Button.jsx trae su propio bg-primary text-on-primary (leccion 5, ya con el fix de la L4).
// Ni el catalogo, ni la card, ni el boton, tienen un solo "dark:" — el tema vive en los tokens.

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

=== 1) el catalogo: columnas por breakpoint (layout, sin color: eso lo llevan las cards) ===

viewport  360px -> grid-cols-1
viewport  700px -> sm:grid-cols-2
viewport 1200px -> lg:grid-cols-4

=== 2) el Button primary en ambos temas: el color sale del TOKEN, cero dark: ===

  tema light  -> button.bg=#2563eb  button.text=#ffffff
  tema dark   -> button.bg=#60a5fa  button.text=#111827

=== 3) ficha: 3 anchos x 2 temas, layout del catalogo + color del Button ===

 360px light -> grid-cols-1      button.bg=#2563eb  button.text=#ffffff
 360px dark  -> grid-cols-1      button.bg=#60a5fa  button.text=#111827
 700px light -> sm:grid-cols-2   button.bg=#2563eb  button.text=#ffffff
 700px dark  -> sm:grid-cols-2   button.bg=#60a5fa  button.text=#111827
1200px light -> lg:grid-cols-4   button.bg=#2563eb  button.text=#ffffff
1200px dark  -> lg:grid-cols-4   button.bg=#60a5fa  button.text=#111827

Lee el bloque 1 como el eje del ancho, aislado: el catálogo pasa de una columna (360px, teléfono) a dos (700px, tablet) a cuatro (1200px, desktop) — el mismo mecanismo mobile-first del módulo 6, sin ninguna diferencia. El bloque 2 es el eje del tema, aislado, y aquí es donde se ve el trabajo de las lecciones 2 y 4 pagando dividendos: no hay ningún resolveClasses corriendo sobre el Button — el Button no necesita prefijo dark: porque bg-primary y text-on-primary ya son tokens que cambian de valor por tema. resolveToken('button.text', 'dark') da #111827 — exactamente el valor que la auditoría de la lección 4 verificó que se lee (6.98:1, AA) sobre button.bg en oscuro.

El bloque 3 junta los dos ejes, y confirma lo que el módulo 6 llamó "perpendiculares": recorre las columnas por ancho, con el tema fijo, y el grid cambia sin que el color del Button se mueva; recorre los pares de filas por tema, con el ancho fijo, y el color del Button cambia sin que el grid se mueva. Seis combinaciones, dos ejes que no se pisan, una sola Catalog.jsx y un solo Button.jsx — ningún CatalogMobile, ningún ButtonDark. Y ninguno de los dos componentes tiene, en su className, la palabra dark: — el catálogo porque su color no es responsabilidad suya (lo llevan las cards), y el Button porque su color vive en tokens que ya resuelven por tema.

Profundización: por qué el catálogo no necesita saber nada de temas

Fíjate en algo que quizás pasaste por alto: catalogGrid —la cadena del <ul>— no tiene ningún token de color, ni claro ni oscuro. No es que "se le olvidó" el dark mode — es que el catálogo, como componente, no tiene color propio. Su responsabilidad es el layout (cuántas columnas, cuánto espacio entre ellas); el color es responsabilidad de cada ProductCard que contiene, y cada ProductCard ya resuelve su propio tema con sus propios tokens (bg-surface, text-foreground de la lección 3). Esta separación —un componente de layout puro que no toca color, componentes de contenido que sí— es la misma disciplina de capas que el módulo 1 instaló desde la lección 1: cada pieza del sistema tiene una responsabilidad, y el catálogo tiene la de organizar el espacio, no la de decidir qué tan oscuro es "oscuro".

Errores comunes

Ponerle dark: al catálogo "por si acaso". Qué pasa: se escribe className="grid ... dark:bg-gray-900" en el <ul> del catálogo, aunque el catálogo no tenga fondo visible propio (el fondo real lo pintan las cards). Por qué pasa: la costumbre de "todo componente necesita su dark:" se aplica sin preguntar si el componente en cuestión tiene algo que cambiar por tema. Cómo detectarlo: agregas o quitas ese dark:bg-gray-900 y visualmente no cambia nada — es una clase muerta, generando CSS que nadie usa. Cómo corregirlo: pregúntate, para cada componente, "¿este elemento pinta algo que depende del tema?" — si la respuesta es no (como el <ul> del catálogo, que es solo un contenedor de layout), no necesita ni tokens de color ni dark:. El sistema no es "todo lleva theming"; es "el theming vive donde el color vive".

Duplicar resolveToken en cada archivo en vez de importarlo una vez. Qué pasa: cada componente (Button.jsx, ProductCard.jsx, Catalog.jsx) reimplementa su propia versión de la resolución de tokens, o peor, escribe los hex directamente. Por qué pasa: en el desarrollo rápido, copiar el fragmento que ya funcionó en otro archivo es más veloz que configurar un import compartido. Cómo detectarlo: si cambias un valor de token (por ejemplo, blue.600 por un azul distinto), tienes que buscar y reemplazar en varios archivos en vez de cambiar una sola fuente. Cómo corregirlo: los tokens viven en un archivo (tokens.js o su equivalente en CSS custom properties), y todo lo demás los consume vía var(--color-*) en CSS real, o vía un único módulo importado en el mundo de Node de este capstone. La duplicación en los ejemplos de esta guía existe por claridad pedagógica (cada lección es autocontenida); en un proyecto real, es exactamente el drift que el módulo 2 completo existe para eliminar.

Creer que "responsive + dark" significa que cada combinación necesita su propio test manual. Qué pasa: alguien intenta verificar el sistema abriendo el navegador en seis ventanas distintas, una por combinación de ancho y tema, y comparando a ojo. Por qué pasa: sin un modelo ejecutable, verificar "todas las combinaciones" a mano parece la única opción. Cómo detectarlo: el proceso de verificación no es reproducible — depende de que alguien recuerde revisar las seis combinaciones cada vez que algo cambia. Cómo corregirlo: la ficha del bloque 3 —generada por código, no por inspección visual— cubre las seis combinaciones en una sola ejecución, siempre, cada vez que corre. Es la misma ventaja que tuvo la auditoría de contraste de la lección 4 sobre "se ve bien a simple vista": un número reproducible vence a un vistazo.

Ejercicios

Ejercicio 1 — Agrega un breakpoint intermedio. Mercado decide que a partir de 900px (justo entre sm y lg) el catálogo debería mostrar 3 columnas, no saltar directo de 2 a 4. Agrega md:grid-cols-3 a catalogGrid y verifica con resolveClasses qué columna gana a 900px.

Ver solución
const catalogGrid = 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 md:gap-4 p-4';
const active = resolveClasses(catalogGrid, { viewport: 900, theme: 'light' });
console.log(active.find(c => c.includes('grid-cols')));
// md:grid-cols-3

A 900px se cumplen sm (640) y md (768), pero no lg (1024). De las que se cumplen, md:grid-cols-3 tiene el breakpoint mayor, así que gana sobre sm:grid-cols-2. Ni sm:grid-cols-2 ni lg:grid-cols-4 necesitaron tocarse — agregar un punto intermedio a la escala es agregar una clase, no reescribir las demás.

Ejercicio 2 — Verifica que el ProductCard tampoco necesita dark:. Usando resolveToken de esta lección (extendido con card.bg y card.text, de la lección 2), confirma que el ProductCard resuelve colores distintos en los dos temas sin que su className cambie.

Ver solución
// resolveToken extendido con card.bg / card.text (mismo patron que button.bg/button.text):
// 'card.bg': 'color.surface', 'card.text': 'color.text'
// 'color.surface': { light: 'white', dark: 'gray.900' }
// 'color.text': { light: 'gray.900', dark: 'gray.50' }

for (const theme of ['light', 'dark']) {
  console.log(theme, resolveToken('card.bg', theme), resolveToken('card.text', theme));
}
// light #ffffff #111827
// dark  #111827 #f9fafb

La className del ProductCardflex flex-col rounded-md bg-surface text-foreground (de la lección 5)— es idéntica en las dos filas de esta salida. Lo que cambió fue el valor que bg-surface y text-foreground resuelven, no las clases del componente. Es la misma prueba que el bloque 2 del ejemplo trabajado, aplicada a un segundo componente — confirma que el patrón "cero dark:, todo por tokens" no es una casualidad del Button, es la forma en que cualquier componente de Mercado maneja el tema.

Ejercicio 3 — Detecta el componente que rompería el patrón. Un desarrollador nuevo en el equipo agrega un PromoBanner con className="bg-yellow-300 dark:bg-yellow-700". ¿Qué le falta a este componente para seguir el patrón del resto del sistema de Mercado, y por qué el resultado visual podría ser el mismo pero el código no?

Ver solución

Le falta pasar por un token semántico (por ejemplo, color.promo, con { light: 'yellow.300', dark: 'yellow.700' }) en vez de un dark: explícito por elemento. El resultado visual puede ser idéntico —el banner sí se ve distinto en cada tema—, pero el código no sigue el patrón del sistema: si mañana Mercado decide que el amarillo de "promo" debe ser un poco más intenso, hay que buscar cada dark:bg-yellow-700 suelto en el código en vez de cambiar un solo valor en tokens.js. Es exactamente el error que "Errores comunes" del módulo 6 (lección 8) ya nombró — meter dark: por elemento reintroduce el drift que el theming por tokens existe para eliminar, y este ejercicio confirma que el error puede colarse en un componente nuevo aunque el resto del sistema ya lo evite.

Resumen y siguiente paso

En esta lección hiciste el catálogo de Mercado responsive (de 1 a 4 columnas con resolveClasses, mobile-first) y confirmaste que el Button de la lección 5 ya es dark sin ningún cambio — su color sale de button.bg/button.text, tokens que cambian de valor bajo el tema, no de un dark: en su className. La ficha de seis combinaciones (tres anchos × dos temas) probó que los dos ejes son perpendiculares: el ancho no toca el color, el tema no toca el layout. Con la vidriera que responde al ancho de la calle y a la hora del día por separado, sin ser dos vidrieras, entendiste por qué esta independencia entre ejes es la propiedad que hace que un sistema de diseño escale sin multiplicar componentes.

Antes de avanzar deberías poder: explicar por qué el catálogo, como componente, no necesita tokens de color propios; ejecutar resolveClasses sobre cualquier cadena con prefijos de ancho; y decir, sin mirar el código, por qué el Button no tiene ningún dark: en su className aunque cambie de color entre temas.

La lección 7 le da al sistema lo único que le falta: accesibilidad de teclado y foco para los componentes interactivos que van más allá de un botón — un Dialog de "vista rápida" del producto. Vas a construir uno a mano y auditarlo con a11yAudit, y después vas a montar el mismo Dialog sobre una primitiva accesible (el modelo Radix del módulo 7), vestida con los tokens que ya tienes, y auditarla también. La diferencia entre los dos números va a ser el argumento más claro de toda la guía a favor de las primitivas.

Recursos