Módulo 8: Project Build Mercados Design System

Conecta Tailwind al set final de tokens

Descripción

En el módulo 3 vestiste el product-card con utilidades que apuntaban a tus tokens: bg-surface emitía var(--color-surface), bg-primary emitía var(--color-primary). El truco funcionaba porque el nombre de la utilidad y el nombre de la custom property eran, letra por letra, el mismo string —tw() armaba var(--color-' + suffix + ')' a partir del sufijo de la clase, sin más—. Eso alcanzaba mientras cada rol tuviera un nombre limpio. Pero el módulo 2 (y la lección 2 de este capstone) te dejó con roles como on-primary y con un caso donde el nombre de la utilidad que quieres escribir (text-foreground) no coincide con el nombre del token que existe (color.text--color-text) —una decisión de nombrado real: foreground se lee mejor en el marcado, pero text es más corto en el token—.

Esta lección completa la pieza que le faltaba a tw(): un colorMap, el mismo objeto que en un tailwind.config.js real vive dentro de theme.extend.colors, que traduce el sufijo de cada utilidad de color a la custom property exacta a la que apunta. Con eso, bg-danger puede apuntar a --color-danger y text-on-danger a --color-on-danger, aunque los nombres no coincidan letra por letra con ningún patrón mecánico —porque ahora hay un mapa explícito, como en Tailwind de verdad, en vez de una suposición—.

Conexión con el módulo. Esta lección integra el módulo 3 completo —el modelo utility-first, tw(), la configuración de Tailwind con tokens— y la extiende con la pieza que le faltaba para manejar el set ampliado de la lección 2. Es el puente directo a la lección 4: el CSS que aquí generas para el product-card y su badge es exactamente lo que la auditoría de contraste va a medir a continuación —no puedes auditar el contraste de un background-color que todavía no sabes a qué custom property apunta—.

Una analogía: el mapa de traducción del intérprete de conferencia

Un intérprete que traduce en tiempo real entre dos idiomas no memoriza cada palabra sola —arma, antes de la conferencia, un glosario: los términos técnicos que van a aparecer, con su traducción exacta acordada de antemano—. Sin el glosario, un intérprete que ve la palabra "primary" en el discurso podría traducirla de varias formas razonables; con el glosario, "primary" siempre se traduce igual, sin dudar, porque el mapeo ya está fijado. El colorMap de esta lección es ese glosario: cuando el navegador (el intérprete) ve bg-danger en el marcado, no tiene que adivinar a qué custom property se refiere —lo busca en el mapa, que dice, sin ambigüedad, "danger se traduce como --color-danger"—. Y el glosario también resuelve los casos donde la palabra visible y el término técnico no coinciden: foreground en el marcado, --color-text en el token, exactamente como un intérprete que sabe que "affordable" en el discurso técnico se traduce como el término legal preciso, no como la palabra literal.

Ejemplo trabajado: tw() con colorMap, sobre el product-card completo

Primero, así se ve la config real de Tailwind —esto se muestra, es lo que escribes en tailwind.config.js—:

// tailwind.config.js — cada color del theme apunta a su custom property.
export default {
  content: ['./src/**/*.{html,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        primary:              'var(--color-primary)',
        surface:               'var(--color-surface)',
        muted:                 'var(--color-muted)',
        foreground:             'var(--color-text)',       // el nombre no coincide con el token
        'muted-foreground':     'var(--color-text-muted)',
        danger:                 'var(--color-danger)',
        'on-primary':           'var(--color-on-primary)',
        'on-danger':            'var(--color-on-danger)',
      },
    },
  },
};

Y así se ve el product-card completo de Mercado, con su fila de precio y su badge de oferta —también se muestra—:

// ProductCard.jsx — el storefront de Mercado, con badge de oferta.
function ProductCard({ product }) {
  return (
    <article className="flex flex-col gap-2 p-4 rounded-md bg-surface text-foreground">
      <img src={product.image} alt={product.name} />
      <h3 className="text-lg">{product.name}</h3>
      <div className="flex items-center gap-2">
        <p className="text-lg text-primary">{product.price}</p>
        {product.onSale && (
          <span className="px-2 py-1 rounded-full text-xs bg-danger text-on-danger">
            Sale
          </span>
        )}
      </div>
    </article>
  );
}

Y aquí está tw(), con el colorMap que traduce cada utilidad de color según la config de arriba —esto se ejecuta—:

