Módulo 21: Proyecto final

Objetivos del módulo

  • Construir de principio a fin una web completa de la tienda EMPRESA: portada, catálogo, ficha de producto y panel de control
  • Organizar un proyecto Vite de varias páginas sin framework
  • Diseñar un sistema visual con colores de marca, tokens semánticos, una utilidad propia y unos pocos componentes
  • Aplicar modo oscuro con clase, respetando la preferencia del sistema y sin parpadeo al cargar
  • Escribir clases dentro de JavaScript sin que Tailwind deje de detectarlas
  • Dibujar gráficos solo con CSS: columnas, barras y un anillo con conic-gradient
  • Hacer que tarjetas, tablas y menús se adapten de verdad, con puntos de ruptura y consultas de contenedor
  • Compilar el proyecto para producción y comprobarlo desde una carpeta limpia
  • Saber qué hay que cambiar exactamente para compilar el mismo proyecto en Tailwind 3.4, y qué se pierde

🔄 Cambia en Tailwind 4.0. Todo este proyecto se configura con @theme, @custom-variant y @utility, que no existen en la LTS (Tailwind 3.4). Si usas la LTS: en la sección 12 tienes el proyecto portado y compilado de verdad con 3.4.19, con su tailwind.config.js y la lista completa de clases que hay que sustituir.


1. Qué vamos a construir

Este módulo reúne lo visto en el curso en una sola web. No es una colección de ejemplos: son cuatro páginas enlazadas que comparten diseño, datos y comportamiento, con todo el código en el texto.

Página Fichero Qué repasa
Portada index.html Degradados, máscaras, tipografía, rejillas
Catálogo catalogo.html Formularios, filtros con has-*, consultas de contenedor
Ficha producto.html Dos columnas, estados de formulario, <dialog>
Panel de control panel.html Barra lateral, tablas, gráficos CSS

Los datos son los reales de EMPRESA: 20 productos, 14 clientes, 24 pedidos y sus líneas. Se leen de un JSON local para que el proyecto funcione sin servidor. El ejercicio 7 lo conecta con la API de ejemplo.

💻 Ejemplo: Descarga el proyecto completo en tienda-empresa-tailwind.zip, sin node_modules ni dist. Descomprímelo, entra en la carpeta y ejecuta npm install y npm run dev.

Esquema del proyecto: cuatro páginas HTML, cada una con su guion, que usan interfaz.js y tienda.js; tienda.js lee datos.json y Tailwind genera un único CSS desde app.css

Figura 1. Cada capa tiene un solo trabajo. Las clases de Tailwind están en el HTML y en interfaz.js; las consultas de datos no saben nada de diseño.

La separación entre interfaz.js y tienda.js no es capricho. Si mañana los datos llegan de una API, solo cambia tienda.js. Si cambia el diseño, solo cambian el HTML, interfaz.js y app.css.

Lo único de este módulo que no es HTML puro son los guiones que rellenan las páginas con los datos. No usan ningún framework: son módulos de JavaScript corrientes, porque la integración con React o Angular ya la viste en el módulo 18.


2. Preparación del proyecto

tienda-empresa-tailwind/
├── .gitignore
├── README.md
├── package.json
├── vite.config.js
├── index.html          portada
├── catalogo.html       catálogo con filtros
├── producto.html       ficha (producto.html?id=5)
├── panel.html          panel de control
└── src/
    ├── app.css         Tailwind y sistema de diseño
    ├── datos.json      datos de EMPRESA
    ├── tienda.js       consultas, formatos y carrito
    ├── interfaz.js     piezas comunes con clases
    ├── portada.js
    ├── catalogo.js
    ├── ficha.js
    └── panel.js

package.json:

{
  "name": "tienda-empresa-tailwind",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "description": "Proyecto final del Curso de Tailwind CSS: tienda EMPRESA",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "@tailwindcss/vite": "4.3.3",
    "tailwindcss": "4.3.3",
    "vite": "8.3.5"
  }
}

Las versiones van fijadas sin ^ para que el proyecto compile igual dentro de un año. Vite 8 pide Node.js 20.19, o bien 22.12 o posterior.

vite.config.js:

import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';

const pagina = (fichero) => resolve(import.meta.dirname, fichero);

export default defineConfig({
  // Rutas relativas: dist/ funciona también dentro de una subcarpeta
  base: './',
  plugins: [tailwindcss()],
  build: {
    rolldownOptions: {
      // Vite solo compila index.html si no le dices que hay más páginas
      input: {
        portada: pagina('index.html'),
        catalogo: pagina('catalogo.html'),
        producto: pagina('producto.html'),
        panel: pagina('panel.html'),
      },
    },
  },
});

Tres detalles que suelen fallar:

  • Sin input, Vite solo compila index.html. En desarrollo todo parece ir bien, porque el servidor sirve cualquier HTML; el error aparece al publicar, cuando catalogo.html no está en dist.
  • base: './' hace que las rutas de dist sean relativas, así que la web funciona dentro de una subcarpeta como las de GitHub Pages.
  • rolldownOptions es el nombre actual en Vite 8. rollupOptions sigue aceptándose, pero sus tipos lo marcan como obsoleto.

Por el mismo motivo, los enlaces entre páginas son relativos: href="catalogo.html", sin barra inicial.


3. Datos y lógica

src/datos.json contiene cinco listas copiadas de la API de ejemplo: categorias, productos, clientes, pedidos y lineasPedido. De clientes y pedidos solo se guardan los campos que usa la web. Este es su aspecto:

{
  "productos": [
    {
      "productoId": 5,
      "nombre": "Torre Workstation",
      "descripcion": "Estación de trabajo para diseño y CAD",
      "categoriaId": 3,
      "precio": 1899,
      "stock": 4,
      "fechaAlta": "2024-06-01",
      "descatalogado": false
    }
  ],
  "lineasPedido": [
    {
      "pedidoId": 5009,
      "numLinea": 1,
      "productoId": 5,
      "cantidad": 2,
      "precioUnitario": 1899,
      "descuento": 12
    }
  ]
}

El fichero completo está en el ZIP: son datos, no código. tienda.js, en cambio, se muestra entero, y no contiene ni una clase de Tailwind:

src/tienda.js:

// Datos de la tienda y consultas sobre ellos. Aquí no hay ninguna clase de Tailwind.
import datos from './datos.json';

export const { categorias, productos, clientes, pedidos, lineasPedido } = datos;

const formatoPrecio = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
const formatoFecha = new Intl.DateTimeFormat('es-ES', { dateStyle: 'medium', timeZone: 'UTC' });

export const precio = (cantidad) => formatoPrecio.format(cantidad);
export const fecha = (texto) => formatoFecha.format(new Date(texto));

// Categorías: el árbol se guarda con categoriaPadreId
export const categoriaDe = (id) => categorias.find((c) => c.categoriaId === id);

export function rutaDe(id) {
  const categoria = categoriaDe(id);
  if (!categoria.categoriaPadreId) return [categoria];
  return [...rutaDe(categoria.categoriaPadreId), categoria];
}

export const raizDe = (id) => rutaDe(id)[0];
export const categoriasRaiz = () => categorias.filter((c) => c.categoriaPadreId === null);
export const enCategoria = (producto, raizId) => raizDe(producto.categoriaId).categoriaId === raizId;

// Pedidos: el importe sale de las líneas, y cada línea puede llevar descuento
export const importeLinea = (l) => l.cantidad * l.precioUnitario * (1 - l.descuento / 100);
export const lineasDe = (pedidoId) => lineasPedido.filter((l) => l.pedidoId === pedidoId);
export const totalPedido = (pedidoId) =>
  lineasDe(pedidoId).reduce((suma, l) => suma + importeLinea(l), 0);

export const clienteDe = (id) => clientes.find((c) => c.clienteId === id);
export const pedidosFacturados = () => pedidos.filter((p) => p.estado !== 'CANCELADO');

export function unidadesVendidas(productoId) {
  const validos = new Set(pedidosFacturados().map((p) => p.pedidoId));
  return lineasPedido
    .filter((l) => l.productoId === productoId && validos.has(l.pedidoId))
    .reduce((suma, l) => suma + l.cantidad, 0);
}

// Carrito mínimo: { idProducto: unidades } en localStorage
const leerCarrito = () => JSON.parse(localStorage.getItem('carrito') ?? '{}');

export function anadirAlCarrito(productoId, unidades) {
  const carrito = leerCarrito();
  carrito[productoId] = (carrito[productoId] ?? 0) + unidades;
  localStorage.setItem('carrito', JSON.stringify(carrito));
}

export const unidadesCarrito = () =>
  Object.values(leerCarrito()).reduce((suma, n) => suma + n, 0);

Dos decisiones que afectan a lo que verás en pantalla:

  • El importe de un pedido no está guardado: sale de sus líneas, descuento incluido. Los pedidos cancelados no cuentan como facturación.
  • timeZone: 'UTC' en las fechas evita que 2026-07-01 se muestre como 30 de junio en un navegador situado al oeste de Greenwich.

💡 Consejo: Intl.NumberFormat con es-ES no separa los miles en números de cuatro cifras: verás 1899,00 € y 61.617 €. Es la regla del idioma, no un error. Si prefieres 1.899,00 €, añade useGrouping: 'always' a las opciones.


4. El sistema de diseño

Antes de escribir una sola página hay que decidir los colores, las sombras y las pocas piezas repetidas. Todo cabe en un fichero:

src/app.css:

@import "tailwindcss";

/* Modo oscuro controlado por una clase en <html>, no solo por el sistema */
@custom-variant dark (&:where(.dark, .dark *));

/* 1. Marca: colores fijos, iguales en modo claro y oscuro */
@theme {
  --font-sans: "Segoe UI", system-ui, -apple-system, Roboto, "Helvetica Neue", Arial, sans-serif;

  --color-marca-50: oklch(0.97 0.014 275);
  --color-marca-100: oklch(0.93 0.034 275);
  --color-marca-200: oklch(0.87 0.065 275);
  --color-marca-300: oklch(0.78 0.105 275);
  --color-marca-400: oklch(0.67 0.16 275);
  --color-marca-500: oklch(0.58 0.2 275);
  --color-marca-600: oklch(0.51 0.21 275);
  --color-marca-700: oklch(0.45 0.19 275);
  --color-marca-800: oklch(0.38 0.15 275);
  --color-marca-900: oklch(0.32 0.11 275);
  --color-marca-950: oklch(0.24 0.08 275);

  --color-acento-400: oklch(0.76 0.16 45);
  --color-acento-500: oklch(0.7 0.19 42);
  --color-acento-600: oklch(0.62 0.2 38);

  --shadow-tarjeta: 0 1px 2px oklch(0.25 0.03 275 / 0.06), 0 4px 12px oklch(0.25 0.03 275 / 0.05);
  --shadow-tarjeta-alta: 0 4px 8px oklch(0.25 0.03 275 / 0.08), 0 16px 32px oklch(0.25 0.03 275 / 0.12);

  --animate-aparecer: aparecer 0.35s ease-out both;

  @keyframes aparecer {
    from {
      opacity: 0;
      transform: translateY(0.5rem);
    }
  }
}

