Módulo 8: Project Build Mercados Design System

Proyecto final: el sistema de diseño de Mercado, de punta a punta

Descripción

Siete lecciones de este módulo, y siete módulos antes de estas, te trajeron hasta aquí. Definiste los tokens finales de Mercado (L2), los conectaste a Tailwind con un colorMap (L3), auditaste su contraste y encontraste —y arreglaste— un bug real de accesibilidad (L4), construiste el Button y el ProductCard con variantes (L5), los hiciste responsive y dark sin un solo dark: en el marcado (L6), y montaste un Dialog sobre una primitiva accesible, auditándolo contra uno hecho a mano (L7). Cada pieza, verificada por separado.

Este proyecto es el ensayo general completo: vas a entregar el sistema de diseño de Mercado entero —tokens, config de Tailwind, componentes con variantes, catálogo responsive y dark, un diálogo accesible— y vas a correr una sola corrida de Node que encadena las seis capas, en orden, sobre ese sistema. No hay lógica nueva que escribir: los seis modelos ya existen, ya los ejecutaste. Lo que este proyecto pide es ensamblarlos, en el orden correcto, y comprobar que el ensamblaje funciona de punta a punta —el mismo criterio de "no lo declares, ejecútalo" que sostuvo cada lección de esta guía, aplicado ahora al sistema completo.

Conexión con el módulo. Este proyecto es la síntesis de las siete lecciones del capstone, y por extensión, de toda la guía. Reutiliza el set de tokens con on-primary/on-danger (L2), el colorMap de tw() (L3), el contrastRatio que valida el fix del Button (L4), el variants() aplicado a dos componentes (L5), el resolveClasses sin duplicación (L6), y el a11yAudit que distingue una primitiva de un componente casero (L7). Es también el cierre de ui-systems-and-design-implementation-guide completa: al terminar este proyecto, el sistema de diseño de Mercado deja de ser una colección de ejemplos por módulo y es un sistema que se sostiene solo, verificado en cada una de sus capas.

Qué vas a construir

El entregable tiene dos partes, y las dos importan.

La parte 1 es el sistema en sí: los tokens (tokens.js o su CSS equivalente), la config de Tailwind (tailwind.config.js), el Button y el ProductCard como componentes con variantes (Button.jsx, ProductCard.jsx), el catálogo (Catalog.jsx), y un QuickViewDialog.jsx montado sobre una primitiva accesible. Es el marcado y la configuración reales —lo que un desarrollador de Mercado escribiría—.

La parte 2 es la corrida end-to-end: un único archivo de Node (mercado.js) que importa —o, para esta guía, reimplementa inline, como en cada lección— los seis modelos (resolveToken, tw, contrastRatio+passesWCAG, variants, resolveClasses, a11yAudit) y los ejecuta en el orden de dependencia real: primero los tokens (sin ellos no hay nada más), después las utilidades que los consumen, después el contraste que verifica lo que las utilidades pintan, después las variantes que empaquetan esas utilidades, después responsive/dark que resuelve por contexto, y por último las primitivas que resuelven el comportamiento que ninguna capa anterior toca.

Especificación del proyecto

Tu mercado.js debe cumplir esto:

Paso 1 — tokens.

  • Define el set completo de 8 semánticos (primary, surface, muted, text, text-muted, danger, on-primary, on-danger) y 7 tokens de componente, con resolveToken.
  • Imprime la tabla de los 7 tokens de componente en los dos temas.

Paso 2 — Tailwind.

  • Define tw() con un colorMap que traduzca cada utilidad de color a su custom property.
  • Corre tw() sobre las cuatro piezas del product-card (tarjeta, fila de precio, precio, badge) e imprime su CSS.