// card.js — tw() con colorMap: la utilidad se traduce a SU custom property, no a una adivinada.
const spacing  = { '1': '0.25rem', '2': '0.5rem', '3': '0.75rem', '4': '1rem', '6': '1.5rem', '8': '2rem' };
const fontSize = { 'text-xs': ['0.75rem', '1rem'], 'text-sm': ['0.875rem', '1.25rem'], 'text-lg': ['1.125rem', '1.75rem'] };

// el mismo mapa que theme.extend.colors en tailwind.config.js.
const colorMap = {
  primary:             '--color-primary',
  surface:              '--color-surface',
  muted:                '--color-muted',
  foreground:            '--color-text',
  'muted-foreground':    '--color-text-muted',
  danger:                '--color-danger',
  'on-primary':          '--color-on-primary',
  'on-danger':           '--color-on-danger',
};

function util(cls) {
  if (cls === 'flex')         return ['display: flex'];
  if (cls === 'flex-col')     return ['flex-direction: column'];
  if (cls === 'items-center') return ['align-items: center'];
  if (cls === 'rounded-md')   return ['border-radius: 0.375rem'];
  if (cls === 'rounded-full') return ['border-radius: 9999px'];
  if (cls in fontSize) { const [fs, lh] = fontSize[cls]; return ['font-size: ' + fs, 'line-height: ' + lh]; }
  let m;
  if ((m = cls.match(/^p-(\d+)$/)))  return ['padding: ' + spacing[m[1]]];
  if ((m = cls.match(/^px-(\d+)$/))) return ['padding-left: ' + spacing[m[1]], 'padding-right: ' + spacing[m[1]]];
  if ((m = cls.match(/^py-(\d+)$/))) return ['padding-top: ' + spacing[m[1]], 'padding-bottom: ' + spacing[m[1]]];
  if ((m = cls.match(/^gap-(\d+)$/))) return ['gap: ' + spacing[m[1]]];
  if ((m = cls.match(/^bg-([a-z-]+)$/))) {
    const v = colorMap[m[1]]; if (!v) throw new Error('color fuera del theme: ' + m[1]);
    return ['background-color: var(' + v + ')'];
  }
  if ((m = cls.match(/^text-([a-z-]+)$/))) {
    const v = colorMap[m[1]]; if (!v) throw new Error('color fuera del theme: ' + m[1]);
    return ['color: var(' + v + ')'];
  }
  throw new Error('utilidad fuera del subconjunto: ' + cls);
}
function tw(classNames) {
  const decls = [];
  for (const cls of classNames.trim().split(/\s+/)) for (const d of util(cls)) decls.push(d);
  return decls;
}

// las cuatro piezas del product-card completo.
const cardParts = {
  '.product-card':            'flex flex-col gap-2 p-4 rounded-md bg-surface text-foreground',
  '.product-card__price-row': 'flex items-center gap-2',
  '.product-card__price':     'text-lg text-primary',
  '.product-card__badge':     'px-2 py-1 rounded-full text-xs bg-danger text-on-danger',
};
console.log('=== product-card: utilidades -> CSS (con colorMap) ===\n');
for (const [selector, classes] of Object.entries(cardParts)) {
  console.log(selector + '   class="' + classes + '"');
  for (const d of tw(classes)) console.log('  ' + d + ';');
  console.log('');
}

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

=== product-card: utilidades -> CSS (con colorMap) ===

.product-card   class="flex flex-col gap-2 p-4 rounded-md bg-surface text-foreground"
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 1rem;
  border-radius: 0.375rem;
  background-color: var(--color-surface);
  color: var(--color-text);

.product-card__price-row   class="flex items-center gap-2"
  display: flex;
  align-items: center;
  gap: 0.5rem;

.product-card__price   class="text-lg text-primary"
  font-size: 1.125rem;
  line-height: 1.75rem;
  color: var(--color-primary);

.product-card__badge   class="px-2 py-1 rounded-full text-xs bg-danger text-on-danger"
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  padding-top: 0.25rem;
  padding-bottom: 0.25rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  line-height: 1rem;
  background-color: var(--color-danger);
  color: var(--color-on-danger);

Lee la salida fijándote en el detalle que esta lección agrega sobre el módulo 3: text-foreground, en la raíz del .product-card, produce color: var(--color-text)no var(--color-foreground)—. Si tw() siguiera armando el nombre de la custom property mecánicamente a partir del sufijo de la clase (como en el módulo 3), esto habría fallado o habría apuntado a una variable que no existe. Funciona porque colorMap['foreground'] está declarado explícitamente como '--color-text' —la traducción del glosario—. Es exactamente lo que pasa en un tailwind.config.js real: el nombre de la utilidad (foreground, elegido porque se lee bien en el marcado) y el nombre de la custom property (--color-text, heredado del token del módulo 2) no tienen por qué coincidir, siempre que la config los conecte.