/* 2. Tokens semánticos: su valor depende del tema activo */
@theme inline {
  --color-fondo: var(--fondo);
  --color-papel: var(--papel);
  --color-hueco: var(--hueco);
  --color-tinta: var(--tinta);
  --color-tenue: var(--tenue);
  --color-linea: var(--linea);
}

@layer base {
  :root {
    color-scheme: light;
    --fondo: oklch(0.98 0.004 275);
    --papel: oklch(1 0 0);
    --hueco: oklch(0.955 0.008 275);
    --tinta: oklch(0.25 0.03 275);
    --tenue: oklch(0.5 0.03 275);
    --linea: oklch(0.91 0.012 275);
  }

  .dark {
    color-scheme: dark;
    --fondo: oklch(0.19 0.02 275);
    --papel: oklch(0.24 0.025 275);
    --hueco: oklch(0.29 0.03 275);
    --tinta: oklch(0.95 0.01 275);
    --tenue: oklch(0.74 0.025 275);
    --linea: oklch(0.34 0.03 275);
  }
}

/* 3. Utilidad propia: el ancho común de todas las páginas */
@utility pagina {
  @apply mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8;
}

/* 4. Componentes: solo lo que se repite en muchos sitios */
@layer components {
  .tarjeta {
    @apply rounded-xl border border-linea bg-papel shadow-tarjeta;
  }

  .boton {
    @apply inline-flex cursor-pointer items-center justify-center gap-2 rounded-lg px-4 py-2 font-semibold shadow-xs transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-marca-500 disabled:cursor-not-allowed disabled:opacity-50;
  }

  .boton-primario {
    @apply bg-marca-600 text-white enabled:hover:bg-marca-700;
  }

  .boton-icono {
    @apply relative grid size-10 cursor-pointer place-items-center rounded-lg text-tenue transition-colors hover:bg-hueco hover:text-tinta focus-visible:outline-2 focus-visible:outline-marca-500;
  }

  .insignia {
    @apply inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap;
  }

  .campo {
    @apply w-full rounded-lg border border-linea bg-papel px-3 py-2 text-tinta outline-hidden transition-colors placeholder:text-tenue focus:border-marca-500 focus:ring-2 focus:ring-marca-500/30;
  }
}

Veámoslo por partes.

Colores de marca con @theme 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) el tema se define en tailwind.config.js. Si usas la LTS: pasa cada color a theme.extend.colors en hexadecimal, porque en 3.4 un color oklch() del tema no admite modificadores como /30. Los valores están en la sección 12.

Cada --color-marca-* genera toda su familia de utilidades: bg-marca-600, text-marca-800, ring-marca-500/30. La escala mantiene el matiz fijo en 275 y solo cambia la luminosidad y la saturación, de ahí que el ejercicio 1 pueda cambiar la marca entera tocando un número. Lo viste con detalle en el módulo 14.

--animate-aparecer y su @keyframes dentro de @theme crean la clase animate-aparecer, que las tarjetas usan con motion-safe: para respetar a quien pide menos movimiento (módulo 13).

Tokens semánticos con @theme inline 🔄 v4.0

🔄 Cambia en Tailwind 4.0. @theme inline no existe en la LTS (Tailwind 3.4). Si usas la LTS: declara papel: 'var(--papel)' en theme.extend.colors. Funciona, pero bg-papel/85 deja de generarse sin aviso; usa rgb(var(--papel) / <alpha-value>) con la variable en tres números.

Un token semántico nombra un papel, no un color. Hay seis:

Clase Significado Claro Oscuro
bg-fondo Fondo de la página casi blanco azul muy oscuro
bg-papel Tarjetas y cabecera blanco azul oscuro
bg-hueco Zonas hundidas, hover gris azulado azul medio
text-tinta Texto principal casi negro casi blanco
text-tenue Texto secundario gris medio gris claro
border-linea Bordes y separadores gris muy claro azul grisáceo

Con inline, bg-papel genera background-color: var(--papel) directamente. El bloque @layer base da los valores, y .dark los redefine.

📘 Concepto: Hay dos formas de hacer modo oscuro, y este proyecto usa las dos a propósito. Lo que cambia siempre (fondos, texto, bordes) va en tokens: una tarjeta se escribe una vez y funciona en ambos temas. Lo que es una excepción (el degradado de la portada, el color de una insignia) va con dark:. Si solo usaras dark:, cada elemento llevaría el doble de clases.

Modo oscuro con @custom-variant 🔄 v4.0

🔄 Cambia en Tailwind 4.0. @custom-variant no existe en la LTS (Tailwind 3.4). Si usas la LTS: añade darkMode: 'selector' a tailwind.config.js; las clases dark: se escriben igual.

Por defecto, dark: sigue a prefers-color-scheme. Aquí queremos que el visitante pueda elegir, así que la variante pasa a depender de la clase dark en <html>. La clase se pone con esta línea, repetida dentro del <head> de las cuatro páginas:

<script>document.documentElement.classList.toggle('dark', localStorage.tema ? localStorage.tema === 'oscuro' : matchMedia('(prefers-color-scheme: dark)').matches)</script>

Si hay una elección guardada, manda esa; si no, manda el sistema. Va en línea y antes del CSS porque tiene que ejecutarse antes de que se pinte nada. Un módulo de JavaScript se ejecuta después, y se vería un fogonazo blanco en cada navegación. Si tu web usa una CSP estricta, recuerda lo visto en el módulo 20 sobre los guiones en línea.

color-scheme en :root y .dark cambia lo que dibuja el propio navegador: barras de desplazamiento, <select> y calendarios.

Una utilidad propia con @utility 🔄 v4.0

🔄 Cambia en Tailwind 4.0. @utility no existe en la LTS (Tailwind 3.4). Si usas la LTS: escribe .pagina { @apply ...; } dentro de @layer components; @apply funciona igual.

pagina centra el contenido y fija sus márgenes. Es una utilidad y no un componente porque ocupa un solo papel y debe combinarse con otras clases, como flex o py-8.

Componentes con mesura

Solo hay cinco. Se quedan porque se repiten decenas de veces, también dentro de JavaScript, donde no hay una plantilla que los reutilice. Es el criterio del módulo 16. Fíjate en lo que no se ha extraído: la cabecera, la rejilla del catálogo y los gráficos aparecen una sola vez, así que sus clases siguen en el marcado.

Al estar en @layer components, cualquier utilidad los sobrescribe. Por eso class="boton bg-white text-marca-800" funciona sin !.

⚠️ Importante: Tres clases de app.css tienen trampa si vienes de la 3.4. shadow-xs y outline-hidden no existen allí con ese nombre, y focus-visible:outline-2 no dibuja nada en 3.4 porque solo fija el ancho. Están en la tabla de la sección 12.


5. Piezas comunes

interfaz.js contiene todo lo que comparten las páginas: iconos, insignias, tarjeta de producto, cabecera, pie, tema y menú.

src/interfaz.js:

// Piezas de interfaz comunes a las cuatro páginas
import { precio, raizDe, categoriaDe, unidadesCarrito } from './tienda.js';

const svg = (trazos, clases = 'size-5') =>
  `<svg class="${clases}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
    stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${trazos}</svg>`;

const trazos = {
  sol: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M5 19l1.5-1.5M17.5 6.5 19 5"/>',
  luna: '<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/>',
  carrito: '<circle cx="9" cy="20" r="1.5"/><circle cx="18" cy="20" r="1.5"/><path d="M2 3h3l2.6 12.5h11L21 7H6"/>',
  menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
  monitor: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>',
  movil: '<rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/>',
  silla: '<path d="M8 3h8v9H8zM6 12h12v3H6zM12 15v4M7 21l5-2 5 2"/>',
};

// Las clases se escriben completas: Tailwind busca texto, no ejecuta JavaScript
const estiloCategoria = {
  Informática: {
    icono: trazos.monitor,
    fondo: 'from-marca-100 to-marca-200 text-marca-700 dark:from-marca-900 dark:to-marca-800 dark:text-marca-200',
  },
  Electrónica: {
    icono: trazos.movil,
    fondo: 'from-orange-100 to-amber-200 text-orange-700 dark:from-orange-950 dark:to-amber-900 dark:text-orange-200',
  },
  Oficina: {
    icono: trazos.silla,
    fondo: 'from-emerald-100 to-teal-200 text-emerald-700 dark:from-emerald-950 dark:to-teal-900 dark:text-emerald-200',
  },
};

export const estiloDe = (producto) => estiloCategoria[raizDe(producto.categoriaId).nombre];
export const icono = (nombre, clases) => svg(trazos[nombre], clases);

export function imagenProducto(producto, clases = '', tamano = 'size-16') {
  const { icono: dibujo, fondo } = estiloDe(producto);
  return `<div class="grid place-items-center bg-linear-to-br ${fondo} ${clases}">
    ${svg(dibujo, `${tamano} transition-transform duration-300 group-hover:scale-110`)}
  </div>`;
}

const tonos = {
  gris: 'bg-hueco text-tenue',
  rojo: 'bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-300',
  ambar: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
  verde: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300',
  marca: 'bg-marca-100 text-marca-800 dark:bg-marca-900 dark:text-marca-200',
};

export function insigniaStock(producto) {
  let [tono, texto] = ['verde', 'En stock'];
  if (producto.descatalogado) [tono, texto] = ['gris', 'Descatalogado'];
  else if (producto.stock === 0) [tono, texto] = ['rojo', 'Agotado'];
  else if (producto.stock < 10) [tono, texto] = ['ambar', `Quedan ${producto.stock}`];
  return `<span class="insignia ${tonos[tono]}">${texto}</span>`;
}

export const estados = {
  ENTREGADO: { texto: 'Entregado', tono: tonos.verde, punto: 'bg-emerald-500', color: 'var(--color-emerald-500)' },
  ENVIADO: { texto: 'Enviado', tono: tonos.marca, punto: 'bg-marca-500', color: 'var(--color-marca-500)' },
  PENDIENTE: { texto: 'Pendiente', tono: tonos.ambar, punto: 'bg-amber-500', color: 'var(--color-amber-500)' },
  CANCELADO: { texto: 'Cancelado', tono: tonos.rojo, punto: 'bg-rose-500', color: 'var(--color-rose-500)' },
};

export function tarjetaProducto(producto) {
  return `<article class="@container motion-safe:animate-aparecer">
    <a href="producto.html?id=${producto.productoId}"
       class="tarjeta group flex h-full flex-col overflow-hidden transition duration-200 hover:-translate-y-1 hover:shadow-tarjeta-alta focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-marca-500 @md:flex-row">
      ${imagenProducto(producto, 'aspect-4/3 @md:aspect-auto @md:w-44 @md:shrink-0')}
      <div class="flex flex-1 flex-col gap-2 p-4">
        <p class="text-xs font-medium tracking-wide text-tenue uppercase">${categoriaDe(producto.categoriaId).nombre}</p>
        <h3 class="font-semibold text-balance">${producto.nombre}</h3>
        <p class="line-clamp-2 text-sm text-tenue">${producto.descripcion}</p>
        <div class="mt-auto flex items-center justify-between gap-2 pt-2">
          <span class="text-lg font-bold tabular-nums">${precio(producto.precio)}</span>
          ${insigniaStock(producto)}
        </div>
      </div>
    </a>
  </article>`;
}

