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-varianty@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 sutailwind.config.jsy 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, sinnode_modulesnidist. Descomprímelo, entra en la carpeta y ejecutanpm installynpm run dev.
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 compilaindex.html. En desarrollo todo parece ir bien, porque el servidor sirve cualquier HTML; el error aparece al publicar, cuandocatalogo.htmlno está endist. base: './'hace que las rutas dedistsean relativas, así que la web funciona dentro de una subcarpeta como las de GitHub Pages.rolldownOptionses el nombre actual en Vite 8.rollupOptionssigue 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 que2026-07-01se muestre como 30 de junio en un navegador situado al oeste de Greenwich.
💡 Consejo:
Intl.NumberFormatcones-ESno separa los miles en números de cuatro cifras: verás1899,00 €y61.617 €. Es la regla del idioma, no un error. Si prefieres1.899,00 €, añadeuseGrouping: '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 atheme.extend.colorsen hexadecimal, porque en 3.4 un coloroklch()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 inlineno existe en la LTS (Tailwind 3.4). Si usas la LTS: declarapapel: 'var(--papel)'entheme.extend.colors. Funciona, perobg-papel/85deja de generarse sin aviso; usargb(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 usarasdark:, cada elemento llevaría el doble de clases.
Modo oscuro con @custom-variant 🔄 v4.0
🔄 Cambia en Tailwind 4.0.
@custom-variantno existe en la LTS (Tailwind 3.4). Si usas la LTS: añadedarkMode: 'selector'atailwind.config.js; las clasesdark: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.
@utilityno existe en la LTS (Tailwind 3.4). Si usas la LTS: escribe.pagina { @apply ...; }dentro de@layer components;@applyfunciona 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.csstienen trampa si vienes de la 3.4.shadow-xsyoutline-hiddenno existen allí con ese nombre, yfocus-visible:outline-2no 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:
groupen el enlace ygroup-hover:scale-110en el icono.h-fullymt-autopara alinear el pie aunque cambie la longitud del texto (módulo 08).line-clamp-2para que todas las descripciones ocupen lo mismo.tabular-numspara 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-queriesy añádelo aplugins.@containery@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
hiddenno lleva!importanten Preflight, así queflexogridlo vencen. Si usas la LTS: añade[&[hidden]]:hiddena#menu-movily#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.

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('');

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-brse llamabg-gradient-to-br. Si usas la LTS: renómbrala;from-*,via-*yto-*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), ybg-size-[...]tampoco. Si usas la LTS: escribe[mask-image:linear-gradient(to_bottom,black_20%,transparent)]ybg-[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ñadirrelativetambié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.

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

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:.
7. El catálogo
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();

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: escribehas-[:checked]:bg-marca-600yhas-[: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.

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

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('');

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.

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; coninvalid: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:

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 clasesstarting: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.

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('');

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-4singrid-cols-1. Sin columnas declaradas, la columna implícita mideautoy untruncateconwhitespace-nowrapla ensancha.grid-cols-1significaminmax(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-thinyscrollbar-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-huecoexiste 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: escribeh-[var(--alto)]yw-[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:
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.

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: escribebg-[image:var(--reparto)]y pon colores hexadecimales enestados, 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
overflowen 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>.

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.*

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:

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/85simplemente no genera nada, porque en 3.4 un color definido comovar(--papel)no acepta modificador de opacidad.*:hover:bg-huecosí compila, pero significa otra cosa. Yvar(--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.

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.

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.

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.

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

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.

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() |
| Navegación | |
|---|---|
| ← Módulo anterior: Producción y rendimiento |