Módulo 3: Utility First With Tailwind

Proyecto: estilar el product-card de Mercado con utilidades

Descripción

Las siete lecciones anteriores te enseñaron a leer el modelo utility-first: qué es una utilidad, por qué se prefiere a las clases semánticas, cómo funciona por debajo, por qué gana por orden de fuente, cómo se engancha a tus tokens y cuándo extraer con @apply. Este proyecto te pone a aplicarlo. Vas a tomar el product-card de Mercado —el mismo componente de react-fundamentals, que en el módulo 2 dejaste con sus tokens definidos pero sin estilar— y vestirlo entero con utilidades conectadas a esos tokens. Y vas a verificar, con el modelo en Node, dos cosas: que cada utilidad mapea al CSS correcto (tw()), y que cuando dos utilidades de la misma propiedad chocan, el orden de fuente resuelve el conflicto —no el orden del class=""—.

El entregable tiene dos partes, y las dos importan. La parte 1 es el marcado estilado: el product-card con sus utilidades, y su botón "Add to cart", cada uno consumiendo los tokens que fabricaste en el módulo 2 (bg-surface, bg-primary). La parte 2 es la verificación ejecutada: correr tw() sobre las clases de la tarjeta para ver el CSS que recibe, y montar un conflicto de padding (p-8 p-4) para comprobar, medido, que el orden de fuente decide. Juntas prueban que dominas el módulo: no solo qué es una utilidad, sino cómo vestir un componente real con utilidades que consumen tokens, y cómo la cascada resuelve los conflictos entre ellas.

Conexión con el módulo. Este proyecto es la síntesis de las siete lecciones. Estila con utilidades (L2) que consumen tus tokens vía la config (L6), reconstruye el CSS del componente con tw() (L4), y resuelve un conflicto por orden de fuente con la especificidad [0,1,0] de todas las utilidades (L5). Es también el puente al módulo 4: verás que los valores de p-4, gap-2 y text-lg salen de una escala (4px de base, ratios tipográficos) —y esa escala, que aquí solo consumes, es lo que el módulo 4 construye y justifica—. Al terminar, la capa de utilidades de Mercado deja de ser teoría y viste un componente real.

Qué vas a construir

El entregable es un archivo de Node —card.js— que, al correrse, imprime dos cosas:

  1. El CSS que reciben las piezas del product-card (parte 1): para la tarjeta y su botón, el bloque de declaraciones que sus utilidades producen, con los colores apuntando a tus tokens.
  2. La resolución de un conflicto de padding (parte 2): dado un elemento con p-8 y p-4, la especificidad de cada una ([0,1,0], empatadas) y cuál gana por orden de fuente.

Como en todo el módulo, el marcado (JSX/HTML con clases de Tailwind) y la config (tailwind.config.js) se muestran —es lo que el alumno escribe—, y la lógica (utilidad→CSS y la resolución del conflicto) se ejecuta en Node. El navegador no corre en un agente, así que mostramos lo que se escribe y ejecutamos lo que lo valida.

Primero, el marcado que vas a estilar. Así queda el product-card de Mercado vestido con utilidades (esto se muestra, es tu entregable de marcado):

// ProductCard.jsx — el storefront de Mercado, estilado con utilidades que consumen tokens.
function ProductCard({ product }) {
  return (
    <article className="flex gap-2 p-4 rounded-md bg-surface">
      <img src={product.image} alt={product.name} />
      <h3 className="text-lg">{product.name}</h3>
      <p className="text-primary">{product.price}</p>
      <button className="flex p-4 rounded-md bg-primary text-lg">
        Add to cart
      </button>
    </article>
  );
}

Y la config que hace que bg-surface y bg-primary apunten a tus tokens del módulo 2 (también se muestra):

// tailwind.config.js — las utilidades de color consumen los tokens del modulo 2.
export default {
  content: ['./src/**/*.{html,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        primary: 'var(--color-primary)',
        surface: 'var(--color-surface)',
        text:    'var(--color-text)',
      },
    },
  },
};

Una analogía: montar la casa modelo con la caja de ladrillos ya lista

En el módulo 2 preparaste la paleta maestra —los botes de color etiquetados por rol, con su versión clara y oscura—. En este módulo aprendiste que tienes, además, una caja de ladrillos estándar (las utilidades) que ya vienen pintados con esos botes. Este proyecto es, por fin, montar la casa modelo: tomas la caja de ladrillos y la paleta lista, y armas el product-card —la pieza que el catálogo de Mercado repetirá cientos de veces—.