const enlaces = [
  { id: 'portada', texto: 'Inicio', href: 'index.html' },
  { id: 'catalogo', texto: 'Catálogo', href: 'catalogo.html' },
  { id: 'panel', texto: 'Panel de control', href: 'panel.html' },
];

const enlace = ({ id, texto, href }, actual) =>
  `<a href="${href}" ${id === actual ? 'aria-current="page"' : ''}
     class="rounded-lg px-3 py-2 text-sm font-medium text-tenue transition-colors hover:bg-hueco hover:text-tinta aria-[current=page]:bg-marca-100 aria-[current=page]:text-marca-800 dark:aria-[current=page]:bg-marca-900 dark:aria-[current=page]:text-marca-200">${texto}</a>`;

function cabecera(actual) {
  const navegacion = enlaces.map((e) => enlace(e, actual)).join('');
  return `<header class="sticky top-0 z-40 border-b border-linea bg-papel/85 backdrop-blur-md print:hidden">
    <div class="pagina flex h-16 items-center gap-2">
      <a href="index.html" class="flex items-center gap-2 text-lg font-bold tracking-tight">
        <span class="grid size-8 place-items-center rounded-lg bg-linear-to-br from-marca-500 to-marca-700 text-sm text-white">E</span>
        EMPRESA
      </a>
      <nav aria-label="Principal" class="ms-6 hidden gap-1 md:flex">${navegacion}</nav>
      <div class="ms-auto flex items-center gap-1">
        <button id="boton-tema" type="button" class="boton-icono" aria-label="Cambiar entre modo claro y oscuro">
          ${icono('luna', 'size-5 dark:hidden')}${icono('sol', 'hidden size-5 dark:block')}
        </button>
        <span class="boton-icono" role="img" aria-label="Carrito">
          ${icono('carrito')}
          <span id="contador-carrito" class="absolute -end-0.5 -top-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-acento-600 px-1 text-xs font-bold text-white"></span>
        </span>
        <button id="boton-menu" type="button" class="boton-icono md:hidden" aria-label="Abrir el menú"
                aria-expanded="false" aria-controls="menu-movil">${icono('menu')}</button>
      </div>
    </div>
    <nav id="menu-movil" aria-label="Móvil" hidden class="pagina flex flex-col gap-1 pb-3 md:hidden">${navegacion}</nav>
  </header>`;
}

const pie = () => `<footer class="mt-16 border-t border-linea bg-papel print:hidden">
  <div class="pagina flex flex-col gap-2 py-8 text-sm text-tenue sm:flex-row sm:items-center sm:justify-between">
    <p><strong class="text-tinta">EMPRESA</strong>: tienda de ejemplo del Curso de Tailwind CSS.</p>
    <p>Madrid · Valencia · Sevilla · Barcelona · Zaragoza · Bilbao</p>
  </div>
</footer>`;

export function pintarCarrito() {
  const contador = document.querySelector('#contador-carrito');
  const unidades = unidadesCarrito();
  contador.textContent = unidades;
  contador.hidden = unidades === 0;
}

function conectarTema() {
  document.querySelector('#boton-tema').addEventListener('click', () => {
    const oscuro = document.documentElement.classList.toggle('dark');
    localStorage.setItem('tema', oscuro ? 'oscuro' : 'claro');
  });
}

function conectarMenu() {
  const boton = document.querySelector('#boton-menu');
  const menu = document.querySelector('#menu-movil');
  boton.addEventListener('click', () => {
    menu.hidden = !menu.hidden;
    boton.setAttribute('aria-expanded', String(!menu.hidden));
  });
}

export function iniciarPagina(actual) {
  document.querySelector('#cabecera').innerHTML = cabecera(actual);
  document.querySelector('#pie').innerHTML = pie();
  conectarTema();
  conectarMenu();
  pintarCarrito();
}

Clases dentro de JavaScript

Tailwind no ejecuta tu código: lo lee como texto y busca cualquier cosa que parezca una clase. Por eso estiloCategoria y tonos guardan clases completas. Si hubieras escrito `bg-${color}-100`, ese texto no contiene ninguna clase real y el CSS no se generaría. Es el error que analizaste en el módulo 18.