Paso 3 — contraste.

  • Define contrastRatio con el algoritmo real (verificable contra #000000/#ffffff = 21:1) y passesWCAG con los umbrales AA/AAA.
  • Audita los cuatro pares de Mercado (card.text, card.text-muted, button.text, badge.text, cada uno sobre su fondo correspondiente) en los dos temas — ocho mediciones, cero fallas.

Paso 4 — variantes.

  • Define variants() con base + variants + defaultVariants + compoundVariants, y la config del Button (con el color ya corregido: text-on-primary, no text-white).
  • Resuelve button({ variant: 'primary', size: 'lg' }) e imprime la cadena completa, con el compound shadow-lg incluido.

Paso 5 — responsive + dark.

  • Define resolveClasses con el modelo de prefijos del módulo 6.
  • Recorre 3 anchos (360/700/1200px) × 2 temas, e imprime la columna de grid activa junto con el color resuelto del Button en cada combinación — layout y color perpendiculares, sin dark: en el marcado.

Paso 6 — primitivas.

  • Define a11yAudit con el checklist de siete requisitos de un Dialog.
  • Audita un QuickViewDialog casero (debe dar 2/7) y uno sobre una primitiva accesible (debe dar 7/7), con el mismo checklist.

Restricciones (las convenciones de toda la guía):

  • Todo identificador, clase, token y valor en inglés; solo comentarios y textos en español.
  • Sin dependencias: puro JavaScript, corre con node mercado.js.
  • Salida literal y reproducible, en el orden de los seis pasos.
  • Cero FAIL en la auditoría de contraste; cero dark: en el marcado del catálogo o del Button.

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 ensamblarlo tú, no en leer la respuesta.

Ver la solución de referencia completa (tokens, config, componentes, y el script end-to-end)

Los tokens, como CSS real (lo que se pega en la hoja de estilos del storefront):

/* tokens.css — el set final de Mercado, light + dark. */
:root {
  --color-primary: #2563eb;
  --color-surface: #ffffff;
  --color-muted: #f3f4f6;
  --color-text: #111827;
  --color-text-muted: #6b7280;
  --color-danger: #ef4444;
  --color-on-primary: #ffffff;
  --color-on-danger: #111827;
}
.dark {
  --color-primary: #60a5fa;
  --color-surface: #111827;
  --color-muted: #1f2937;
  --color-text: #f9fafb;
  --color-text-muted: #9ca3af;
  --color-danger: #f87171;
  --color-on-primary: #111827;
  --color-on-danger: #111827;
}

La config de Tailwind:

// tailwind.config.js
export default {
  darkMode: 'class',
  content: ['./src/**/*.{html,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        primary:            'var(--color-primary)',
        surface:             'var(--color-surface)',
        muted:                'var(--color-muted)',
        foreground:            'var(--color-text)',
        'muted-foreground':    'var(--color-text-muted)',
        danger:                'var(--color-danger)',
        'on-primary':          'var(--color-on-primary)',
        'on-danger':           'var(--color-on-danger)',
      },
    },
  },
};

El Button, con cva:

// Button.jsx
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors',
  {
    variants: {
      variant: {
        primary:   'bg-primary text-on-primary', // FIX de la L4: token, no "text-white" fijo
        secondary: 'bg-surface text-primary border border-primary',
        ghost:     'bg-transparent text-primary',
      },
      size: { sm: 'text-sm px-3 py-1', md: 'text-base px-4 py-2', lg: 'text-lg px-6 py-3' },
    },
    defaultVariants: { variant: 'primary', size: 'md' },
    compoundVariants: [{ variant: 'primary', size: 'lg', class: 'shadow-lg' }],
  }
);

function Button({ variant, size, className, ...props }) {
  return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}

El ProductCard, con variantes de layout y badge condicional:

// ProductCard.jsx
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Button } from './Button';

const cardVariants = cva('flex flex-col rounded-md bg-surface text-foreground', {
  variants: {
    layout: { default: 'p-4 gap-2', compact: 'p-2 gap-1' },
  },
  defaultVariants: { layout: 'default' },
});

