Módulo 6: Responsive And Dark Mode

Proyecto: el catálogo de Mercado, responsive y dark

Descripción

Las siete lecciones anteriores te enseñaron a entender la capa responsive + dark por partes: los prefijos de ancho y el enfoque mobile-first (L2), los breakpoints como una escala del sistema (L3), la variante dark: (L4), las dos estrategias de dark mode (L5), el theming por tokens que evita el dark: por elemento (L6) y cómo los dos ejes se combinan sin duplicar el componente (L7). Este proyecto te pone a construirlo y verificarlo. Vas a tomar el catálogo de Mercado —la rejilla de product-cards— y hacerlo responsive (de 1 a N columnas con prefijos) y dark (toggle por .dark + tokens), sin duplicar el componente, y luego comprobar con el modelo Node qué clases y qué tokens quedan activos para cada combinación de breakpoint y tema.

El entregable tiene tres partes, y las tres importan. La parte 1 verifica el eje responsive: correr resolveClasses sobre la cadena del catálogo a varios anchos y ver, medido, cómo el grid pasa de 1 a 2 a 4 columnas. La parte 2 verifica el eje del tema: simular el toggle .dark y ver, vía resolveToken, cómo el color de fondo y texto cambia de valor entre claro y oscuro —sin un solo dark: en el marcado—. Y la parte 3 junta los dos ejes en una ficha completa: la matriz de anchos × temas que prueba que una sola class="" cubre las seis combinaciones. Juntas demuestran que dominas el módulo: no solo qué es responsive + dark, sino cómo armar la cadena de un componente real y verificar qué resuelve cada combinación antes de que el catálogo se repita por todo el storefront.

Conexión con el módulo. Este proyecto es la síntesis de las siete lecciones. Usa los prefijos mobile-first (L2) sobre la escala de breakpoints (L3), enruta el color por tokens en vez de dark: por elemento (L4, L6) bajo la estrategia class con su toggle (L5), y combina los dos ejes en una cadena sin duplicación (L7). Es también el cierre de la guía en su capa responsive + dark: el catálogo que en el módulo 3 vestiste con utilidades sueltas y cuyo Button en el módulo 5 hiciste con variantes, ahora responde al tamaño y al tema. Al terminar, el catálogo de Mercado deja de ser un layout fijo y claro y se vuelve un componente que se adapta —verificado antes de repetirse—.

Qué vas a construir

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

  1. El catálogo por breakpoint (parte 1): para tres anchos (360/700/1200), la clase de grid que resolveClasses deja activa —el catálogo pasando de 1 a 2 a 4 columnas—.
  2. El toggle .dark (parte 2): para <html class=""> y <html class="dark">, el tema activo y los colores a los que resuelven bg-surface y text-foreground vía resolveToken —el color cambiando por token, sin dark: en el marcado—.
  3. La ficha completa (parte 3): la matriz de tres anchos × dos temas, mostrando por cada combinación las columnas del grid y los colores de fondo y texto —la prueba de que una sola class="" cubre las seis—.

Como en todo el módulo, el JSX del catálogo, su tailwind.config y el CSS de tokens se muestran —es lo que escribes—, y la resolución de clases y tokens se ejecuta en Node. El navegador y Tailwind no corren en un agente, así que mostramos lo que se escribe y ejecutamos lo que lo valida. Recuerda que resolveClasses es un modelo pedagógico del cascade de prefijos de Tailwind: en producción, el navegador aplica las media queries y la clase .dark, pero la lógica de qué queda activo es la que ejecutas —y ejecutarla es lo que prueba que entiendes qué resuelve cada combinación—.

Una analogía: el plano del edificio antes de construir las cien unidades

Una constructora que va a levantar un edificio con cien departamentos idénticos —la misma unidad repetida piso por piso— no manda a construir sin antes validar el plano de la unidad tipo: un solo plano que muestra, para cada configuración (el departamento de esquina con más luz, el interior, el de planta baja), exactamente qué queda igual y qué cambia. El plano no es burocracia: es la prueba de que la unidad parametrizada produce lo correcto para cada caso antes de que la grúa la repita cien veces. Si el departamento de esquina debía tener una ventana extra y el plano muestra que no la tiene, se corrige el plano —no el departamento número cien—.