La detección automática ya cubre src/*.js, así que no hace falta @source.

Por qué una función tarjetaProducto

La tarjeta aparece en tres páginas. En un proyecto sin framework, una función que devuelve texto hace el papel de componente. Reúne lo que has practicado en el curso:

  • group en el enlace y group-hover:scale-110 en el icono.
  • h-full y mt-auto para alinear el pie aunque cambie la longitud del texto (módulo 08).
  • line-clamp-2 para que todas las descripciones ocupen lo mismo.
  • tabular-nums para que los precios no bailen.

La tarjeta responde a su contenedor 🆕 v4.0

🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4). Si usas la LTS: instala @tailwindcss/container-queries y añádelo a plugins. @container y @md: se escriben igual, pero @3xs: no existe en el plugin: usa @[16rem]:.

@container en el <article> y @md:flex-row en el enlace hacen que la tarjeta se ponga en horizontal cuando ella mide al menos 28 rem, sea cual sea el ancho de la pantalla. La misma función sirve para una rejilla de cuatro columnas y para una lista; lo comprobarás en el ejercicio 3.

El atributo hidden y Preflight 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4), el atributo hidden no lleva !important en Preflight, así que flex o grid lo vencen. Si usas la LTS: añade [&[hidden]]:hidden a #menu-movil y #contador-carrito, o el menú quedará siempre abierto.

El menú móvil es <nav hidden class="flex ...">. Mostrarlo es tan simple como menu.hidden = !menu.hidden, y aria-expanded mantiene informados a los lectores de pantalla.

Pantalla de 390 píxeles con el menú móvil desplegado: Inicio, Catálogo con fondo resaltado y Panel de control

Figura 2. Menú desplegado. aria-[current=page]: resalta la página actual usando el mismo atributo que leen los lectores de pantalla, así que el estilo y la accesibilidad no pueden desincronizarse.


6. La portada

index.html:

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>EMPRESA · Tienda de informática y oficina</title>
  <script>document.documentElement.classList.toggle('dark', localStorage.tema ? localStorage.tema === 'oscuro' : matchMedia('(prefers-color-scheme: dark)').matches)</script>
  <link rel="stylesheet" href="/src/app.css">
  <script type="module" src="/src/portada.js"></script>
</head>
<body class="flex min-h-dvh flex-col bg-fondo font-sans text-tinta antialiased">
  <div id="cabecera"></div>

  <main class="flex-1">
    <section class="relative isolate overflow-hidden bg-linear-to-br from-marca-800 via-marca-600 to-acento-500 text-white dark:from-marca-950 dark:via-marca-800 dark:to-acento-600">
      <div aria-hidden="true" class="absolute inset-0 -z-10 bg-[radial-gradient(white_1px,transparent_1px)] bg-size-[1.5rem_1.5rem] opacity-25 mask-b-from-20%"></div>
      <div class="pagina py-20 sm:py-28">
        <p class="text-sm font-semibold tracking-widest text-marca-100 uppercase">Informática, electrónica y oficina</p>
        <h1 class="mt-3 max-w-2xl text-4xl font-bold tracking-tight text-balance text-shadow-sm sm:text-6xl">
          Todo lo que tu equipo necesita para trabajar
        </h1>
        <p class="mt-5 max-w-xl text-lg text-marca-50">
          Portátiles, monitores, periféricos y mobiliario con envío a toda España y precios para empresas.
        </p>
        <div class="mt-8 flex flex-wrap gap-3">
          <a href="catalogo.html" class="boton bg-white text-marca-800 hover:bg-marca-50">Ver el catálogo</a>
          <a href="panel.html" class="boton bg-white/15 text-white ring-1 ring-white/40 hover:bg-white/25">Panel de control</a>
        </div>
      </div>
    </section>

    <section class="pagina relative -mt-8">
      <dl id="cifras" class="tarjeta grid grid-cols-2 divide-linea md:grid-cols-4 md:divide-x"></dl>
    </section>

    <section class="pagina mt-16" aria-labelledby="titulo-categorias">
      <h2 id="titulo-categorias" class="text-2xl font-bold tracking-tight">Compra por categoría</h2>
      <div id="lista-categorias" class="mt-6 grid gap-4 sm:grid-cols-3"></div>
    </section>

    <section class="pagina mt-16" aria-labelledby="titulo-novedades">
      <div class="flex items-baseline justify-between gap-4">
        <h2 id="titulo-novedades" class="text-2xl font-bold tracking-tight">Novedades</h2>
        <a href="catalogo.html" class="text-sm font-semibold text-marca-700 hover:underline dark:text-marca-300">Ver todo</a>
      </div>
      <div id="lista-novedades" class="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-4"></div>
    </section>
  </main>

  <div id="pie"></div>
</body>
</html>

src/portada.js:

import { productos, clientes, pedidos, categoriasRaiz, enCategoria } from './tienda.js';
import { iniciarPagina, tarjetaProducto, imagenProducto } from './interfaz.js';

iniciarPagina('portada');

const activos = productos.filter((p) => !p.descatalogado);

const cifras = [
  ['Productos activos', activos.length],
  ['Clientes', clientes.length],
  ['Pedidos atendidos', pedidos.filter((p) => p.estado === 'ENTREGADO').length],
  ['Categorías', categoriasRaiz().length],
];

document.querySelector('#cifras').innerHTML = cifras
  .map(([nombre, valor]) => `<div class="px-6 py-5 text-center">
    <dd class="text-3xl font-bold text-marca-700 tabular-nums dark:text-marca-300">${valor}</dd>
    <dt class="mt-1 text-sm text-tenue">${nombre}</dt>
  </div>`)
  .join('');

document.querySelector('#lista-categorias').innerHTML = categoriasRaiz()
  .map((categoria) => {
    const propios = activos.filter((p) => enCategoria(p, categoria.categoriaId));
    return `<a href="catalogo.html?categoria=${categoria.categoriaId}"
               class="tarjeta group flex items-center gap-4 overflow-hidden transition hover:shadow-tarjeta-alta">
      ${imagenProducto(propios[0], 'size-24 shrink-0', 'size-10')}
      <div>
        <h3 class="font-semibold group-hover:text-marca-700 dark:group-hover:text-marca-300">${categoria.nombre}</h3>
        <p class="text-sm text-tenue">${propios.length} productos</p>
      </div>
    </a>`;
  })
  .join('');

// Las novedades son los cuatro productos activos dados de alta más recientemente
document.querySelector('#lista-novedades').innerHTML = activos
  .toSorted((a, b) => b.fechaAlta.localeCompare(a.fechaAlta))
  .slice(0, 4)
  .map(tarjetaProducto)
  .join('');

Portada: cabecera, gran bloque con degradado de azul a naranja y el título, cuatro cifras, categorías y novedades

Figura 3. Portada en modo claro. La franja de cifras se monta sobre el degradado con un margen negativo.

Degradados 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4), bg-linear-to-br se llama bg-gradient-to-br. Si usas la LTS: renómbrala; from-*, via-* y to-* no cambian.

El degradado cambia en modo oscuro solo con añadir dark:from-*, dark:via-* y dark:to-*. Es uno de los casos excepcionales que no merecen un token.

Sombra de texto 🆕 v4.1

🆕 Desde Tailwind 4.1. No existe en la LTS (Tailwind 3.4). Si usas la LTS: usa un valor arbitrario como [text-shadow:0_1px_2px_rgb(0_0_0/0.25)]; el resultado es parecido, no idéntico.

text-shadow-sm despega el título del degradado, justo donde este se aclara.

Fondo de puntos con máscara 🆕 v4.1

🆕 Desde Tailwind 4.1. mask-b-from-* no existe en la LTS (Tailwind 3.4), y bg-size-[...] tampoco. Si usas la LTS: escribe [mask-image:linear-gradient(to_bottom,black_20%,transparent)] y bg-[length:1.5rem_1.5rem].

La trama es un radial-gradient repetido, y mask-b-from-20% la funde hacia abajo. isolate en la sección crea un contexto de apilamiento, para que -z-10 deje la trama detrás del texto pero no detrás del degradado. Lleva aria-hidden porque es pura decoración.

⚠️ Importante: Al montar esta página, las cifras se quedaban cortadas por arriba, tapadas por el degradado. El margen negativo era correcto; el problema es el orden de pintura: un elemento posicionado se pinta encima de uno sin posicionar, y la sección del degradado lleva relative. La solución fue añadir relative también a la sección de las cifras, que va después en el documento. Cuando un -mt-* parezca no funcionar, sospecha del apilamiento antes que del margen.

Rejillas sin puntos de ruptura sobrantes

#cifras pasa de dos a cuatro columnas con grid-cols-2 md:grid-cols-4, y md:divide-x solo dibuja separadores cuando están en fila.

Portada en modo oscuro con degradado más profundo y las cifras sobre fondo oscuro

Figura 4. La misma página con el sistema en modo oscuro. Sin ninguna elección guardada, manda prefers-color-scheme.

Portada en móvil: todos los bloques apilados en una sola columna

Figura 5. A 390 píxeles todo se apila. Ningún bloque necesitó código específico para móvil: se escribe primero la versión estrecha y se amplía con sm: y md:.


catalogo.html:

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Catálogo · EMPRESA</title>
  <script>document.documentElement.classList.toggle('dark', localStorage.tema ? localStorage.tema === 'oscuro' : matchMedia('(prefers-color-scheme: dark)').matches)</script>
  <link rel="stylesheet" href="/src/app.css">
  <script type="module" src="/src/catalogo.js"></script>
</head>
<body class="flex min-h-dvh flex-col bg-fondo font-sans text-tinta antialiased">
  <div id="cabecera"></div>

  <main class="pagina flex-1 py-8">
    <h1 class="text-3xl font-bold tracking-tight">Catálogo</h1>

    <form id="filtros" class="tarjeta mt-6 flex flex-col gap-4 p-4">
      <fieldset id="chips" class="flex flex-wrap gap-2">
        <legend class="sr-only">Categoría</legend>
      </fieldset>
      <div class="grid gap-3 sm:grid-cols-[1fr_auto_auto] sm:items-center">
        <label class="block">
          <span class="sr-only">Buscar</span>
          <input id="busqueda" type="search" placeholder="Buscar por nombre o descripción" class="campo">
        </label>
        <label class="block">
          <span class="sr-only">Ordenar</span>
          <select id="orden" class="campo">
            <option value="nombre">Nombre</option>
            <option value="barato">Precio: de menor a mayor</option>
            <option value="caro">Precio: de mayor a menor</option>
            <option value="nuevo">Más recientes</option>
          </select>
        </label>
        <label class="flex items-center gap-2 text-sm whitespace-nowrap">
          <input id="solo-stock" type="checkbox" class="size-4 accent-marca-600">
          Solo disponibles
        </label>
      </div>
    </form>

    <p id="recuento" class="mt-6 text-sm text-tenue" aria-live="polite"></p>
    <div id="lista" class="mt-3 grid gap-5 sm:grid-cols-2 lg:grid-cols-3"></div>
    <p id="vacio" hidden class="tarjeta mt-3 p-10 text-center text-tenue">
      Ningún producto coincide con los filtros.
    </p>
  </main>

  <div id="pie"></div>
</body>
</html>

src/catalogo.js:

import { productos, categoriasRaiz, enCategoria } from './tienda.js';
import { iniciarPagina, tarjetaProducto } from './interfaz.js';

iniciarPagina('catalogo');

const $ = (selector) => document.querySelector(selector);

const chip = (valor, texto, marcado) => `<label
  class="cursor-pointer rounded-full border border-linea px-3 py-1.5 text-sm font-medium text-tenue transition-colors hover:border-marca-400 has-checked:border-marca-600 has-checked:bg-marca-600 has-checked:text-white has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-marca-500">
  <input type="radio" name="categoria" value="${valor}" class="sr-only" ${marcado ? 'checked' : ''}>${texto}
</label>`;

const inicial = new URLSearchParams(location.search).get('categoria') ?? '';

$('#chips').insertAdjacentHTML(
  'beforeend',
  chip('', 'Todas', inicial === '') +
    categoriasRaiz()
      .map((c) => chip(c.categoriaId, c.nombre, inicial === String(c.categoriaId)))
      .join(''),
);

const comparadores = {
  nombre: (a, b) => a.nombre.localeCompare(b.nombre, 'es'),
  barato: (a, b) => a.precio - b.precio,
  caro: (a, b) => b.precio - a.precio,
  nuevo: (a, b) => b.fechaAlta.localeCompare(a.fechaAlta),
};

function pintar() {
  const categoria = new FormData($('#filtros')).get('categoria');
  const texto = $('#busqueda').value.trim().toLowerCase();
  const soloStock = $('#solo-stock').checked;

  const visibles = productos
    .filter((p) => !categoria || enCategoria(p, Number(categoria)))
    .filter((p) => `${p.nombre} ${p.descripcion}`.toLowerCase().includes(texto))
    .filter((p) => !soloStock || (p.stock > 0 && !p.descatalogado))
    .toSorted(comparadores[$('#orden').value]);

  $('#lista').innerHTML = visibles.map(tarjetaProducto).join('');
  $('#vacio').hidden = visibles.length > 0;
  $('#recuento').textContent = `${visibles.length} de ${productos.length} productos`;
}

$('#filtros').addEventListener('input', pintar);
$('#filtros').addEventListener('submit', (evento) => evento.preventDefault());
pintar();

Catálogo: panel de filtros con categorías en forma de píldora, buscador, orden y casilla, y debajo una rejilla de tarjetas de producto

Figura 6. Filtros arriba y rejilla de tres columnas. Las píldoras son botones de radio reales.

Píldoras con has-checked: 🔄 v4.0

🔄 Cambia en Tailwind 4.0. has-* existe en la LTS (Tailwind 3.4), pero sin el atajo. Si usas la LTS: escribe has-[:checked]:bg-marca-600 y has-[:focus-visible]:outline-2.

La <label> se pinta según el estado del <input> que contiene, y el <input> queda oculto con sr-only, no con hidden. Es importante: sr-only lo mantiene accesible con el teclado, y has-focus-visible: muestra el foco en la píldora. Con hidden no podrías llegar a ella pulsando el tabulador.

Un solo evento

Todos los controles están dentro de <form id="filtros">, y input sube por el DOM hasta él. Una sola escucha cubre el buscador, el orden, las píldoras y la casilla. FormData lee el radio marcado sin tener que recorrerlos.

La categoría se admite también en la dirección (catalogo.html?categoria=7), que es como enlaza la portada.

Catálogo en modo oscuro filtrado por Electrónica, con disponibles y orden por precio: 4 de 20 productos

Figura 7. Filtros combinados en modo oscuro. El recuento lleva aria-live="polite" para que un lector de pantalla lo anuncie.

Catálogo buscando nevera: el recuento indica 0 de 20 y aparece el mensaje de que ningún producto coincide

Figura 8. Estado vacío. Una interfaz real también se diseña para cuando no hay resultados.

#vacio usa el atributo hidden, igual que el menú. Como no lleva ninguna clase de display, aquí funciona también en 3.4.


8. La ficha de producto

producto.html:

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Producto · EMPRESA</title>
  <script>document.documentElement.classList.toggle('dark', localStorage.tema ? localStorage.tema === 'oscuro' : matchMedia('(prefers-color-scheme: dark)').matches)</script>
  <link rel="stylesheet" href="/src/app.css">
  <script type="module" src="/src/ficha.js"></script>
</head>
<body class="flex min-h-dvh flex-col bg-fondo font-sans text-tinta antialiased">
  <div id="cabecera"></div>

  <main class="pagina flex-1 py-8">
    <nav id="migas" aria-label="Ruta" class="text-sm text-tenue"></nav>

    <div class="mt-6 grid gap-8 md:grid-cols-2">
      <div id="imagen" class="group self-start overflow-hidden rounded-2xl border border-linea"></div>

      <section>
        <div id="insignia"></div>
        <h1 id="nombre" class="mt-3 text-3xl font-bold tracking-tight text-balance sm:text-4xl"></h1>
        <p id="descripcion" class="mt-3 text-lg text-tenue"></p>
        <p id="precio" class="mt-6 text-4xl font-bold tabular-nums"></p>
        <p class="text-sm text-tenue">IVA incluido</p>

        <form id="compra" class="mt-6 flex items-end gap-3 print:hidden">
          <label class="block w-24 text-sm font-medium">
            Unidades
            <input id="unidades" type="number" min="1" value="1" required
                   class="campo mt-1 tabular-nums user-invalid:border-rose-500 user-invalid:ring-2 user-invalid:ring-rose-500/30">
          </label>
          <button id="boton-comprar" class="boton boton-primario flex-1 py-2.5">Añadir al carrito</button>
        </form>

        <dl id="detalles" class="mt-8 divide-y divide-linea border-y border-linea text-sm"></dl>
      </section>
    </div>

    <section class="mt-16 print:hidden" aria-labelledby="titulo-relacionados">
      <h2 id="titulo-relacionados" class="text-2xl font-bold tracking-tight">También te puede interesar</h2>
      <div id="relacionados" class="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-4"></div>
    </section>
  </main>

  <dialog id="dialogo" aria-labelledby="titulo-dialogo"
          class="m-auto w-full max-w-sm rounded-2xl border border-linea bg-papel p-6 text-tinta shadow-tarjeta-alta transition duration-200 backdrop:bg-black/50 starting:open:translate-y-4 starting:open:opacity-0">
    <h2 id="titulo-dialogo" class="text-lg font-bold">Añadido al carrito</h2>
    <p id="texto-dialogo" class="mt-2 text-tenue"></p>
    <form method="dialog" class="mt-5 flex justify-end gap-2">
      <a href="catalogo.html" class="boton bg-hueco text-tinta">Seguir comprando</a>
      <button class="boton boton-primario">Cerrar</button>
    </form>
  </dialog>

  <div id="pie"></div>
</body>
</html>

src/ficha.js:

import {
  productos, rutaDe, raizDe, precio, fecha, unidadesVendidas, anadirAlCarrito,
} from './tienda.js';
import {
  iniciarPagina, imagenProducto, insigniaStock, tarjetaProducto, pintarCarrito,
} from './interfaz.js';

iniciarPagina('catalogo');

const $ = (selector) => document.querySelector(selector);

const id = Number(new URLSearchParams(location.search).get('id'));
const producto = productos.find((p) => p.productoId === id) ?? productos[0];
const disponible = producto.stock > 0 && !producto.descatalogado;

document.title = `${producto.nombre} · EMPRESA`;

const pasos = rutaDe(producto.categoriaId)
  .map((c) => `<a class="hover:text-tinta hover:underline" href="catalogo.html?categoria=${raizDe(c.categoriaId).categoriaId}">${c.nombre}</a>`)
  .join('<span class="mx-2">/</span>');
$('#migas').innerHTML = `<a class="hover:text-tinta hover:underline" href="catalogo.html">Catálogo</a><span class="mx-2">/</span>${pasos}`;

$('#imagen').innerHTML = imagenProducto(producto, 'aspect-4/3', 'size-32');
$('#insignia').innerHTML = insigniaStock(producto);
$('#nombre').textContent = producto.nombre;
$('#descripcion').textContent = producto.descripcion;
$('#precio').textContent = precio(producto.precio);

const detalles = [
  ['Referencia', `#${String(producto.productoId).padStart(4, '0')}`],
  ['Categoría', rutaDe(producto.categoriaId).map((c) => c.nombre).join(' › ')],
  ['En catálogo desde', fecha(producto.fechaAlta)],
  ['Unidades en almacén', producto.stock],
  ['Unidades vendidas', unidadesVendidas(producto.productoId)],
];

$('#detalles').innerHTML = detalles
  .map(([nombre, valor]) => `<div class="flex justify-between gap-4 py-2.5">
    <dt class="text-tenue">${nombre}</dt>
    <dd class="text-end font-medium tabular-nums">${valor}</dd>
  </div>`)
  .join('');

$('#unidades').max = producto.stock;
$('#unidades').disabled = !disponible;
$('#boton-comprar').disabled = !disponible;
if (!disponible) $('#boton-comprar').textContent = 'No disponible';

$('#compra').addEventListener('submit', (evento) => {
  evento.preventDefault();
  const unidades = Number($('#unidades').value);
  anadirAlCarrito(producto.productoId, unidades);
  pintarCarrito();
  $('#texto-dialogo').textContent = `${unidades} × ${producto.nombre}: ${precio(unidades * producto.precio)}`;
  $('#dialogo').showModal();
});

// Relacionados: productos activos de la misma categoría raíz
const raiz = raizDe(producto.categoriaId).categoriaId;
$('#relacionados').innerHTML = productos
  .filter((p) => p.productoId !== id && !p.descatalogado && raizDe(p.categoriaId).categoriaId === raiz)
  .slice(0, 4)
  .map(tarjetaProducto)
  .join('');

Ficha de la Torre Workstation: imagen a la izquierda; a la derecha insignia, nombre, precio, compra y datos; debajo, productos relacionados

Figura 9. Dos columnas desde md:. self-start impide que la imagen se estire hasta la altura de la columna derecha.

Sin self-start, el borde de #imagen se estiraba hasta la altura de la columna de datos y dejaba un hueco vacío bajo el dibujo: en una rejilla, los elementos se estiran por defecto.

Hay un solo HTML para todos los productos. ficha.js lee ?id= y rellena los huecos. Con textContent los datos no se interpretan nunca como HTML; innerHTML solo se usa con plantillas propias y datos de confianza. Si los datos vinieran de usuarios tendrías que escaparlos.

Producto no disponible

Un producto agotado o descatalogado no se oculta, porque alguien puede llegar por un enlace antiguo. Su botón pasa a disabled, y disabled:opacity-50 disabled:cursor-not-allowed en .boton hace el resto. enabled:hover:bg-marca-700 evita que un botón desactivado reaccione al ratón.

Ficha en móvil y modo oscuro del Altavoz Inteligente: insignia Descatalogado y botón No disponible atenuado

Figura 10. Producto descatalogado, en móvil y modo oscuro.

Validación con user-invalid: 🆕 v4.1

🆕 Desde Tailwind 4.1. No existe en la LTS (Tailwind 3.4). Si usas la LTS: usa la variante arbitraria [&:user-invalid]:border-rose-500, que compila igual; con invalid: el campo saldría en rojo antes de que el usuario lo toque.

max se fija desde JavaScript con el stock real. Si pides nueve unidades de una torre con cuatro en almacén, el campo se marca al salir de él:

Ficha con el campo Unidades marcado en rojo porque contiene 9 y solo quedan 4

Figura 11. user-invalid: espera a que el usuario interactúe. El navegador impide además enviar el formulario.

Diálogo con entrada animada 🆕 v4.0

🆕 Desde Tailwind 4.0. starting: no existe en la LTS (Tailwind 3.4). Si usas la LTS: quita las clases starting:open:*. No hay equivalente sencillo: el diálogo aparecerá sin transición, aunque seguirá funcionando igual.

showModal() da gratis el foco atrapado, el cierre con Escape y el fondo ::backdrop, que se estiliza con backdrop:bg-black/50. <form method="dialog"> cierra sin JavaScript. m-auto es imprescindible, porque Preflight quita el margen que centraba el diálogo. starting:open: define de dónde parte la transición al abrirse.

Diálogo centrado con el texto 2 por Portátil UltraBook 14: 1798,00 euros, con la página oscurecida detrás y el contador del carrito en 2

Figura 12. El contador de la cabecera se actualiza con pintarCarrito() antes de abrir el diálogo.


9. El panel de control

Es la página más densa, y la que mejor demuestra que Tailwind no se queda en tarjetas bonitas.

panel.html:

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Panel de control · EMPRESA</title>
  <script>document.documentElement.classList.toggle('dark', localStorage.tema ? localStorage.tema === 'oscuro' : matchMedia('(prefers-color-scheme: dark)').matches)</script>
  <link rel="stylesheet" href="/src/app.css">
  <script type="module" src="/src/panel.js"></script>
</head>
<body class="flex min-h-dvh flex-col bg-fondo font-sans text-tinta antialiased">
  <div id="cabecera"></div>

  <div class="pagina flex-1 gap-8 py-8 lg:grid lg:grid-cols-[13rem_minmax(0,1fr)]">
    <aside class="lg:sticky lg:top-24 lg:self-start print:hidden">
      <nav aria-label="Secciones del panel"
           class="flex gap-1 overflow-x-auto pb-3 scrollbar-none *:shrink-0 *:rounded-lg *:px-3 *:py-2 *:text-sm *:font-medium *:text-tenue *:hover:bg-hueco *:hover:text-tinta lg:flex-col lg:pb-0">
        <a href="#resumen">Resumen</a>
        <a href="#ventas">Ventas</a>
        <a href="#pedidos">Pedidos</a>
        <a href="#almacen">Almacén</a>
        <a href="#clientes">Clientes</a>
      </nav>
    </aside>

    <main class="flex min-w-0 flex-col gap-8">
      <h1 class="text-3xl font-bold tracking-tight">Panel de control</h1>

      <section id="resumen" aria-label="Resumen" class="scroll-mt-24">
        <div id="indicadores" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"></div>
      </section>

      <section id="ventas" class="grid scroll-mt-24 grid-cols-1 gap-4 lg:grid-cols-3">
        <figure class="tarjeta p-5 lg:col-span-2">
          <figcaption class="font-semibold">Facturación mensual</figcaption>
          <p class="text-sm text-tenue">Agosto de 2025 a julio de 2026, sin pedidos cancelados</p>
          <ol id="columnas" class="mt-10 grid h-56 grid-cols-12 items-end gap-1 border-b border-linea sm:gap-2"></ol>
          <div id="etiquetas" aria-hidden="true" class="mt-1 grid grid-cols-12 gap-1 text-center text-xs text-tenue sm:gap-2"></div>
        </figure>

        <figure class="tarjeta flex flex-col p-5">
          <figcaption class="font-semibold">Pedidos por estado</figcaption>
          <div class="mt-4 flex flex-1 flex-col items-center justify-center gap-5 sm:flex-row lg:flex-col">
            <div id="anillo" role="img"
                 class="relative grid size-40 shrink-0 place-items-center rounded-full bg-(image:--reparto)">
              <div class="grid size-26 place-items-center rounded-full bg-papel text-center">
                <p id="total-pedidos" class="text-3xl leading-none font-bold tabular-nums"></p>
              </div>
            </div>
            <ul id="leyenda" class="grid gap-1.5 text-sm"></ul>
          </div>
        </figure>
      </section>

      <section id="pedidos" class="tarjeta scroll-mt-24 overflow-hidden">
        <div class="flex flex-wrap items-center justify-between gap-3 p-5">
          <h2 class="font-semibold">Pedidos</h2>
          <label class="flex items-center gap-2 text-sm text-tenue">
            Estado
            <select id="filtro-estado" class="campo w-auto py-1">
              <option value="">Todos</option>
            </select>
          </label>
        </div>
        <div class="max-h-96 overflow-auto border-t border-linea scrollbar-thin scrollbar-thumb-marca-300 dark:scrollbar-thumb-marca-700">
          <table class="w-full min-w-96 text-sm">
            <thead class="sticky top-0 bg-hueco text-xs tracking-wide text-tenue uppercase">
              <tr class="*:px-4 *:py-2.5 *:text-start *:font-semibold">
                <th scope="col">Pedido</th>
                <th scope="col">Cliente</th>
                <th scope="col" class="hidden sm:table-cell">Fecha</th>
                <th scope="col">Estado</th>
                <th scope="col" class="text-end!">Total</th>
              </tr>
            </thead>
            <tbody id="filas" class="divide-y divide-linea"></tbody>
          </table>
        </div>
      </section>

      <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
        <section id="almacen" class="tarjeta scroll-mt-24 p-5">
          <h2 class="font-semibold">Productos con poco stock</h2>
          <p class="text-sm text-tenue">Menos de 10 unidades, sin contar descatalogados</p>
          <ul id="bajo-stock" class="mt-4 flex flex-col gap-3"></ul>
        </section>

        <section id="clientes" class="tarjeta scroll-mt-24 p-5">
          <h2 class="font-semibold">Mejores clientes</h2>
          <p class="text-sm text-tenue">Por facturación acumulada</p>
          <ul id="mejores" class="mt-4 flex flex-col gap-3"></ul>
        </section>
      </div>
    </main>
  </div>

  <div id="pie"></div>
</body>
</html>

src/panel.js:

import {
  productos, clientes, pedidos, pedidosFacturados, clienteDe, totalPedido, precio, fecha, categoriaDe,
} from './tienda.js';
import { iniciarPagina, estados, insigniaStock } from './interfaz.js';

iniciarPagina('panel');

const $ = (selector) => document.querySelector(selector);
const facturados = pedidosFacturados();
const facturacion = facturados.reduce((suma, p) => suma + totalPedido(p.pedidoId), 0);
const entero = (n) => new Intl.NumberFormat('es-ES', { maximumFractionDigits: 0 }).format(n);

// Indicadores
const indicadores = [
  ['Facturación', `${entero(facturacion)} €`, `${facturados.length} pedidos válidos`],
  ['Pedido medio', precio(facturacion / facturados.length), 'Sin cancelados'],
  ['Pendientes', pedidos.filter((p) => p.estado === 'PENDIENTE').length, 'Por preparar y enviar'],
  ['Agotados', productos.filter((p) => p.stock === 0 && !p.descatalogado).length, 'Productos activos sin stock'],
];

$('#indicadores').innerHTML = indicadores
  .map(([nombre, valor, nota]) => `<article class="@container tarjeta p-5">
    <h2 class="text-sm text-tenue">${nombre}</h2>
    <p class="mt-1 text-2xl font-bold tabular-nums @3xs:text-3xl">${valor}</p>
    <p class="mt-1 text-xs text-tenue">${nota}</p>
  </article>`)
  .join('');

// Gráfico de columnas: la altura es una variable CSS, no una clase por valor
const meses = Array.from({ length: 12 }, (_, i) => {
  const dia = new Date(Date.UTC(2025, 7 + i, 1));
  return {
    clave: dia.toISOString().slice(0, 7),
    inicial: dia.toLocaleDateString('es-ES', { month: 'narrow', timeZone: 'UTC' }),
    nombre: dia.toLocaleDateString('es-ES', { month: 'long', year: 'numeric', timeZone: 'UTC' }),
    total: 0,
  };
});

for (const pedido of facturados) {
  const mes = meses.find((m) => pedido.fechaPedido.startsWith(m.clave));
  if (mes) mes.total += totalPedido(pedido.pedidoId);
}

const maximo = Math.max(...meses.map((m) => m.total));

$('#columnas').innerHTML = meses
  .map((m) => `<li class="group relative flex h-full flex-col justify-end">
    <div tabindex="0" style="--alto: ${((m.total / maximo) * 100).toFixed(1)}%"
         class="h-(--alto) min-h-px rounded-t-md bg-linear-to-t from-marca-600 to-marca-400 transition-opacity group-hover:opacity-80 focus-visible:outline-2 focus-visible:outline-acento-500">
      <span class="sr-only">${m.nombre}: </span>
      <span class="pointer-events-none absolute start-1/2 bottom-full z-10 -translate-x-1/2 rounded-md bg-tinta px-2 py-1 text-xs font-medium whitespace-nowrap text-fondo opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
        ${entero(m.total)} €
      </span>
    </div>
  </li>`)
  .join('');

$('#etiquetas').innerHTML = meses.map((m) => `<span>${m.inicial}</span>`).join('');

// Anillo: conic-gradient calculado, pasado por variable
const cuentas = Object.keys(estados).map((clave) => ({
  ...estados[clave],
  clave,
  cuenta: pedidos.filter((p) => p.estado === clave).length,
}));

let acumulado = 0;
const tramos = cuentas.map(({ color, cuenta }) => {
  const desde = (acumulado / pedidos.length) * 100;
  acumulado += cuenta;
  return `${color} ${desde}% ${(acumulado / pedidos.length) * 100}%`;
});

$('#anillo').style.setProperty('--reparto', `conic-gradient(${tramos.join(', ')})`);
$('#anillo').setAttribute('aria-label',
  cuentas.map((e) => `${e.texto}: ${e.cuenta}`).join(', '));
$('#total-pedidos').textContent = pedidos.length;

$('#leyenda').innerHTML = cuentas
  .map(({ punto, texto, cuenta }) => `<li class="flex items-center gap-2">
    <span class="size-3 rounded-full ${punto}"></span>
    <span>${texto}</span>
    <strong class="ms-auto ps-4 tabular-nums">${cuenta}</strong>
  </li>`)
  .join('');

// Tabla de pedidos, con el más reciente arriba
const fila = (p) => `<tr class="transition-colors odd:bg-papel even:bg-fondo hover:bg-marca-50 dark:hover:bg-marca-950">
  <td class="px-4 py-2.5 font-medium tabular-nums">${p.pedidoId}</td>
  <td class="max-w-44 truncate px-4 py-2.5">${clienteDe(p.clienteId).nombre}</td>
  <td class="hidden px-4 py-2.5 whitespace-nowrap text-tenue sm:table-cell">${fecha(p.fechaPedido)}</td>
  <td class="px-4 py-2.5"><span class="insignia ${estados[p.estado].tono}">${estados[p.estado].texto}</span></td>
  <td class="px-4 py-2.5 text-end whitespace-nowrap tabular-nums">${precio(totalPedido(p.pedidoId))}</td>
</tr>`;

function pintarPedidos() {
  const filtro = $('#filtro-estado').value;
  $('#filas').innerHTML = pedidos
    .filter((p) => !filtro || p.estado === filtro)
    .toReversed()
    .map(fila)
    .join('');
}

$('#filtro-estado').insertAdjacentHTML('beforeend',
  cuentas.map((e) => `<option value="${e.clave}">${e.texto}</option>`).join(''));
$('#filtro-estado').addEventListener('input', pintarPedidos);
pintarPedidos();

// Barras horizontales: mismo truco que las columnas, con el ancho
const barra = (porcentaje, color) => `<div class="mt-1.5 h-2 overflow-hidden rounded-full bg-hueco">
  <div style="--valor: ${porcentaje.toFixed(1)}%" class="h-full w-(--valor) rounded-full ${color}"></div>
</div>`;

$('#bajo-stock').innerHTML = productos
  .filter((p) => p.stock < 10 && !p.descatalogado)
  .toSorted((a, b) => a.stock - b.stock)
  .map((p) => `<li>
    <div class="flex items-center justify-between gap-3 text-sm">
      <a class="min-w-0 truncate hover:underline" href="producto.html?id=${p.productoId}">${p.nombre}
        <span class="text-tenue">· ${categoriaDe(p.categoriaId).nombre}</span></a>
      ${insigniaStock(p)}
    </div>
    ${barra((p.stock / 10) * 100, p.stock < 5 ? 'bg-rose-500' : 'bg-amber-500')}
  </li>`)
  .join('');

const ranking = clientes
  .map((c) => ({
    ...c,
    total: facturados
      .filter((p) => p.clienteId === c.clienteId)
      .reduce((suma, p) => suma + totalPedido(p.pedidoId), 0),
  }))
  .toSorted((a, b) => b.total - a.total)
  .slice(0, 5);

$('#mejores').innerHTML = ranking
  .map((c) => `<li>
    <div class="flex justify-between gap-3 text-sm">
      <span class="min-w-0 truncate">${c.nombre} <span class="text-tenue">· ${c.ciudad}</span></span>
      <span class="font-medium tabular-nums">${entero(c.total)} €</span>
    </div>
    ${barra((c.total / ranking[0].total) * 100, 'bg-marca-500')}
  </li>`)
  .join('');

Panel de control: barra lateral, cuatro indicadores, gráfico de columnas, anillo de estados, tabla de pedidos y dos listas con barras

Figura 13. Panel a 1200 píxeles. Los números son los reales: 61.617 € en 23 pedidos válidos.

Distribución

lg:grid lg:grid-cols-[13rem_minmax(0,1fr)] da una barra lateral fija y un contenido flexible. minmax(0,1fr) y min-w-0 en <main> importan más de lo que parece: un elemento de rejilla no puede encoger por debajo de su contenido, así que una tabla ancha empujaría toda la página.

⚠️ Importante: Al montar el panel, la versión móvil salió con 405 píxeles de ancho en lugar de 390: toda la página se podía desplazar en horizontal. La causa era grid gap-4 sin grid-cols-1. Sin columnas declaradas, la columna implícita mide auto y un truncate con whitespace-nowrap la ensancha. grid-cols-1 significa minmax(0, 1fr), y eso lo arregla. Mide siempre el ancho de la página en móvil.

Debajo de lg:, la barra lateral se convierte en una fila desplazable.

Barra de navegación con scrollbar-none 🆕 v4.3

🆕 Desde Tailwind 4.3. scrollbar-none, scrollbar-thin y scrollbar-thumb-* no existen en la LTS (Tailwind 3.4). Si usas la LTS: escribe [scrollbar-width:none] y [scrollbar-width:thin]. Para el color, usa [scrollbar-color:#a4b2fb_transparent].

Variantes de hijos y su orden 🔄 v4.0

🔄 Cambia en Tailwind 4.0. *:hover:bg-hueco existe en la LTS (Tailwind 3.4), pero compila al revés: .clase:hover > * pinta todos los enlaces al pasar por encima de la barra. Si usas la LTS: escribe [&>*:hover]:bg-hueco.

*:px-3 y compañía aplican la clase a cada hijo directo. En v4 las variantes se leen de izquierda a derecha, así que *:hover: significa “cada hijo, cuando se le pasa el ratón”.

Consultas de contenedor en los indicadores

Cada indicador es un @container, y @3xs:text-3xl aumenta la cifra solo si la tarjeta mide al menos 16 rem. A 1200 píxeles, con cuatro en fila, la cifra se queda en text-2xl; en móvil, con la tarjeta entera, crece. Un punto de ruptura daría el resultado contrario.

Gráfico de columnas con una variable CSS 🔄 v4.0

🔄 Cambia en Tailwind 4.0. La sintaxis h-(--alto) no existe en la LTS (Tailwind 3.4). Si usas la LTS: escribe h-[var(--alto)] y w-[var(--valor)]. El mecanismo es el mismo.

El problema de un gráfico es que sus alturas se conocen al ejecutar, y Tailwind trabaja al compilar. La solución es separar las dos cosas:

Esquema: panel.js calcula el porcentaje, el atributo style crea la variable alto, la clase h-(--alto) la lee y la barra toma su altura; debajo, aviso de que h-[${porcentaje}%] no funciona

Figura 14. La clase es fija y se genera al compilar. Lo que cambia es la variable.

La estructura es una rejilla de doce columnas con items-end. Cada <li> llena la altura con h-full, y dentro la barra toma su porcentaje de ella. El valor exacto aparece al pasar el ratón gracias a group-hover:opacity-100.

Un gráfico debe ser legible sin verlo. El nombre del mes va en sr-only, y la cifra permanece en el árbol de accesibilidad porque opacity-0 no la retira. tabindex="0" y group-focus-within: dan al teclado lo mismo que al ratón.

Gráfico de columnas con el mes de enero resaltado y la etiqueta 8045 euros encima, junto al anillo de estados

Figura 15. Enero de 2026 destaca por el pedido 5016, de 8045,05 €. Son datos reales, y por eso varios meses quedan casi a cero: agosto y diciembre de 2025 no tienen pedidos, y min-h-px deja una línea como marca.

Anillo con conic-gradient 🔄 v4.0

🔄 Cambia en Tailwind 4.0. bg-(image:--reparto) no existe en la LTS (Tailwind 3.4), y tampoco las variables --color-* del tema. Si usas la LTS: escribe bg-[image:var(--reparto)] y pon colores hexadecimales en estados, o el anillo no se pintará.

Un conic-gradient con cortes secos es un gráfico de sectores. panel.js acumula porcentajes y genera algo así:

conic-gradient(
  var(--color-emerald-500) 0% 75%,
  var(--color-marca-500) 75% 83.33%,
  var(--color-amber-500) 83.33% 95.83%,
  var(--color-rose-500) 95.83% 100%
)

Un círculo interior bg-papel convierte el sector en anillo, y al usar un token funciona en ambos temas. role="img" con aria-label da la información completa a quien no ve el gráfico.

Recuerda escribir bg-(image:--reparto) y no bg-(--reparto): como viste en el módulo 05, un degradado necesita la pista image: o no se pinta nada.

Tabla con cabecera fija

La tabla usa un doble desplazamiento: max-h-96 overflow-auto en el envoltorio y sticky top-0 en <thead>. Las columnas prescindibles se esconden en móvil con hidden sm:table-cell, y text-end! vence a *:text-start en la columna de importes.

💡 Consejo: Pon el overflow en un envoltorio y nunca en la <table>. Lo mismo vale para @container, que no responde sobre una tabla, como comprobaste en el módulo 10.

odd: y even: alternan fondos usando tokens. El filtro por estado vuelve a pintar el <tbody>.

Panel completo en modo oscuro

Figura 16. Modo oscuro. Casi nada de esta página lleva dark:: tarjetas, tabla y barras cambian por los *tokens. Las pocas clases dark: que quedan son las de las insignias, la fila bajo el ratón y la barra de desplazamiento.*

Panel a 390 píxeles: pestañas en fila, indicadores apilados, gráfico, anillo, tabla sin la columna de fecha ni total visibles sin desplazar, y listas

Figura 17. Panel en móvil. La tabla se desplaza dentro de su caja, y la página ya no se sale de los 390 píxeles.


10. Lista de comprobación

Antes de dar una interfaz por terminada, repasa esto. La tercera columna cuenta lo que salió en este proyecto:

Comprobación Cómo Qué encontró aquí
Ancho móvil Mide la página a 390 píxeles Desbordamiento de 15 píxeles en el panel
Apilamiento Mira lo que se solapa Cifras tapadas en la portada
Estados vacíos Busca algo que no exista Mensaje de catálogo vacío
Datos extremos Usa el valor más largo Insignia partida en dos líneas: whitespace-nowrap
Modo oscuro Revisa cada página Sin cambios
Teclado Recorre con el tabulador Repásalo tú: el foco debe verse en píldoras y columnas
Producción Compila y abre dist Sin cambios

11. Compilar para producción

npm run build

Salida real, partiendo del ZIP recién descomprimido:

dist/catalogo.html                  2.24 kB │ gzip: 1.02 kB
dist/index.html                     2.90 kB │ gzip: 1.28 kB
dist/producto.html                  2.91 kB │ gzip: 1.26 kB
dist/panel.html                     4.89 kB │ gzip: 1.77 kB
dist/assets/interfaz-DkJib-LF.css  45.68 kB │ gzip: 8.30 kB
dist/assets/portada-R6tvW2WD.js     1.22 kB │ gzip: 0.67 kB
dist/assets/catalogo-BlUx6hLu.js    1.48 kB │ gzip: 0.82 kB
dist/assets/producto-Bg2XYlQt.js    1.82 kB │ gzip: 0.94 kB
dist/assets/panel-CC2mC5_I.js       4.69 kB │ gzip: 2.12 kB
dist/assets/interfaz-MWNy7eAw.js   19.91 kB │ gzip: 5.95 kB
✓ built in 1.96s

Las cuatro páginas comparten un único CSS de 8,3 kB comprimido, y npm run preview sirve dist para probarlo. Vite ha separado además interfaz-*.js, con lo común y los datos, de modo que el navegador solo lo descarga una vez.

Para publicar, sube el contenido de dist a cualquier alojamiento estático. Gracias a base: './', sirve también en una subcarpeta.


12. Qué cambia para compilarlo en Tailwind 3.4

Esta sección no es teórica: el proyecto completo se ha portado y compilado con Tailwind 3.4.19, y se han capturado sus páginas. El resultado es prácticamente idéntico:

El panel de control compilado con Tailwind 3.4.19, con el mismo aspecto que la versión 4

Figura 18. El mismo panel compilado con 3.4.19.

Paso 1: dependencias y PostCSS

"devDependencies": {
  "@tailwindcss/container-queries": "0.1.1",
  "autoprefixer": "10.4.21",
  "postcss": "8.5.6",
  "tailwindcss": "3.4.19",
  "vite": "8.3.5"
}

Quita @tailwindcss/vite del package.json y de vite.config.js, incluida la línea plugins, y crea postcss.config.js:

postcss.config.js:

export default { plugins: { tailwindcss: {}, autoprefixer: {} } };

Paso 2: tailwind.config.js

Todo lo que había en @theme pasa aquí. Los colores oklch() se han convertido a hexadecimal.

tailwind.config.js:

import containerQueries from '@tailwindcss/container-queries';

export default {
  content: ['./*.html', './src/**/*.js'],
  darkMode: 'selector',
  theme: {
    extend: {
      fontFamily: {
        sans: ['"Segoe UI"', 'system-ui', '-apple-system', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
      },
      colors: {
        marca: {
          50: '#f2f5ff', 100: '#e1e7ff', 200: '#c7d2ff', 300: '#a4b2fb', 400: '#7a8af6', 500: '#5d67ee',
          600: '#4a4edb', 700: '#3d3fbb', 800: '#2f3390', 900: '#252a69', 950: '#161944',
        },
        acento: { 400: '#ff8c53', 500: '#fb6c2b', 600: '#e44a08' },
        fondo: 'var(--fondo)',
        papel: 'var(--papel)',
        hueco: 'var(--hueco)',
        tinta: 'var(--tinta)',
        tenue: 'var(--tenue)',
        linea: 'var(--linea)',
      },
      boxShadow: {
        tarjeta: '0 1px 2px rgb(29 33 48 / 0.06), 0 4px 12px rgb(29 33 48 / 0.05)',
        'tarjeta-alta': '0 4px 8px rgb(29 33 48 / 0.08), 0 16px 32px rgb(29 33 48 / 0.12)',
      },
      animation: { aparecer: 'aparecer 0.35s ease-out both' },
      keyframes: {
        aparecer: { from: { opacity: '0', transform: 'translateY(0.5rem)' } },
      },
    },
  },
  plugins: [containerQueries],
};