function ProductCard({ product, layout, className }) {
  return (
    <article className={cn(cardVariants({ layout }), className)}>
      <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>
      <Button variant="primary" size="lg">Add to cart</Button>
    </article>
  );
}

El catálogo, responsive, sin color propio:

// Catalog.jsx
import { ProductCard } from './ProductCard';

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>
  );
}
// ni el catalogo, ni la card, ni el boton, tienen un solo "dark:" en su className.

El Dialog de "vista rápida", sobre una primitiva accesible:

// QuickViewDialog.jsx
import * as Dialog from '@radix-ui/react-dialog';
import { Button } from './Button';

function QuickViewDialog({ product, open, onOpenChange }) {
  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50" />
        <Dialog.Content className="bg-surface text-foreground rounded-md p-4">
          <Dialog.Title className="text-lg">{product.name}</Dialog.Title>
          <p className="text-primary">{product.price}</p>
          <Button variant="primary" size="md">Add to cart</Button>
          <Dialog.Close asChild>
            <Button variant="ghost" size="sm">Cerrar</Button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

La corrida end-to-end, mercado.js:

// mercado.js — el sistema de diseno de Mercado, de punta a punta.
// Encadena las 6 capas del capstone sobre el mismo storefront: tokens -> tailwind ->
// contraste -> variantes -> responsive/dark -> primitivas accesibles.

// ---------- CAPA 1 (M2): tokens ----------
const tokens = {
  primitives: {
    'blue.600': '#2563eb', 'blue.400': '#60a5fa',
    'gray.50':  '#f9fafb', 'gray.100': '#f3f4f6', 'gray.400': '#9ca3af',
    'gray.500': '#6b7280', 'gray.800': '#1f2937', 'gray.900': '#111827',
    'white':    '#ffffff',
    'red.500':  '#ef4444', 'red.400': '#f87171',
  },
  semantics: {
    'color.primary':    { light: 'blue.600', dark: 'blue.400' },
    'color.surface':    { light: 'white',    dark: 'gray.900' },
    'color.muted':      { light: 'gray.100', dark: 'gray.800' },
    'color.text':       { light: 'gray.900', dark: 'gray.50'  },
    'color.text-muted': { light: 'gray.500', dark: 'gray.400' },
    'color.danger':     { light: 'red.500',  dark: 'red.400'  },
    'color.on-primary': { light: 'white',    dark: 'gray.900' },
    'color.on-danger':  { light: 'gray.900', dark: 'gray.900' },
  },
  component: {
    'card.bg': 'color.surface', 'card.text': 'color.text', 'card.text-muted': 'color.text-muted',
    'button.bg': 'color.primary', 'button.text': 'color.on-primary',
    'badge.bg': 'color.danger', 'badge.text': 'color.on-danger',
  },
};
function resolveToken(name, theme) {
  if (name in tokens.component) return resolveToken(tokens.component[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);
}

// ---------- CAPA 2 (M3): tw() con colorMap ----------
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'] };
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;
}

// ---------- CAPA 3 (M4): contraste WCAG 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;

// ---------- CAPA 4 (M5): variants() ----------
function variants(config, props = {}) {
  const classes = config.base ? [config.base] : [];
  const selected = {};
  for (const axis of Object.keys(config.variants ?? {})) {
    const value = props[axis] !== undefined ? props[axis] : config.defaultVariants?.[axis];
    selected[axis] = value;
    const cls = config.variants[axis]?.[value];
    if (cls) classes.push(cls);
  }
  for (const compound of config.compoundVariants ?? []) {
    const { class: cls, ...conditions } = compound;
    if (Object.entries(conditions).every(([axis, val]) => selected[axis] === val) && cls) classes.push(cls);
  }
  return classes.join(' ');
}
const buttonConfig = {
  base: 'inline-flex items-center justify-center rounded-md font-medium transition-colors',
  variants: {
    variant: {
      primary:   'bg-primary text-on-primary',
      secondary: 'bg-surface text-primary border border-primary',
      ghost:     'bg-transparent text-primary',
    },
    size: { sm: 'text-sm px-3 py-1', md: 'text-base px-4 py-2', lg: 'text-lg px-6 py-3' },
  },
  defaultVariants: { variant: 'primary', size: 'md' },
  compoundVariants: [{ variant: 'primary', size: 'lg', class: 'shadow-lg' }],
};
const button = (props) => variants(buttonConfig, props);

// ---------- CAPA 5 (M6): resolveClasses() ----------
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);
}
const catalogGrid = 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-4 p-4';