Tu proyecto es ese plano de la unidad tipo. La cadena del product-card es la unidad; las combinaciones de ancho y tema son las configuraciones (esquina, interior, planta baja). La ficha de la parte 3 lista, para cada combinación, exactamente qué queda activo —cuántas columnas, qué color de fondo—: la prueba de que el componente parametrizado produce lo correcto para el teléfono claro, el tablet oscuro, el desktop de cada tema. Y confirma, como todo buen plano, que lo que debía cambiar cambia y lo que debía quedarse se queda: el grid crece con el ancho pero no con el tema, el color sigue el tema pero no el ancho. Un constructor que valida el plano antes de la grúa repite con confianza; uno que construye sin plano descubre la ventana faltante en la unidad cien. Tú verificas ejecutando, antes de que el product-card se repita por todo Mercado.

Especificación del proyecto

Tu catalog.js debe cumplir esto:

El modelo (compartido por las tres partes).

  • Incluye resolveClasses(classList, { viewport, theme }) —el modelo del cascade de prefijos del módulo— con su BREAKPOINTS, propertyOf y parseClass.
  • Incluye resolveToken(name, theme) —el del módulo 2— con al menos los semánticos color.surface y color.text, cada uno con override { light, dark }.
  • Define la cadena del catálogo: grid responsive (grid-cols-1 sm:grid-cols-2 lg:grid-cols-4), gap responsive, padding y radio constantes, y color por tokens (bg-surface text-foreground) —sin un solo dark:—.

Parte 1 — el catálogo por breakpoint.

  • Corre resolveClasses sobre la cadena a 360, 700 y 1200 px (tema claro) e imprime la clase de grid activa en cada uno (1 → 2 → 4 columnas).

Parte 2 — el toggle .dark.

  • Modela activeTheme({ htmlHasDark }) (estrategia class: el tema sale de si <html> tiene .dark).
  • Para htmlHasDark en false y true, imprime el <html class="...">, el tema activo y los colores de bg-surface y text-foreground vía resolveToken.

Parte 3 — la ficha completa.

  • Recorre los tres anchos × dos temas e imprime, por combinación, las columnas del grid y los colores de fondo y texto. Cierra imprimiendo las clases activas completas de una combinación (p. ej. 1200px dark).

Restricciones (las convenciones del módulo):

  • Todo identificador, clase, token y valor en inglés; solo comentarios y textos en español.
  • Sin dependencias: puro JavaScript, corre con node catalog.js.
  • Salida literal y reproducible.
  • El color va por tokens (sin dark: en la cadena); el layout por prefijos. Los dos ejes no comparten propiedad.

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 — el catalogo de Mercado responsive + dark, sin duplicar el componente.
// resolveClasses es un modelo pedagogico del cascade de prefijos de Tailwind.
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 + resolveToken del modulo 2.
const tokens = {
  primitives: { 'gray.50': '#f9fafb', 'gray.900': '#111827', 'white': '#ffffff' },
  semantics: { 'color.surface': { light: 'white', dark: 'gray.900' }, 'color.text': { light: 'gray.900', dark: 'gray.50' } },
};
function resolveToken(name, theme) {
  if (name in tokens.semantics)  return resolveToken(tokens.semantics[name][theme], theme);
  if (name in tokens.primitives) return tokens.primitives[name];
  throw new Error('Token desconocido: ' + name);
}

// estrategia 'class': el tema sale del toggle (.dark en <html>), no del SO.
function activeTheme(env) { return env.htmlHasDark ? 'dark' : 'light'; }

// la UNA cadena del catalogo: layout responsive por prefijos, color por tokens.
const catalog = 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-4 p-4 rounded-md bg-surface text-foreground';

// parte 1 — la rejilla responde al ancho: 1 -> 2 -> 4 columnas, sin duplicar.
console.log('=== 1) el catalogo: columnas por breakpoint ===\n');
for (const viewport of [360, 700, 1200]) {
  const active = resolveClasses(catalog, { viewport, theme: 'light' });
  const cols = active.find(c => c.includes('grid-cols'));
  console.log(`viewport ${String(viewport).padStart(4)}px -> ${cols}`);
}