Paso 3: app.css

Las tres primeras líneas sustituyen a @import "tailwindcss". Desaparecen @custom-variant y los dos @theme. El bloque @layer base se queda igual. pagina pasa a @layer components:

@tailwind base;
@tailwind components;
@tailwind utilities;

/* Aquí sigue igual el @layer base con :root y .dark */

@layer components {
  .pagina {
    @apply mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8;
  }

  /* Aquí siguen .tarjeta, .boton... con los cambios de la tabla */
}

Paso 4: sustituir clases

v4.3 v3.4 Si no lo cambias
bg-linear-to-* bg-gradient-to-* Sin degradado
h-(--alto), w-(--valor) h-[var(--alto)], w-[var(--valor)] Barras sin tamaño
bg-(image:--reparto) bg-[image:var(--reparto)] Sin anillo
var(--color-emerald-500) y similares en estados #10b981, #5d67ee, #f59e0b, #f43f5e Sin anillo, sin aviso
aspect-4/3 aspect-[4/3] Imagen sin altura
size-26 size-[6.5rem] Sin tamaño
bg-size-[1.5rem_1.5rem] bg-[length:1.5rem_1.5rem] Trama rota
text-shadow-sm [text-shadow:0_1px_2px_rgb(0_0_0/0.25)] Sin sombra
mask-b-from-20% [mask-image:linear-gradient(to_bottom,black_20%,transparent)] Trama sin fundir
scrollbar-none, scrollbar-thin [scrollbar-width:none], [scrollbar-width:thin] Barra normal
scrollbar-thumb-* Quítala o usa [scrollbar-color:...] Color del sistema
has-checked:, has-focus-visible: has-[:checked]:, has-[:focus-visible]: Píldora sin marcar
user-invalid: [&:user-invalid]: Sin borde rojo
starting:open:* Quitar Sin animación de entrada
text-end! !text-end Cabecera alineada a la izquierda
focus-visible:outline-2 focus-visible:outline focus-visible:outline-2 Sin anillo de foco
has-focus-visible:outline-2 Añade también has-[:focus-visible]:outline Sin foco en las píldoras
shadow-xs shadow-sm Sin sombra
outline-hidden outline-none Contorno del navegador
@3xs:text-3xl @[16rem]:text-3xl Cifra pequeña
*:hover:bg-hueco, *:hover:text-tinta [&>*:hover]:bg-hueco, [&>*:hover]:text-tinta Toda la barra resaltada
bg-papel/85 bg-[color-mix(in_srgb,var(--papel)_85%,transparent)] Cabecera transparente
#menu-movil, #contador-carrito Añadir [&[hidden]]:hidden Menú siempre abierto