// ---------- CAPA 6 (M7): a11yAudit() ----------
const DIALOG_CHECKLIST = [
  { id: 'role',         label: 'role="dialog" + aria-modal="true"' },
  { id: 'labelledby',   label: 'aria-labelledby apunta al titulo visible' },
  { id: 'initialFocus', label: 'el foco entra al primer elemento enfocable al abrir' },
  { id: 'focusTrap',    label: 'Tab/Shift+Tab quedan atrapados dentro del dialog' },
  { id: 'escapeCloses', label: 'Escape cierra el dialog' },
  { id: 'returnFocus',  label: 'el foco vuelve al trigger que lo abrio, al cerrar' },
  { id: 'clickOutside', label: 'un click fuera del dialog lo cierra' },
];
function a11yAudit(name, implemented) {
  const results = DIALOG_CHECKLIST.map((check) => ({ ...check, pass: Boolean(implemented[check.id]) }));
  const passed = results.filter((r) => r.pass).length;
  return { name, results, passed, total: DIALOG_CHECKLIST.length };
}
function printAudit(audit) {
  console.log(`-- ${audit.name}: ${audit.passed}/${audit.total} --`);
  for (const r of audit.results) console.log('  [' + (r.pass ? 'x' : ' ') + '] ' + r.label);
}
const caseroImpl = { role: true, labelledby: false, initialFocus: false, focusTrap: false, escapeCloses: false, returnFocus: false, clickOutside: true };
const radixImpl  = { role: true, labelledby: true,  initialFocus: true,  focusTrap: true,  escapeCloses: true,  returnFocus: true,  clickOutside: true };

// =====================================================================
// LA CORRIDA END-TO-END: las 6 capas, en orden, sobre Mercado.
// =====================================================================
console.log('=== PASO 1) tokens: resolveToken en ambos temas (7 tokens de componente) ===\n');
console.log('token'.padEnd(18) + 'light'.padEnd(12) + 'dark');
console.log('-'.repeat(40));
for (const name of Object.keys(tokens.component)) {
  console.log(name.padEnd(18) + resolveToken(name, 'light').padEnd(12) + resolveToken(name, 'dark'));
}

console.log('\n=== PASO 2) tailwind: tw() del product-card (utilidad -> CSS real) ===\n');
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',
};
for (const [selector, classes] of Object.entries(cardParts)) {
  console.log(selector + '   class="' + classes + '"');
  for (const d of tw(classes)) console.log('  ' + d + ';');
  console.log('');
}

console.log('=== PASO 3) contraste: los 4 pares de Mercado, ambos temas, con el sanity check ===\n');
console.log('  sanity: #000000/#ffffff = ' + r2(contrastRatio('#000000', '#ffffff')) + ':1');
const pairs = [
  ['card.text / card.bg', 'card.text', 'card.bg'],
  ['card.text-muted / card.bg', 'card.text-muted', 'card.bg'],
  ['button.text / button.bg', 'button.text', 'button.bg'],
  ['badge.text / badge.bg', 'badge.text', 'badge.bg'],
];
for (const theme of ['light', 'dark']) {
  console.log('  -- tema ' + theme + ' --');
  for (const [name, fgTok, bgTok] of pairs) {
    const fg = resolveToken(fgTok, theme), bg = resolveToken(bgTok, theme);
    const ratio = contrastRatio(fg, bg);
    console.log('  ' + name.padEnd(28) + (r2(ratio) + ':1').padEnd(10) + passesWCAG(ratio));
  }
}