// parte 2 — el toggle .dark cambia el tema; el color sale de los tokens.
console.log('\n=== 2) el toggle .dark: el color sale de los tokens ===\n');
for (const htmlHasDark of [false, true]) {
  const theme = activeTheme({ htmlHasDark });
  console.log(`<html class="${htmlHasDark ? 'dark' : ''}"> -> tema ${theme.padEnd(5)} -> bg-surface=${resolveToken('color.surface', theme)}  text-foreground=${resolveToken('color.text', theme)}`);
}

// parte 3 — la ficha completa: 3 anchos x 2 temas, UNA sola cadena.
console.log('\n=== 3) ficha: una class="" cubre las 6 combinaciones ===\n');
for (const viewport of [360, 700, 1200]) {
  for (const theme of ['light', 'dark']) {
    const active = resolveClasses(catalog, { viewport, theme });
    const cols = active.find(c => c.includes('grid-cols'));
    console.log(`${String(viewport).padStart(4)}px ${theme.padEnd(5)} -> ${cols.padEnd(16)} bg-surface=${resolveToken('color.surface', theme)}  text-foreground=${resolveToken('color.text', theme)}`);
  }
}
console.log('\nclases activas @ 1200px, dark:');
console.log('  ' + resolveClasses(catalog, { viewport: 1200, theme: 'dark' }).join(' '));

Y así se ve el catálogo de Mercado como marcado real, con su tailwind.config y su CSS de tokens (esto se muestra —es tu entregable de marcado—):

// tailwind.config.js — las utilidades de color apuntan a tokens; estrategia class.
export default {
  darkMode: 'class',
  theme: { extend: { colors: {
    surface: 'var(--color-surface)',
    foreground: 'var(--color-text)',
  } } },
};
/* tokens con override por tema (el bloque .dark del modulo 2) */
:root  { --color-surface: #ffffff; --color-text: #111827; }
.dark  { --color-surface: #111827; --color-text: #f9fafb; }
// el catalogo: UNA cadena por card. Layout responsive por prefijos; color por tokens.
function Catalog({ products }) {
  return (
    <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-4">
      {products.map((p) => (
        <li key={p.id} className="p-4 rounded-md bg-surface text-foreground">
          <img src={p.image} alt={p.name} />
          <h3>{p.name}</h3>
          <p>{p.price}</p>
        </li>
      ))}
    </ul>
  );
}
// ni un dark: en el marcado. El toggle agrega .dark a <html> y los tokens cambian de valor.

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

=== 1) el catalogo: columnas por breakpoint ===

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

=== 2) el toggle .dark: el color sale de los tokens ===

<html class=""> -> tema light -> bg-surface=#ffffff  text-foreground=#111827
<html class="dark"> -> tema dark  -> bg-surface=#111827  text-foreground=#f9fafb

=== 3) ficha: una class="" cubre las 6 combinaciones ===

 360px light -> grid-cols-1      bg-surface=#ffffff  text-foreground=#111827
 360px dark  -> grid-cols-1      bg-surface=#111827  text-foreground=#f9fafb
 700px light -> sm:grid-cols-2   bg-surface=#ffffff  text-foreground=#111827
 700px dark  -> sm:grid-cols-2   bg-surface=#111827  text-foreground=#f9fafb
1200px light -> lg:grid-cols-4   bg-surface=#ffffff  text-foreground=#111827
1200px dark  -> lg:grid-cols-4   bg-surface=#111827  text-foreground=#f9fafb

clases activas @ 1200px, dark:
  grid lg:grid-cols-4 md:gap-4 p-4 rounded-md bg-surface text-foreground

Lee la salida como el plano que es, en sus tres partes.

La parte 1 es el eje responsive aislado. El catálogo pasa de grid-cols-1 (360px, teléfono) a sm:grid-cols-2 (700px, tablet chico) a lg:grid-cols-4 (1200px, desktop): una tarjeta por fila, luego dos, luego cuatro, conforme aparece ancho. Una sola cadena de grid, tres layouts —sin un CatalogMobile ni un CatalogDesktop—. Fíjate en que el diseño salta md (va de sm a lg): usas solo los breakpoints donde el contenido pide un cambio, no todos (L3).