Montar la casa modelo tiene dos momentos. Primero la armas: encajas los ladrillos (flex, gap-2, p-4, rounded-md, bg-surface) hasta que la tarjeta toma forma, sin fabricar ninguna pieza nueva ni inventar nombres. Después la inspeccionas: desarmas mentalmente cada pieza para confirmar que quedó bien —que p-4 de verdad puso el relleno que esperabas, que bg-surface apunta a tu bote y no a un azul cualquiera—. Y haces una prueba de estrés: pones dos ladrillos de padding en la misma ranura a propósito, para ver cuál queda —y confirmar que el que queda es el que la cascada dice, no el que pusiste de último con la mano—. Un constructor que monta la casa modelo y la inspecciona a fondo puede replicarla cien veces con confianza; uno que solo la arma y no revisa descubre el problema en la casa noventa. Tú inspeccionas ejecutando, antes de que el product-card se repita en todo el storefront.

Especificación del proyecto

Tu card.js debe cumplir esto:

Parte 1 — el CSS de las piezas.

  • Define util(cls) y tw(classNames) para el subconjunto del módulo (flex, gap-N, p-N, rounded-md, bg-<rol>, text-<rol>, text-lg/text-sm).
  • Las utilidades de color (bg-*, text-*) deben emitir una declaración que apunte al token (var(--color-*)), no un hex.
  • Corre tw() sobre las clases de la tarjeta (flex gap-2 p-4 rounded-md bg-surface) y de su botón (flex p-4 rounded-md bg-primary text-lg), e imprime el bloque de CSS de cada una.

Parte 2 — el conflicto por orden de fuente.

  • Reusa specificity de web-fundamentals M3.
  • Modela un elemento con class="p-8 p-4" y una hoja generada donde .p-4 va antes que .p-8 (orden de escala).
  • Imprime la especificidad de cada utilidad (deben ser [0,1,0], empatadas) y cuál gana por orden de fuente.

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 card.js.
  • Salida literal y reproducible.
  • Las utilidades de color apuntan al token; nunca hex crudo ni bg-[#...].

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 — (1) tw() sobre el product-card de Mercado; (2) orden de fuente resuelve un conflicto.
const spacing  = { '2': '0.5rem', '4': '1rem', '8': '2rem' };
const fontSize = { 'text-sm': ['0.875rem', '1.25rem'], 'text-lg': ['1.125rem', '1.75rem'] };

function util(cls) {
  if (cls === 'flex')       return ['display: flex'];
  if (cls === 'rounded-md') return ['border-radius: 0.375rem'];
  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(/^gap-(\d+)$/)))    return ['gap: ' + spacing[m[1]]];
  if ((m = cls.match(/^bg-([a-z]+)$/)))  return ['background-color: var(--color-' + m[1] + ')'];
  if ((m = cls.match(/^text-([a-z]+)$/))) return ['color: var(--color-' + m[1] + ')'];
  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;
}

// (1) el product-card de Mercado: raiz + su boton "Add to cart".
const card = {
  '.product-card':      'flex gap-2 p-4 rounded-md bg-surface',
  '.product-card__cta': 'flex p-4 rounded-md bg-primary text-lg',
};
console.log('=== 1) product-card: utilidades -> CSS ===\n');
for (const [selector, classes] of Object.entries(card)) {
  console.log(selector + '   class="' + classes + '"');
  for (const d of tw(classes)) console.log('  ' + d + ';');
  console.log('');
}

// (2) conflicto: dos utilidades de padding sobre el CTA; el orden de fuente decide.
function specificity(selector) {
  let a = 0, b = 0, c = 0;
  for (const piece of selector.trim().split(/\s+|>|\+|~/).filter(Boolean)) {
    a += (piece.match(/#[\w-]+/g) || []).length;
    b += (piece.match(/\.[\w-]+/g) || []).length;
    const type = piece.match(/^[a-zA-Z][\w-]*/);
    if (type && type[0] !== '*') c += 1;
  }
  return [a, b, c];
}
function cmpVec(x, y) { for (let i = 0; i < 3; i++) { if (x[i] > y[i]) return 1; if (x[i] < y[i]) return -1; } return 0; }

const generatedCss = [
  { selector: '.p-4', prop: 'padding', value: '1rem', order: 1 },
  { selector: '.p-8', prop: 'padding', value: '2rem', order: 2 },
];
const ctaOverride = ['p-8', 'p-4']; // el dev escribio p-4 de ultimo pensando que "gana el ultimo"
function resolveProp(prop, classes, css) {
  const applicable = css.filter((r) => r.prop === prop && classes.includes(r.selector.slice(1)));
  let best = applicable[0];
  for (const r of applicable.slice(1)) {
    const c = cmpVec(specificity(r.selector), specificity(best.selector));
    if (c > 0) best = r; else if (c === 0 && r.order > best.order) best = r;
  }
  const tied = applicable.some((r) => r !== best && cmpVec(specificity(r.selector), specificity(best.selector)) === 0);
  return { best, tied };
}

console.log('=== 2) conflicto de padding: class="' + ctaOverride.join(' ') + '" ===');
for (const r of generatedCss) console.log('  ' + r.selector + '  especificidad ' + JSON.stringify(specificity(r.selector)) + '  (orden en el CSS: ' + r.order + ')');
const { best, tied } = resolveProp('padding', ctaOverride, generatedCss);
console.log('  gana ' + best.selector + ' -> padding: ' + best.value +
            (tied ? '  (empate en especificidad -> orden de fuente)' : ''));

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

=== 1) product-card: utilidades -> CSS ===

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

.product-card__cta   class="flex p-4 rounded-md bg-primary text-lg"
  display: flex;
  padding: 1rem;
  border-radius: 0.375rem;
  background-color: var(--color-primary);
  font-size: 1.125rem;
  line-height: 1.75rem;

=== 2) conflicto de padding: class="p-8 p-4" ===
  .p-4  especificidad [0,1,0]  (orden en el CSS: 1)
  .p-8  especificidad [0,1,0]  (orden en el CSS: 2)
  gana .p-8 -> padding: 2rem  (empate en especificidad -> orden de fuente)

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