Las filas en negrita son las peligrosas: no dan error de compilación y algunas ni siquiera se notan a primera vista.

⚠️ Importante: Tres fallos no se detectan buscando clases inexistentes. bg-papel/85 simplemente no genera nada, porque en 3.4 un color definido como var(--papel) no acepta modificador de opacidad. *:hover:bg-hueco sí compila, pero significa otra cosa. Y var(--color-emerald-500) es una cadena de JavaScript: en 3.4 esas variables no existen y el navegador descarta el degradado entero.

Lo que se pierde

  • La animación de entrada del diálogo, sin alternativa sencilla.
  • La escala de sombras de texto y las máscaras con utilidades: siguen disponibles, pero con valores arbitrarios más largos.
  • El color de la barra de desplazamiento con clases.

Lo que no se gana si migras por los navegadores

El motivo principal para elegir 3.4 son los navegadores antiguos, y aquí hay una trampa: portar las clases no basta. Este proyecto sigue usando oklch() en sus tokens y color-mix() en la cabecera, que son justo lo que esos navegadores no entienden. Si es tu caso, cambia también los tokens:

:root {
  --papel: 255 255 255;
}
colors: {
  papel: 'rgb(var(--papel) / <alpha-value>)',
}

Con esta forma bg-papel/85 vuelve a generarse en 3.4 y no necesitas color-mix(). Valores hexadecimales equivalentes:

Token Claro Oscuro
--fondo #f7f8fb #11131d
--papel #ffffff #1c1e2b
--hueco #eef0f6 #272a3a
--tinta #1d2130 #eceef5
--tenue #5e6275 #a6aabb
--linea #dfe1e9 #333748

Comparación de tamaño

Versión CSS Comprimido
Tailwind 4.3.3 45,68 kB 8,30 kB
Tailwind 3.4.19 28,94 kB 6,48 kB

En este proyecto el CSS de v4 pesa más, entre otras cosas por las variables del tema. La diferencia comprimida es de menos de 2 kB, y no debería decidir la elección.


13. Ejercicios

Todos parten del proyecto del ZIP. Las soluciones publicadas se han aplicado sobre una copia y comprobado en el navegador.

Ejercicio 1: Cambiar la marca

La empresa quiere pasar del añil a un verde azulado. Cámbialo tocando el menor número posible de líneas.

Ver solución

Las once líneas --color-marca-* comparten el matiz 275. Basta con buscar y reemplazar, solo en ellas, 275) por 195):

--color-marca-500: oklch(0.58 0.2 195);
--color-marca-600: oklch(0.51 0.21 195);

Ni el HTML ni el JavaScript cambian, porque ninguna clase nombra un color concreto: dicen marca. Los tokens no se tocan y conservan su ligero tinte azulado. Comprueba siempre el contraste después: la saturación de cada matiz varía, y un color que funcionaba con blanco encima puede dejar de hacerlo.