La parte 2 es el eje del tema aislado. Con <html class=""> (sin .dark), el tema es claro y bg-surface resuelve a #ffffff, text-foreground a #111827. Con <html class="dark">, el toggle encendió el interruptor, el tema es oscuro, y los mismos tokens resuelven a #111827 y #f9fafb —el fondo y el texto se invirtieron—. Y lo clave: en el marcado del catálogo no hay ningún dark:. El color cambió porque el token cambió de valor bajo .dark (L6), no porque escribieras un par por elemento. El toggle es una clase; el resto lo hacen los tokens.

La parte 3 junta los dos ejes en la ficha completa, y es donde se ve que no se pisan. Recorre las columnas (fijando el tema) y el grid crece con el ancho, idéntico en claro y oscuro —el tema no toca el layout—. Recorre los pares de filas (fijando el ancho) y el color sigue el tema, idéntico en los tres anchos —el ancho no toca el color—. Seis combinaciones, dos ejes perpendiculares, una sola class="". La última línea muestra las clases activas a 1200px dark —grid lg:grid-cols-4 md:gap-4 p-4 rounded-md bg-surface text-foreground—: conviven las responsive resueltas (lg:grid-cols-4, md:gap-4), las de color por token (bg-surface, text-foreground) y las constantes (grid, p-4, rounded-md). El componente responsive + dark sin duplicar, verificado.

Junta las tres partes y tienes el catálogo de Mercado cerrado sobre las capas de todo el módulo: el layout que responde al ancho (parte 1), el color que responde al tema por tokens (parte 2) y la ficha que prueba que una cadena cubre las seis combinaciones (parte 3). Es la teoría del módulo convertida en un componente de sistema, verificado antes de repetirse.

Extensiones (opcionales, para ir más lejos)

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

  • Agrega un breakpoint intermedio (L3). Suma md:grid-cols-3 a la cadena del grid y corre la ficha a 800px. Confirma que a ese ancho gana md:grid-cols-3 (tres columnas) —entre sm (dos) y lg (cuatro)—, y que las demás combinaciones no se alteran. Un punto más en la escala, resuelto por el mismo modelo.
  • Verifica el contraste por tema (L4, L6, módulo 4). Trae el contrastRatio(fg, bg) del módulo 4 y córrelo sobre los pares resueltos: color.text sobre color.surface en claro y en oscuro. Confirma que los dos pasan AA —y descubre, midiendo, que un tema no hereda la accesibilidad del otro—.
  • Modela el eje del tamaño del texto (L2). Agrega text-sm md:text-base lg:text-lg al card y muestra en la ficha qué tamaño de fuente queda activo por ancho, junto a las columnas. Un segundo eje responsive (tipografía) sobre el mismo componente.
  • Simula la estrategia media (L5). Cambia activeTheme para que lea prefersDark (estrategia media) en vez de htmlHasDark, y muestra que con el sistema en oscuro pero sin toggle, media da dark y class daría light. El mismo entorno, dos estrategias, dos temas.

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

Errores comunes

Duplicar el catálogo para móvil o para dark en vez de combinar ejes. Qué pasa: se crea un CatalogMobile de una columna o un CatalogDark con colores oscuros escritos, aparte del catálogo base. Por qué pasa: cada combinación "se ve distinta", así que parece que necesita su propio componente. Cómo detectarlo: tienes más de un componente de catálogo, y un cambio a la forma de la tarjeta hay que hacerlo en varios. Cómo corregirlo: un catálogo con una class="" que lleva los prefijos de ancho (eje responsive) y los tokens de color (eje del tema). Las seis combinaciones de la ficha son el producto de dos controles, no seis componentes. Si te descubres creando un CatalogX, esa es la señal de que un eje debía ir en la cadena, no en un componente nuevo —el error que el módulo entero desarma—.

Meter dark: por elemento en vez de enrutar el color por tokens. Qué pasa: el card se escribe con bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-50 —el par de tema por elemento— en lugar de bg-surface text-foreground. Por qué pasa: dark: explícito (L4) es lo primero que se aprende, y funciona. Cómo detectarlo: tu marcado repite el color oscuro en cada elemento, y si cambias el gris del tema tienes que tocar cada uno. Cómo corregirlo: enruta el color por token (bg-surface, text-foreground) para que el dark viva en el override de --color-surface bajo .dark, no en el marcado. La ficha de la parte 2 es la prueba: el color cambia sin que la cadena de clases tenga un solo dark:. Escribir el par por elemento reintroduce el drift que el token elimina.