La parte 1 es tu componente estilado, reconstruido desde sus utilidades. La .product-card recibe cinco declaraciones —display: flex, gap: 0.5rem, padding: 1rem, border-radius: 0.375rem y un fondo que apunta a --color-surface—: exactamente lo que una clase .product-card { ... } habría contenido, pero compuesto con ladrillos y sin inventar la clase. Su botón (.product-card__cta) recibe seis declaraciones, con bg-primary apuntando a --color-primary y text-lg aportando el par font-size/line-height. Fíjate en los dos fondos: la tarjeta usa --color-surface (blanco en claro, casi negro en oscuro) y el botón --color-primary (el azul de marca) —los dos heredarán el theming del módulo 2 sin que toques el marcado, porque las utilidades apuntan a tus tokens, no a hex—.

La parte 2 es tu prueba de la cascada. El botón, en un descuido, recibió class="p-8 p-4" —dos paddings, con p-4 escrito de último—. La salida mide lo que importa: ambas utilidades son [0,1,0], empatadas en especificidad (son clases de una sola clase), así que ninguna gana por ser "más específica". El desempate lo hace el orden de fuente, y gana .p-8 —que aparece después en el CSS generado (posición 2, por la escala)—, dando padding: 2rem. El dev escribió p-4 de último pensando que ganaría; no ganó, porque el navegador ignora el orden del class y mira el del catálogo. La lección práctica del proyecto: si quieres el padding chico, no reordenes el class —quita p-8—; tener dos utilidades de la misma propiedad es siempre el error.

Junta las dos partes y tienes la capa 2 de Mercado sobre la capa 1: el product-card vestido con utilidades que consumen tus tokens (parte 1) y la prueba de que sabes cómo la cascada resuelve los conflictos entre ellas (parte 2). Es la teoría del módulo convertida en un componente real, verificado.

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 bg-muted (L6). Suma muted: 'var(--color-muted)' a la config (y su token al modelo) y estila la SearchBar de Mercado con bg-muted. Verás una utilidad nueva que hereda su theming del token, sin escribir CSS.
  • Extrae el botón con @apply y decide si vale la pena (L7). Escribe .cta { @apply flex p-4 rounded-md bg-primary text-lg; } con tu applyToRule, compáralo con dejar las utilidades en el marcado, y argumenta —según cuántas veces aparece el botón— cuál conviene.
  • Rompe el conflicto con !important y observa el problema (L5). Marca .p-4 como ganadora con !important en el modelo y nota que ahora cualquier cambio futuro a ese padding necesitará otro !important. Confirma por qué reordenar (o quitar la clase) es mejor.
  • Mide el CSS de dos componentes (L3). Agrega un wishlist-card idéntico a product-card y cuenta cuántas utilidades distintas generan entre los dos vs cuántas declaraciones sumaría el enfoque semántico.

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

Errores comunes