Fíjate también en el .product-card__badge: cuatro clases de espaciado (px-2, py-1) se traducen en cuatro declaraciones (padding-left, padding-right, padding-top, padding-bottom) —no dos—, porque px-*/py-* son ejes (horizontal/vertical), y cada eje son dos lados. Y las dos utilidades de color del badge —bg-danger y text-on-danger— apuntan, vía el colorMap, a los dos tokens que fijaste en la lección 2 para ese propósito exacto: el fondo de la etiqueta y el texto que va sobre ese fondo. El badge completo son ocho declaraciones, generadas por seis clases, sin que hayas escrito una sola línea de CSS a mano.

Profundización: el colorMap no es solo conveniencia, es honestidad

Podrías preguntarte por qué no simplificar y renombrar foreground a text en la config, para que vuelva a coincidir mecánicamente como en el módulo 3. La respuesta es que foreground/background son los nombres que la comunidad de sistemas de diseño (shadcn, Radix Themes, Material) usa por convención para "el color de texto que corresponde a esta superficie" —son más legibles en el marcado que text a secas, que es ambiguo con el tamaño de fuente (text-lg)—. El costo de usar el nombre más legible es que ya no coincide letra por letra con el token interno, y ese costo se paga una vez, en la config, con un mapa explícito —no en cada archivo que usa la utilidad—. Es el mismo principio que vas a ver una y otra vez en este módulo: cuando dos capas necesitan nombres distintos por buenas razones (legibilidad arriba, organización abajo), el sistema pone una traducción explícita en el medio, en vez de forzar a que coincidan o de repetir la traducción en cada lugar que la usa.

Errores comunes

Omitir un color del colorMap y descubrirlo tarde. Qué pasa: se usa bg-warning en el JSX sin haber agregado warning al colorMap, y tw() lanza color fuera del theme: warning. Por qué pasa: se agregó la utilidad al marcado sin volver a la config. Cómo detectarlo: el error explícito de util() —a diferencia del módulo 3, donde una utilidad de color inexistente en el patrón mecánico simplemente producía un var() roto sin avisar, aquí el colorMap falla ruidosamente si el color no está—. Cómo corregirlo: cada color que aparece en el marcado debe tener su entrada en colorMap (y en el tailwind.config.js real) antes de usarse. La falla ruidosa es una ventaja: un bg-warning sin configurar rompe la build de Tailwind real de la misma forma, así que el error temprano en Node imita el error temprano en producción.

Duplicar la traducción en vez de centralizarla en el mapa. Qué pasa: en vez de un colorMap único, se escribe un if (cls === 'text-foreground') return ['color: var(--color-text)'] especial, por fuera del patrón general de bg-*/text-*. Por qué pasa: parece más directo resolver el caso especial ahí mismo. Cómo detectarlo: cada color nuevo que no coincide con su token requiere una línea de código nueva en util(), en vez de una línea de datos en colorMap. Cómo corregirlo: todo color —coincida o no su nombre con el token— pasa por el mismo colorMap. Agregar un color es agregar una entrada al objeto, no una rama nueva a la función. Es la misma disciplina que evita que resolveToken tenga un caso especial por semántico.

Confundir colorMap con tokens.semantics. Qué pasa: se intenta buscar color.on-primary (el nombre del token, con el prefijo color. y puntos) directamente en colorMap, y falla porque colorMap usa el nombre de la utilidad (on-primary, sin prefijo) como llave. Por qué pasa: los dos objetos se parecen —ambos mapean roles a algo relacionado con color— pero viven en capas distintas. Cómo detectarlo: un Token desconocido o un undefined al mezclar las llaves de un objeto con las del otro. Cómo corregirlo: tokens.semantics (lección 2) mapea nombre de token → valor por tema (resolveToken lo recorre). colorMap (esta lección) mapea sufijo de utilidad → nombre de custom property (tw() lo recorre). Son dos traducciones distintas, en dos direcciones distintas, que conviven porque la custom property (--color-on-primary) es el punto donde ambas cadenas se encuentran.

Ejercicios

Ejercicio 1 — Agrega el Button al colorMap. El Button de Mercado (que vas a construir con variantes en la lección 5) usa bg-primary text-on-primary para su variante primary. Verifica, sin correr nada, que esas dos clases ya tienen entrada en el colorMap de esta lección, y di a qué custom property apunta cada una.