Inventar la salida en vez de ejecutarla. Qué pasa: se escribe el "Qué esperar" a mano, armando la ficha mentalmente. Por qué pasa: parece que uno "ya sabe" qué columnas y qué colores dará cada combinación. Cómo detectarlo: tu salida reportada no coincide carácter por carácter con la real —una columna mal resuelta, un color intercambiado, un breakpoint que ganó de más—. Cómo corregirlo: corre node catalog.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ó". Una ficha armada a mano es exactamente el tipo de error que verificar el plano existe para atrapar; no lo reintroduzcas en la verificación.

Rúbrica de autoevaluación

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

  • Grid responsive. La cadena usa prefijos de ancho sobre la escala (grid-cols-1 sm:grid-cols-2 lg:grid-cols-4), mobile-first (la base es una columna).
  • Color por tokens, sin dark:. El color va por bg-surface/text-foreground; no hay un solo dark: en el marcado del catálogo.
  • Estrategia class. El tema sale del toggle .dark en <html> (darkMode: 'class'), no de prefers-color-scheme.
  • Ejes independientes. El grid cambia por ancho y no por tema; el color cambia por tema y no por ancho (la ficha lo confirma).
  • Sin duplicación. Un solo componente de catálogo cubre las seis combinaciones; no hay CatalogMobile ni CatalogDark.
  • Ficha completa. La parte 3 recorre los tres anchos × dos temas y muestra columnas y colores por combinación.
  • Salida literal. Corriste node catalog.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 6 haciendo, no solo leyendo. Tomaste el catálogo de Mercado y lo hiciste responsive (de 1 a 2 a 4 columnas con prefijos sobre la escala) y dark (toggle .dark + color por tokens), sin duplicar el componente, y verificaste su ficha: el grid creciendo con el ancho, el color siguiendo el tema, y una sola class="" cubriendo las seis combinaciones. Con el plano de la unidad tipo antes de construir viste por qué se hace así: validas qué resuelve cada combinación antes de que el product-card se repita por todo el storefront, y confirmas que lo que debía cambiar cambia (el grid por ancho, el color por tema) y lo que debía quedarse se queda (cada eje sin tocar al otro).

Da un paso atrás y mira lo que aprendiste en las ocho lecciones. Sabes que las clases sin prefijo son la base móvil y que los prefijos sm:/md:/lg: agregan estilos hacia arriba —mobile-first, agregar cuartos cuando hay terreno (L2)—. Sabes que esos breakpoints son una escala compartida del sistema, no valores sueltos, y que el dónde quebrar lo dicta el contenido (L3). Sabes que dark: es un prefijo condicional cuya condición es el tema (L4), y que hay dos estrategiasmedia (automática) y class (toggle manual)— con implicaciones distintas (L5). Sabes que la forma de sistema del dark es el theming por tokens: la utilidad apunta a un token que cambia de valor bajo .dark, sin dark: por elemento (L6). Y sabes que los dos ejes —tamaño y tema— son perpendiculares y componibles: una sola class="" los cubre sin duplicar el componente (L7). La capa responsive + dark, sobre los tokens, utilidades, escalas y componentes de los módulos 2-5, ya no es teoría: el catálogo de Mercado responde al tamaño y al tema, verificado.

Lo que no hiciste todavía —a propósito— es apoyarte en primitivas accesibles. Estilaste componentes con tokens, variantes, responsive y dark, pero los construiste desde cero: el foco, los roles ARIA, el manejo de teclado de un menú o un diálogo los tendrías que resolver a mano. El módulo 7 — Primitivas y librerías toma el modelo shadcn/Radix: componentes sin estilo pero con la accesibilidad resuelta que tú vistes con tus tokens —el foco visible, los roles, el teclado, gratis—, y decide cuándo usar una librería y cuándo construir. El sistema de diseño que estas capas fueron levantando —tokens, utilidades, escalas, variantes, responsive, dark— se apoyará ahí en cimientos accesibles que no tienes que reinventar.

Recursos