Portada con la marca en verde azulado

Figura 19. Toda la web cambia de color con un solo reemplazo.

Ejercicio 2: Insignia “Nuevo”

Muestra una insignia en la esquina de los productos dados de alta desde 2025.

Ver solución

En interfaz.js:

const esNuevo = (producto) => producto.fechaAlta >= '2025-01-01';

export function tarjetaProducto(producto) {
  return `<article class="@container relative motion-safe:animate-aparecer">
    ${esNuevo(producto) ? '<span class="insignia absolute start-3 top-3 z-10 bg-acento-600 text-white">Nuevo</span>' : ''}
    <a href="producto.html?id=${producto.productoId}"

El <article> necesita relative para anclar la insignia. Va fuera del enlace y lleva z-10, porque la imagen tiene su propio contexto al escalarse con group-hover:. Como las fechas están en formato ISO, compararlas como texto es correcto. Se cumple en la Tablet 10 y el Smartphone Gama Alta.

Cuatro novedades, dos de ellas con la insignia Nuevo en la esquina

Figura 20. La insignia se coloca sobre la imagen.

Ejercicio 3: Vista de lista

Añade al catálogo un botón que alterne entre rejilla y lista, sin tocar tarjetaProducto.

Ver solución

En catalogo.html, sustituye el recuento y la lista:

<div class="mt-6 flex items-center justify-between gap-3">
  <p id="recuento" class="text-sm text-tenue" aria-live="polite"></p>
  <button id="boton-vista" type="button" aria-pressed="false" class="boton bg-hueco py-1 text-sm text-tinta aria-pressed:bg-marca-600 aria-pressed:text-white">Ver en lista</button>
</div>
<div id="lista" class="mt-3 grid gap-5 sm:grid-cols-2 lg:grid-cols-3 data-[vista=lista]:grid-cols-1!"></div>

En catalogo.js:

$('#boton-vista').addEventListener('click', (evento) => {
  const enLista = $('#lista').dataset.vista !== 'lista';
  $('#lista').dataset.vista = enLista ? 'lista' : 'rejilla';
  evento.currentTarget.setAttribute('aria-pressed', String(enLista));
});

La tarjeta no cambia: @container detecta que ahora mide más de 28 rem y se pone en horizontal ella sola. Hace falta ! porque lg:grid-cols-3 va después en el CSS y ganaría. Con una consulta de pantalla no podrías lograr esto.

Catálogo en vista de lista: tarjetas horizontales a todo el ancho

Figura 21. La misma tarjeta, adaptada a su contenedor.

Ejercicio 4: Tema sepia

Añade un tercer tema, de modo que el botón recorra claro, oscuro y sepia, y la elección sobreviva a la recarga.

Ver solución

Como todo se apoya en tokens, un tema nuevo son seis valores. En app.css, antes de la utilidad:

@layer base {
  [data-tema="sepia"] {
    color-scheme: light;
    --fondo: oklch(0.96 0.02 85);
    --papel: oklch(0.985 0.012 85);
    --hueco: oklch(0.92 0.025 85);
    --tinta: oklch(0.3 0.04 60);
    --tenue: oklch(0.48 0.04 60);
    --linea: oklch(0.86 0.03 85);
  }
}

En interfaz.js:

const temas = ['claro', 'oscuro', 'sepia'];

function conectarTema() {
  const raiz = document.documentElement;
  document.querySelector('#boton-tema').addEventListener('click', () => {
    const tema = temas[(temas.indexOf(raiz.dataset.tema) + 1) % temas.length];
    raiz.dataset.tema = tema;
    raiz.classList.toggle('dark', tema === 'oscuro');
    localStorage.setItem('tema', tema);
  });
}

Y en el <head> de las cuatro páginas, el guion inicial pasa a ser:

<script>
  const tema = localStorage.tema ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'oscuro' : 'claro');
  document.documentElement.dataset.tema = tema;
  document.documentElement.classList.toggle('dark', tema === 'oscuro');
</script>

Las clases dark: siguen funcionando, porque el modo oscuro aún depende de .dark. Las insignias y los degradados del tema sepia usan los colores claros, así que no hace falta nada más.

Catálogo con tema sepia: fondo crema y tinta marrón

Figura 22. Un tema nuevo no ha requerido tocar ni una clase del marcado.

Ejercicio 5: Línea de media

Dibuja sobre el gráfico de columnas una línea discontinua con la facturación media mensual.

Ver solución

Añade relative al <ol id="columnas">, y en panel.js, tras pintar las columnas:

const media = meses.reduce((suma, m) => suma + m.total, 0) / meses.length;

$('#columnas').insertAdjacentHTML('beforeend', `<li aria-hidden="true"
  style="--media: ${((media / maximo) * 100).toFixed(1)}%"
  class="pointer-events-none absolute inset-x-0 bottom-(--media) border-t-2 border-dashed border-acento-500"></li>`);

Es el mismo truco de la variable, ahora con bottom. Al ser absolute, sale del flujo de la rejilla y no ocupa una decimotercera columna. pointer-events-none deja que el ratón llegue a las barras que tiene debajo. En 3.4 escribe bottom-[var(--media)].

Gráfico de columnas con una línea naranja discontinua que marca la media

Figura 23. La media queda por debajo de julio y al nivel de marzo.

Ejercicio 6: Ficha imprimible

Haz que la ficha se imprima bien en papel blanco aunque el visitante esté en modo oscuro.

Ver solución

El HTML ya lleva print:hidden en cabecera, pie, compra y relacionados. Falta el color, y aquí hay una trampa. Lo primero que se te ocurre es redefinir los tokens:

@layer base {
  @media print {
    :root,
    .dark {
      color-scheme: light;
      --fondo: oklch(1 0 0);
      --papel: oklch(1 0 0);
      --hueco: oklch(0.955 0.008 275);
      --tinta: oklch(0.2 0 0);
      --tenue: oklch(0.4 0 0);
      --linea: oklch(0.8 0 0);
    }
  }
}

Al probarlo, el fondo sale blanco, pero la imagen y la insignia siguen oscuras: sus clases dark: no dependen de los tokens. Hay que desactivar también la variante al imprimir, cambiando su definición:

@custom-variant dark {
  @media not print {
    &:where(.dark, .dark *) {
      @slot;
    }
  }
}

Por último, reduce la imagen con print:grid-cols-[12rem_1fr] en la rejilla. Puedes probarlo sin gastar papel desde las herramientas del navegador, emulando el medio print.

Ficha para imprimir, con la imagen pequeña en colores claros aunque la página esté en modo oscuro

Figura 24. Vista de impresión con el sistema en modo oscuro.

Ejercicio 7 (propuesto): Conectar con la API real

Arranca la API de ejemplo con node server.mjs y cambia tienda.js para leer de http://localhost:3000/api/productos, /categorias, /clientes y /pedidos. Las páginas tendrán que esperar a los datos: muestra un estado de carga. Observa que /api/pedidos/:id ya devuelve las líneas con su total.

Ejercicio 8 (propuesto): Esqueletos de carga

Continúa el anterior: mientras llegan los datos, muestra tarjetas vacías con animate-pulse y bg-hueco, y respeta motion-reduce:.

Ejercicio 9 (propuesto): Página del carrito

Crea carrito.html, añádela a rolldownOptions.input, enlaza el icono y muestra una tabla adaptable con unidades editables, subtotales y un resumen sticky.

Ejercicio 10 (propuesto): Portar los ejercicios a 3.4

Aplica la sección 12 a tu proyecto con los ejercicios anteriores resueltos. Vigila en especial aria-pressed:, data-[vista=lista]:grid-cols-1! y bottom-(--media), y comprueba el resultado con la tabla en la mano.


Resumen

Concepto API Idea clave
Proyecto de varias páginas rolldownOptions.input, base: './' Sin input, Vite solo compila index.html
Marca @theme con --color-marca-* Un matiz común permite cambiar la marca entera
Tokens semánticos @theme inline, --papel Nombran un papel; cambian con el tema
Modo oscuro @custom-variant dark, localStorage Guion en línea en <head>, para evitar el fogonazo
Utilidad propia @utility pagina Una sola responsabilidad, combinable
Componentes @layer components, @apply Solo lo muy repetido; las utilidades lo sobrescriben
Clases en JavaScript Texto completo Tailwind lee, no ejecuta
Adaptación a contenedor @container, @md: La tarjeta depende de su ancho, no de la pantalla
Altura dinámica h-(--alto), style="--alto: 40%" Clase fija y variable cambiante
Anillo conic-gradient, bg-(image:--reparto) Cortes secos y un círculo interior
Tablas overflow-auto, sticky, hidden sm:table-cell El desplazamiento va en un envoltorio
Rejilla segura grid-cols-1, min-w-0 auto deja que el contenido ensanche la página
Compilación en 3.4 tailwind.config.js, PostCSS, sustitución de clases Los fallos graves no avisan al compilar
Navegadores antiguos rgb(var(--x) / <alpha-value>), hexadecimal Portar las clases no basta: hay que quitar oklch() y color-mix()