console.log('\n=== PASO 4) variantes: Button {variant:"primary", size:"lg"} ===\n');
console.log('  ' + button({ variant: 'primary', size: 'lg' }));

console.log('\n=== PASO 5) responsive + dark: resolveClasses a 3 viewports x 2 temas ===\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)}`);
  }
}

console.log('\n=== PASO 6) primitivas: a11yAudit del Dialog, casero vs Radix ===\n');
printAudit(a11yAudit('QuickViewDialog casero', caseroImpl));
console.log('');
printAudit(a11yAudit('QuickViewDialog (Radix)', radixImpl));

console.log('\n=== RESUMEN: el sistema de Mercado, de punta a punta ===');
console.log('  tokens: 7/7 componentes resuelven en 2 temas');
console.log('  tailwind: 4/4 piezas del product-card tienen su CSS');
console.log('  contraste: 8/8 pares medidos (4 pares x 2 temas), 0 FAIL');
console.log('  variantes: Button primary+lg -> ' + button({ variant: 'primary', size: 'lg' }).split(' ').length + ' clases, con shadow-lg');
console.log('  responsive+dark: 6/6 combinaciones (3 anchos x 2 temas) resueltas, 0 dark: en el marcado');
console.log('  primitivas: Dialog casero 2/7, Dialog con Radix 7/7');

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

=== PASO 1) tokens: resolveToken en ambos temas (7 tokens de componente) ===

token             light       dark
----------------------------------------
card.bg           #ffffff     #111827
card.text         #111827     #f9fafb
card.text-muted   #6b7280     #9ca3af
button.bg         #2563eb     #60a5fa
button.text       #ffffff     #111827
badge.bg          #ef4444     #f87171
badge.text        #111827     #111827

=== PASO 2) tailwind: tw() del product-card (utilidad -> CSS real) ===

.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);

=== PASO 3) contraste: los 4 pares de Mercado, ambos temas, con el sanity check ===

  sanity: #000000/#ffffff = 21:1
  -- tema light --
  card.text / card.bg         17.74:1   AAA
  card.text-muted / card.bg   4.83:1    AA
  button.text / button.bg     5.17:1    AA
  badge.text / badge.bg       4.71:1    AA
  -- tema dark --
  card.text / card.bg         16.98:1   AAA
  card.text-muted / card.bg   6.99:1    AA
  button.text / button.bg     6.98:1    AA
  badge.text / badge.bg       6.41:1    AA

=== PASO 4) variantes: Button {variant:"primary", size:"lg"} ===

  inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-on-primary text-lg px-6 py-3 shadow-lg

=== PASO 5) responsive + dark: resolveClasses a 3 viewports x 2 temas ===

   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

=== PASO 6) primitivas: a11yAudit del Dialog, casero vs Radix ===

-- QuickViewDialog casero: 2/7 --
  [x] role="dialog" + aria-modal="true"
  [ ] aria-labelledby apunta al titulo visible
  [ ] el foco entra al primer elemento enfocable al abrir
  [ ] Tab/Shift+Tab quedan atrapados dentro del dialog
  [ ] Escape cierra el dialog
  [ ] el foco vuelve al trigger que lo abrio, al cerrar
  [x] un click fuera del dialog lo cierra

-- QuickViewDialog (Radix): 7/7 --
  [x] role="dialog" + aria-modal="true"
  [x] aria-labelledby apunta al titulo visible
  [x] el foco entra al primer elemento enfocable al abrir
  [x] Tab/Shift+Tab quedan atrapados dentro del dialog
  [x] Escape cierra el dialog
  [x] el foco vuelve al trigger que lo abrio, al cerrar
  [x] un click fuera del dialog lo cierra