Ver solución
  • bg-primarycolorMap['primary']--color-primary.
  • text-on-primarycolorMap['on-primary']--color-on-primary.

Las dos ya están en el mapa de esta lección —no hace falta agregar nada—. Es la prueba de que definir el colorMap completo antes de construir el Button (en vez de ir agregando colores sobre la marcha) hace que la lección 5 no tenga que tocar la configuración: el Button solo usa utilidades que ya existen.

Ejercicio 2 — Predice el CSS del badge sin rounded-full. Si el badge usara rounded-md en vez de rounded-full, ¿qué línea cambia en la salida de tw()? Dilo sin correr el código.

Ver solución

Solo cambia una línea: border-radius: 9999px; se reemplaza por border-radius: 0.375rem; —el mismo valor que usa .product-card para sus esquinas—. Las otras siete declaraciones del badge (padding en los cuatro lados, tamaño de fuente, interlineado, fondo, color de texto) no dependen de esa clase y quedan idénticas. La lección: cada utilidad en tw() es independiente —cambiar una no reordena ni afecta a las demás—, que es justo la propiedad que hace que las utilidades sean seguras de combinar y recombinar.

Ejercicio 3 — Encuentra el color que falta. Este colorMap parcial no incluye un color que el product-card usa. ¿Cuál falta, y qué error lanzaría tw() al procesarlo?

const colorMap = {
  primary: '--color-primary',
  surface: '--color-surface',
  foreground: '--color-text',
  danger: '--color-danger',
};
Ver solución

Falta 'on-danger': '--color-on-danger' —el badge usa text-on-danger, y sin esa entrada, util('text-on-danger') haría colorMap['on-danger'], que sería undefined, y la función lanzaría Error: color fuera del theme: on-danger. (También falta muted y on-primary si se procesaran otras piezas del sistema, pero para el badge específico de esta lección, on-danger es el que rompe primero.) La lección: un colorMap incompleto no es un bug silencioso —es un error que tw() reporta con el nombre exacto del color que falta, igual que Tailwind real fallaría en build si usas una clase de color que no está en tu theme.extend.colors.

Resumen y siguiente paso

En esta lección conectaste el set final de tokens de la lección 2 a Tailwind, con la pieza que le faltaba al tw() del módulo 3: un colorMap explícito que traduce cada utilidad de color a su custom property, incluso cuando los nombres no coinciden (foreground--color-text) o son roles compuestos (on-primary, on-danger). Corriste tw() sobre las cuatro piezas del product-card completo —la tarjeta, la fila de precio, el precio, y el badge de oferta— y viste dieciocho declaraciones de CSS generadas desde seis clases, sin escribir CSS a mano. Con el intérprete y su glosario entendiste por qué esa traducción explícita, hecha una vez en la config, es mejor que forzar a que los nombres coincidan o que repetir la traducción en cada archivo.

Antes de avanzar deberías poder: explicar qué problema resuelve el colorMap que el tw() del módulo 3 no tenía; leer un tailwind.config.js con theme.extend.colors y decir a qué custom property apunta cada clave; y ejecutar tw() sobre cualquier combinación de las utilidades de este módulo sin mirar la solución.

La lección 4 toma este CSS generado y le hace la pregunta que ninguna lección anterior respondió con un número: ¿se lee? Vas a auditar el contraste de los cuatro pares de color de Mercado —el texto sobre la tarjeta, el texto secundario, el texto del botón sobre su fondo, el texto del badge sobre el suyo— en los dos temas, con el algoritmo real de WCAG. Y vas a descubrir, midiendo, por qué color.on-primary tenía que ser un token con override de tema y no simplemente text-white.

Recursos

  • Tailwind CSS, "Theme" — tailwindcss.com/docs/theme. Cómo theme.extend.colors conecta las utilidades a tus custom properties; la fuente real del colorMap de esta lección. En inglés.
  • Tailwind CSS, "Styling with utility classes" — tailwindcss.com/docs/styling-with-utility-classes. El flujo completo de estilar un componente con utilidades, aplicado aquí al product-card con badge. En inglés.
  • shadcn/ui, "Theming" — ui.shadcn.com/docs/theming. Los pares foreground/background y primary/primary-foreground que inspiran el nombrado de este módulo. En inglés.
  • MDN, "padding" — developer.mozilla.org/en-US/docs/Web/CSS/padding. La propiedad shorthand detrás de p-*, px-* y py-*, y por qué px-*/py-* generan dos declaraciones cada una. En inglés.