Meter el color como hex en la utilidad en vez de apuntar al token. Qué pasa: se estila el botón con bg-[#2563eb] en vez de bg-primary, y el tw() emite background-color: #2563eb. Por qué pasa: es más rápido que configurar el token. Cómo detectarlo: tu salida tiene hex crudos en vez de var(--color-*), y el botón no cambia en tema oscuro. Cómo corregirlo: las utilidades de color deben apuntar al token (var(--color-primary)), como en la solución. Si emites el hex, congelas el valor y pierdes el theming del módulo 2 —el botón sería el mismo azul en claro y oscuro—. El proyecto entero se apoya en que la capa 2 (utilidades) consume la capa 1 (tokens); romper eso rompe el sistema.

Reportar que gana .p-4 "porque va de último en el class". Qué pasa: al predecir la parte 2, se dice que gana p-4 (padding 1rem) porque está escrita de última. Por qué pasa: el instinto de "gana el último" no distingue el orden del class del orden del CSS. Cómo detectarlo: tu predicción no coincide con la salida (que da .p-8). Cómo corregirlo: el navegador decide por el orden en el CSS generado, donde .p-8 va después de .p-4; el orden del class no cuenta. Gana .p-8 (padding 2rem). Este es justo el error que la parte 2 existe para desarmar —revísalo contra la lección 5—.

Inventar la salida en vez de ejecutarla. Qué pasa: se escribe el "Qué esperar" a mano, calculando los valores 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 0.5rem donde iba 1rem, un token mal escrito—. Cómo corregirlo: corre node card.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 valor de utilidad mal calculado a mano es exactamente el tipo de error que el sistema existe para eliminar; no lo reintroduzcas en la verificación.

Rúbrica de autoevaluación

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

  • Componente estilado con utilidades. El product-card y su botón usan utilidades en el marcado, sin clases semánticas inventadas.
  • Utilidades de color que apuntan al token. bg-surface y bg-primary emiten var(--color-*), no hex; el theming del módulo 2 se hereda.
  • tw() reconstruye el CSS. Corre sobre las clases de la tarjeta y el botón y produce el bloque de declaraciones correcto.
  • Especificidad medida. Las dos utilidades de padding dan [0,1,0] —empatadas—, no una mayor que otra.
  • Orden de fuente resuelve el conflicto. Gana .p-8 por aparecer después en el CSS, no .p-4 por ir de último en el class.
  • Salida literal. Corriste node card.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 3 haciendo, no solo leyendo. Vestiste el product-card de Mercado con utilidades que consumen tus tokens (bg-surface, bg-primary apuntando a var(--color-*)), reconstruiste su CSS con tw(), y comprobaste con la cascada que un conflicto de padding lo resuelve el orden de fuente —no el orden del class=""—, porque todas las utilidades comparten la especificidad [0,1,0]. Con la casa modelo que se arma y luego se inspecciona viste por qué se hace así: montas con ladrillos estándar sin inventar piezas, y verificas ejecutando antes de repetir el componente en todo el storefront.

Da un paso atrás y mira lo que aprendiste en las ocho lecciones. Sabes qué es utility-first —estilar componiendo utilidades atómicas en el marcado— (L2). Sabes por qué se prefiere a las clases semánticas —velocidad, no nombrar todo, CSS que no crece, consistencia por la escala— (L3). Sabes cómo funciona por debajo —cada utilidad es una clase de una declaración, generada por adelantado; tw() reconstruye el estilo— (L4). Sabes que las utilidades ganan por orden de fuente, no por el class, porque todas son [0,1,0] (L5). Sabes configurar Tailwind con tus tokens para que las utilidades hereden tu theming (L6). Y sabes cuándo usar @apply —y cuándo no, para no reconstruir el CSS semántico— (L7). La capa de utilidades, sobre los cimientos de tokens del módulo 2, ya no es teoría: viste un componente real.

Lo que no hiciste todavía —a propósito— es construir la escala de la que las utilidades leen sus valores. p-4 dio 1rem, gap-2 dio 0.5rem, text-lg dio su par tipográfico —pero, ¿por qué esos valores y no otros? ¿De dónde sale la base de 4px, los ratios de la escala tipográfica, los pasos 50–900 de una paleta?— Eso empieza ahora. El módulo 4 — Escalas, color y contraste construye la escala que da coherencia a todo lo que aquí solo consumiste: el espaciado, la tipografía, las paletas de color, y el contraste WCAG que un sistema accesible debe garantizar (con el contrastRatio ejecutado). Las utilidades que vestiste aquí empiezan a leer de una escala que entenderás de raíz.

Recursos

  • Tailwind CSS, "Styling with utility classes" — tailwindcss.com/docs/styling-with-utility-classes. El flujo de estilar un componente con utilidades —lo que hiciste con el product-card—. En inglés.
  • Tailwind CSS, "Theme" — tailwindcss.com/docs/theme. Cómo la config conecta tus tokens con las utilidades de color del proyecto. En inglés.
  • shadcn/ui, "Theming" — ui.shadcn.com/docs/theming. Un componente real (Card, Button) estilado con utilidades sobre tokens semánticos —el espejo de tu product-card—. En inglés.
  • web-fundamentals-html-css — módulo 3, "La cascada y la especificidad", lección 5. La fuente del resolve que usaste para la parte 2; repásalo si el orden de fuente aún te cuesta.