=== RESUMEN: el sistema de Mercado, de punta a punta ===
  tokens: 7/7 componentes resuelven en 2 temas
  tailwind: 4/4 piezas del product-card tienen su CSS
  contraste: 8/8 pares medidos (4 pares x 2 temas), 0 FAIL
  variantes: Button primary+lg -> 12 clases, con shadow-lg
  responsive+dark: 6/6 combinaciones (3 anchos x 2 temas) resueltas, 0 dark: en el marcado
  primitivas: Dialog casero 2/7, Dialog con Radix 7/7

Lee esta salida como lo que es: el sistema de diseño completo de Mercado, probado de punta a punta en una sola ejecución.

El paso 1 confirma que los cimientos resuelven: siete tokens de componente, dos temas cada uno, catorce valores — y entre ellos, la asimetría que las lecciones 2 y 4 explicaron: button.text se invierte (#ffffff#111827) y badge.text no (#111827 en ambas columnas). El paso 2 confirma que esos tokens llegan al CSS real a través de Tailwind — dieciocho declaraciones, generadas por seis clases entre las cuatro piezas del product-card, sin una línea de CSS escrita a mano. El paso 3 es la garantía dura: el sanity check da 21:1 (el algoritmo es correcto) y las ocho mediciones —cuatro pares, dos temas— dan cero FAIL, incluyendo el par que en la lección 4 fallaba antes del fix (button.text/button.bg, ahora en 5.17 y 6.98, los dos AA).

El paso 4 muestra el Button empaquetado: doce clases resueltas para { variant: 'primary', size: 'lg' }, con text-on-primary (el color corregido) y shadow-lg (el compound) en su lugar. El paso 5 cruza ese Button con el eje de tamaño y tema: seis combinaciones, el grid cambiando por ancho, el color del botón cambiando por tema, ninguno de los dos ejes tocando al otro — y en ningún momento aparece la palabra dark: en ninguna clase. El paso 6 cierra con la comparación más contundente de la guía: el mismo Dialog, la misma apariencia exacta, 2/7 cuando se construye a mano y 7/7 cuando se monta sobre una primitiva accesible.

El resumen final son seis líneas, una por capa, y las seis dicen lo mismo de formas distintas: el sistema se sostiene solo. No es una promesa — es un número por línea, y cada número salió de código que corriste, no de una afirmación en prosa.

Rúbrica de autoevaluación

Marca cada punto; si todos están, completaste el capstone:

  • Tokens completos. Los 8 semánticos (incluyendo on-primary y on-danger) y los 7 tokens de componente resuelven en los dos temas con resolveToken.
  • Tailwind conectado. tw() con colorMap genera el CSS correcto para las cuatro piezas del product-card, incluyendo el caso foreground → --color-text (nombres que no coinciden).
  • Contraste sin fallas. Los ocho pares (cuatro × dos temas) pasan AA o AAA; el sanity check da 21:1.
  • El bug real, encontrado y arreglado. Puedes explicar por qué text-white fijo fallaba en modo oscuro (2.54:1) y por qué text-on-primary lo arregla (6.98:1).
  • Variantes en dos componentes. variants() resuelve el Button (dos ejes + compound) y el ProductCard (un eje), con la misma función.
  • Responsive + dark sin duplicación. resolveClasses cubre las seis combinaciones (tres anchos × dos temas); cero dark: en el marcado del catálogo o del Button.
  • Primitivas verificadas. a11yAudit da 2/7 para el Dialog casero y 7/7 para el montado sobre una primitiva, con el mismo checklist y la misma apariencia.
  • Salida literal. Corriste node mercado.js de verdad y la salida coincide con lo que reportas — no inventaste el output, en ninguno de los seis pasos.

Ejercicios de transferencia

Estos tres ejercicios no repasan lo que ya hiciste — te piden llevar el sistema de Mercado un paso más allá de donde esta guía llega, hacia las guías que siguen.

Ejercicio 1 — Un componente nuevo, con el sistema ya construido. Mercado necesita un Badge reutilizable (no solo para "Sale" — también "New", "Out of stock"), como componente independiente con sus propias variantes (variant: danger/info/neutral). Sin escribir código, describe en cuatro pasos cómo lo construirías usando solo lo que este sistema ya tiene: qué token(s) necesitarías (¿ya existen o hacen falta nuevos?), qué entrada del colorMap agregarías, cómo se vería su variants(), y qué par de contraste tendrías que auditar antes de darlo por terminado.

Ver enfoque sugerido
  1. Tokens: color.danger/color.on-danger ya existen (los usa el badge de "Sale"). Para info y neutral harían falta dos semánticos nuevos —por ejemplo color.info (blue.100/blue.900 de fondo) con su color.on-info—, siguiendo el mismo patrón de par fondo/texto medido, no asumido.
  2. colorMap: una entrada por cada color nuevo (info, on-info) apuntando a su custom property, exactamente como danger/on-danger en la lección 3.
  3. variants(): una config con un eje variant: { danger: 'bg-danger text-on-danger', info: 'bg-info text-on-info', neutral: 'bg-muted text-foreground' } y base con el padding/radio compartido (px-2 py-1 rounded-full text-xs) — el mismo patrón exacto del Button.
  4. Auditoría: antes de dar el componente por terminado, correr contrastRatio sobre on-info/info en los dos temas — el mismo paso que la lección 4 hizo con on-primary/primary, porque un par de color nuevo nunca se asume accesible, se mide.

La lección: un componente nuevo en un sistema maduro no reinventa nada — reutiliza tokens si existen, agrega los mínimos que faltan, usa el motor de variantes ya construido, y pasa por la misma auditoría que todo lo demás. Es la prueba de que "sistema" significa que agregar algo nuevo es barato.

Ejercicio 2 — Hacia nextjs-app-router-guide. El Catalog.jsx de este proyecto asume que products ya llegó como prop. En una app real con Next.js App Router, ¿de dónde vendrían esos datos, y qué decisión (Server Component vs Client Component) tomarías para el Catalog frente al QuickViewDialog? No necesitas código — describe el razonamiento.

Ver enfoque sugerido

El Catalog en sí —que solo recibe products y los recorre para renderizar ProductCards— no necesita interactividad de cliente: podría ser un Server Component que hace fetch de los productos directamente en el servidor (sin un useEffect ni un estado de carga en el cliente) y pasa los datos ya resueltos a los ProductCard. El QuickViewDialog, en cambio, necesita estado (open/onOpenChange) y responde a eventos del usuario (clic, teclado, Escape) — eso requiere ser un Client Component ('use client'), como cualquier componente que use hooks de React o maneje interacción del navegador. La decisión no es "toda la página es cliente o toda es servidor" — es por componente, y el sistema de diseño que construiste (tokens, utilidades, variantes) funciona igual en los dos casos, porque es CSS y configuración, no lógica de datos. Esa frontera exacta —qué renderiza el servidor, qué vive en el cliente, cómo se pasan props entre los dos— es el tema central de nextjs-app-router-guide.

Ejercicio 3 — Hacia frontend-state-and-data-guide y fullstack-performance-and-deployment-guide. El catálogo de Mercado, en producción, necesita: (a) recordar qué tema eligió el usuario entre visitas, y (b) no enviar al navegador el CSS de utilidades que ningún componente usa. Para cada necesidad, nombra la guía de la que sale la respuesta y en una frase por qué esta guía (ui-systems-and-design-implementation-guide) no la resuelve.

Ver enfoque sugerido
  • (a) Recordar el tema elegido es un problema de estado que persiste entre sesiones —guardarlo (localStorage, cookie, preferencia de cuenta) y sincronizarlo con la estrategia class del módulo 6 al cargar la página—. Esta guía enseñó cómo el tema cambia el sistema (tokens, dark:), no dónde se guarda la elección del usuario ni cómo se comparte entre componentes sin prop-drilling — eso es frontend-state-and-data-guide.
  • (b) No enviar CSS no usado es el purgado de Tailwind — la herramienta escanea el código fuente y elimina del bundle final las clases que ningún archivo usa—. Esta guía mencionó que Tailwind purga (en el módulo 3) pero no entra en el mecanismo de build, el tamaño del bundle final, ni el critical CSS — eso es fullstack-performance-and-deployment-guide.

La lección de cierre: esta guía te dio el sistema —qué CSS escribir y por qué—; las guías hermanas te dan lo que pasa alrededor de ese sistema una vez que la app crece — dónde vive el estado, cómo se sirve rápido. Saber la frontera entre las tres es tan parte de "pensar en sistemas" como saber construir uno.

Cierre de la guía: el ecosistema Fullstack, completo

Con este proyecto termina ui-systems-and-design-implementation-guide. Vale la pena mirar el camino completo, de atrás hacia adelante: empezaste en el módulo 1 preguntando qué es un sistema de diseño y por qué CSS ad-hoc no escala. En el 2 construiste su cimiento —tokens—. En el 3, la capa que los consume —utilidades—. En el 4, la garantía de que lo que esas utilidades pintan se lee —escalas y contraste—. En el 5, el empaquetado de esas utilidades en componentes con variantes. En el 6, la extensión de esos componentes a cualquier tamaño y cualquier tema, sin duplicarse. En el 7, la delegación de lo que no vale la pena reinventar —primitivas accesibles—. Y en este módulo 8, el ensamblaje de las siete capas en un sistema que se sostiene solo, con cada afirmación respaldada por un número que ejecutaste.

Esta guía se apoyó en dos prerequisitos del ecosistema Fullstack —web-fundamentals-html-css-guide (el CSS a mano detrás de cada utilidad) y react-fundamentals-guide (los componentes que estas utilidades y variantes visten)— y ahora entrega lo que sus tres guías hermanas dan por sentado:

  • nextjs-app-router-guide — toma el sistema de diseño que construiste y lo lleva a producción: qué se renderiza en el servidor, qué en el cliente, cómo se sirve. El Catalog y el QuickViewDialog de este proyecto son el punto de partida exacto de esa guía.
  • frontend-state-and-data-guide — resuelve lo que este sistema no toca: de dónde vienen los datos que llenan ProductCard, cómo se comparte el estado (el carrito, el tema elegido) entre componentes sin duplicar lógica.
  • fullstack-performance-and-deployment-guide — cierra el círculo con el purgado de Tailwind, el critical CSS, y el despliegue del storefront completo que este módulo dejó listo, pero sin optimizar para producción.

El sistema de diseño de Mercado que construiste en este capstone no es un ejercicio aislado — es el Button, el ProductCard y el Catalog que vas a encontrar, con estos mismos nombres, en cada una de esas tres guías. Llegas ahí con los cimientos ya puestos: tokens que resuelven por tema, utilidades que los consumen, contraste verificado, componentes con variantes, responsive sin duplicación, y primitivas accesibles. Lo que sigue es construir sobre eso, no repetirlo.

Recursos

  • Tailwind CSS, documentación completa — tailwindcss.com/docs. El punto de referencia para toda la capa de utilidades y configuración de este proyecto. En inglés.
  • Design Tokens Community Group (W3C) — design-tokens.github.io/community-group. El estándar detrás del set de tokens completo de Mercado. En inglés.
  • WCAG 2.1, "Contrast (Minimum)" — w3.org/WAI/WCAG21/Understanding/contrast-minimum.html. El umbral que las ocho mediciones del paso 3 aplican. En inglés.
  • cva (class-variance-authority) — cva.style/docs. El motor real detrás del Button y el ProductCard. En inglés.
  • Radix Primitives — radix-ui.com/primitives. La base del QuickViewDialog accesible. En inglés.
  • ui.shadcn.com — ui.shadcn.com. Un sistema de producción con la misma arquitectura de capas —tokens, utilidades, variantes, primitivas— que este capstone construyó desde cero. En inglés.