skill · sumi-nuxt · v0.6.1
Oficio con Tailwind
Tailwind CSS v4 en proyectos Nuxt/Vue: configuración @theme basada en CSS alimentada por los tokens de DESIGN.md, capas de tokens semánticos, extracción de componentes en lugar de proliferación de @apply, APIs de variantes, disciplina de escala de espaciado y tipografía, modo oscuro, container queries, y cómo evitar el abuso de valores arbitrarios y la sopa de clases. Úsala al dar estilo a plantillas *.vue, al editar app/assets/css/*.css, main.css, la configuración de tailwind en nuxt.config.ts, al añadir tokens de diseño, temas o modo oscuro, o al revisar listas largas de clases.
- Referencia
- sumi-nuxt
- skill
- tailwind-craft
- source
- plugins/sumi-nuxt/skills/tailwind-craft/SKILL.md
Tailwind es un sistema de restricciones. Su valor viene de un conjunto pequeño y con nombre de tokens aplicados
de forma coherente. Los valores arbitrarios, los colores sueltos y las cadenas de 40 clases son la señal de que las
restricciones se abandonaron. Los tokens vienen de DESIGN.md (sumi-design); las reglas de capas y de nombres de CSS
vienen de sumi:css-architecture.
Principios básicos
- Primero los tokens. Cada color, fuente, radio, sombra y paso de espaciado de la interfaz se asigna a un token en
@theme. Si un valor no está en el tema, añádelo aDESIGN.mdy al tema, o no lo uses. - Semántico antes que crudo. Los componentes usan
bg-surface,text-fg-muted,border-line, nobg-zinc-50 dark:bg-zinc-900. Los pasos crudos de la paleta existen solo para definir tokens semánticos. - Extrae componentes, no clases. La repetición se resuelve con un componente de Vue (o una función de
variantes), no con copias de
@applyde cadenas de utilidades. - Una sola escala. El espaciado, la tipografía y el radio siguen la escala. Nada de
mt-[13px]. - Accesible por construcción. Estilos de focus-visible, pares de tokens con contraste comprobado, movimiento reducido.
Configuración en Nuxt (v4)
// nuxt.config.ts
import tailwindcss from '@tailwindcss/vite'
export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
vite: { plugins: [tailwindcss()] },
})
El módulo @nuxtjs/tailwindcss es una alternativa; verifica su compatibilidad con Tailwind v4 antes de elegirlo.
En la v4 no hay un tailwind.config.js por defecto; la configuración vive en el CSS.
Tema a partir de DESIGN.md
/* app/assets/css/main.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* raw tokens copied from DESIGN.md */
--font-sans: "<Body family from DESIGN.md>", ui-sans-serif, system-ui, sans-serif;
--font-display: "Fraunces Variable", ui-serif, serif;
--color-ink-950: oklch(0.18 0.02 260);
--color-ink-50: oklch(0.98 0.005 260);
--color-brand-600: oklch(0.55 0.17 35);
--radius-container: 0.75rem;
--ease-enter: cubic-bezier(0.22, 1, 0.36, 1);
}
/* semantic layer: switches with theme, consumed by components */
@theme inline {
--color-surface: var(--surface);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-line: var(--line);
--color-accent: var(--accent);
}
:root { --surface: var(--color-ink-50); --fg: var(--color-ink-950); --fg-muted: oklch(0.45 0.02 260);
--line: oklch(0.9 0.01 260); --accent: var(--color-brand-600); }
.dark { --surface: var(--color-ink-950); --fg: var(--color-ink-50); --fg-muted: oklch(0.75 0.02 260);
--line: oklch(0.3 0.02 260); --accent: oklch(0.7 0.15 35); }
- Reemplaza los espacios de nombres por defecto de forma deliberada (
--color-*: initial;) cuando la paleta de la marca deba ser la única paleta; así dejan de compilarse losbg-blue-500sueltos. - Cada par fg/superficie debe cumplir el contraste WCAG 2.2 AA (4.5:1 para texto, 3:1 para texto grande y la interfaz). Comprueba ambos temas.
- Los nombres de los tokens reflejan los roles de
DESIGN.mdmediante la tabla de alias de Tailwind desumi-design:design-tokens(text→fg,text-muted→fg-muted,border→line,accent-contrast→accent-fg, de modo que las utilidades se leantext-fg, notext-text); nunca inventes nombres en los componentes. Detalles:references/tokens-and-variants.md.
Disciplina de clases
<!-- BAD: class soup, raw colors, arbitrary values, duplicated in 6 places -->
<button class="inline-flex items-center justify-center gap-[6px] rounded-[10px] bg-[#e4572e]
px-[18px] py-[9px] text-[15px] font-semibold text-white shadow-[0_2px_8px_rgba(0,0,0,.15)]
hover:bg-[#c94a25] dark:bg-[#ff7a50] focus:outline-none">Save</button>
<!-- GOOD: one component owns the recipe, tokens only -->
<UiButton variant="primary" size="md">Save</UiButton>
- Ordena las clases de forma coherente (composición, caja, tipografía, visual, estado); usa el plugin de Tailwind para Prettier.
- Más de ~12 utilidades en un elemento, repetidas en 2 o más lugares: extrae un componente.
- Variantes mediante una función tipada (
cvaotailwind-variants) dentro del componente; combina las clases del consumidor contailwind-mergepara que las sobrescrituras ganen de forma predecible. @applysolo para dar estilo a marcado que no controlas (contenido de CMS o markdown, widgets de terceros). En bloques<style>de SFC, usa@reference "~/assets/css/main.css";antes de@apply.- Utilidades personalizadas con
@utilitypara reglas realmente reutilizables y de un solo propósito (por ejemplo,@utility text-balance-safe { ... }), no para recetas de componentes. - Los valores arbitrarios (
w-[37rem]) se permiten una vez para un caso realmente puntual, con un comentario; un segundo uso significa que es un token. - Las clases dinámicas deben ser cadenas completas en el código fuente (
variant === 'primary' ? 'bg-accent' : 'bg-surface'). Nunca construyas nombres de clase por concatenación (bg-${color}-500); el escáner no puede verlos.
Composición y adaptabilidad
- Primero móvil: clases base para pantallas pequeñas,
md:/lg:añaden complejidad hacia arriba. - Prefiere container queries para los componentes que viven en columnas de distinto ancho:
@containeren el contenedor,@md:grid-cols-2en los hijos. Puntos de corte del viewport para la composición de la página. - Usa
gapen lugar de márgenes entre hermanos;space-*solo para pilas simples. - Tipografía fluida con
clamp()definida como tokens (--text-display), no improvisada en las plantillas. - Respeta el contenido: anchos basados en
max-w-prose/chpara el texto de lectura.
Estados, movimiento y modo oscuro
- Todo elemento interactivo: estilos
hover:,focus-visible:(anillo visible con desfase),active:,disabled:/aria-disabled:. Nunca elimines los contornos sin un reemplazo. - Da estilo según los atributos de estado:
aria-expanded:,aria-selected:,data-[state=open]:(bibliotecas headless). motion-safe:para transiciones y animaciones; duraciones y curvas a partir de tokens.- Modo oscuro mediante la estrategia de clase, con la preferencia guardada en una cookie o en
@nuxtjs/color-mode(evita el parpadeo de hidratación, consultanuxt-data-ssr). Los tokens semánticos hacen que los componentes no necesiten clasesdark:.
Antipatrones (señales de relleno de IA)
- Códigos hexadecimales y valores arbitrarios por todas partes (
text-[#333],p-[13px],shadow-[...]). - El hero genérico con degradado de morado a azul,
rounded-2xl shadow-xlen cada tarjeta, glassmorphism por defecto: un estilo que ignoraDESIGN.md. - Duplicados
dark:en cada elemento en lugar de tokens semánticos. @applyrecreando Bootstrap (.btn,.card,.container) para marcado que es tuyo.- Cadenas de 30 clases copiadas y pegadas entre archivos; nombres de clase concatenados como cadenas.
- Espaciado mezclado (
p-3,p-[14px],p-3.5,p-4) en componentes hermanos. focus:outline-nonesin un reemplazo confocus-visible; texto de cuerpotext-gray-400de bajo contraste.- Conservar un
tailwind.config.jsde la v3 con tema en JS junto a la configuración CSS de la v4 "por si acaso".
Lista de verificación
- Todos los colores, fuentes, radios, sombras y curvas salen de tokens
@themerastreables hastaDESIGN.md. - Los componentes usan tokens semánticos; el modo oscuro funciona sin clases
dark:por elemento. - Sin valores arbitrarios salvo casos puntuales documentados; sin nombres de clase concatenados.
- Las recetas repetidas están extraídas en componentes con variantes tipadas;
@applysolo para marcado ajeno. - Estados de focus-visible, hover, deshabilitado y movimiento reducido presentes; contraste AA verificado en ambos temas.
- La composición usa container queries donde los componentes se reutilizan en distintos anchos.
- Tamaño de la salida CSS comprobado; sin paletas sin usar en el envío (
sumi:performance).
Tokens y componentes con variantes
Correspondencia de DESIGN.md con @theme
DESIGN.md (generado por sumi-design) es la fuente de verdad. Refleja sus grupos de tokens en los espacios de nombres
de Tailwind para que las utilidades se generen automáticamente:
| Grupo de DESIGN.md | Espacio de nombres de @theme | Genera |
|---|---|---|
| Colores (crudos) | --color-* |
bg-*, text-*, border-*, fill-*... |
| Familias tipográficas | --font-* |
font-* |
| Escala tipográfica | --text-* (+ --text-*--line-height) |
text-* |
| Base de espaciado | --spacing |
p-4, gap-6 = múltiplos de la base |
| Radios | --radius-* |
rounded-* |
| Sombras / elevación | --shadow-* |
shadow-* |
| Curvas | --ease-* |
ease-* |
| Puntos de corte | --breakpoint-* |
sm:, md:... |
| Tamaños de contenedor | --container-* |
@sm:, max-w-* |
Los nombres de espacios de nombres anteriores reflejan las convenciones de Tailwind v4; verifica con la documentación vigente al añadir un grupo menos habitual.
Reglas:
- Mantén pequeña la paleta cruda (marca, rampa neutra, colores de retroalimentación). Los tokens semánticos la referencian.
- Si el diseño necesita un valor que no está en
DESIGN.md, actualiza primeroDESIGN.mdy luego el tema. - Nombres: los tokens semánticos describen el rol (
surface,surface-raised,fg,fg-muted,line,accent,accent-fg,danger,focus), no la apariencia (light-gray).
Escala tipográfica con alturas de línea
@theme {
--text-body: 1rem;
--text-body--line-height: 1.6;
--text-h2: clamp(1.5rem, 1.2rem + 1.2vw, 2.125rem);
--text-h2--line-height: 1.2;
--text-h2--letter-spacing: -0.01em;
}
Uso: text-h2, text-body. Los encabezados toman su tamaño de los tokens, nunca de text-[34px].
Componente con variantes con cva + tailwind-merge
// app/utils/cn.ts
import { twMerge } from 'tailwind-merge'
import { clsx, type ClassValue } from 'clsx'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
<!-- app/components/ui/Button.vue -> <UiButton> -->
<script setup lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
const button = cva(
'inline-flex items-center justify-center gap-2 rounded-card font-medium transition-colors ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus ' +
'disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none',
{
variants: {
variant: {
primary: 'bg-accent text-accent-fg hover:bg-accent/90',
secondary: 'border border-line bg-surface text-fg hover:bg-surface-raised',
ghost: 'text-fg hover:bg-surface-raised',
},
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-6 text-lg' },
},
defaultVariants: { variant: 'primary', size: 'md' },
},
)
type ButtonVariants = VariantProps<typeof button>
const { variant, size, class: className, type = 'button' } = defineProps<{
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: string
type?: 'button' | 'submit' | 'reset'
}>()
</script>
<template>
<button :type="type" :class="cn(button({ variant, size }), className)">
<slot />
</button>
</template>
Notas:
- Supone que los tokens semánticos
accent-fg,surface-raisedyfocusexisten en la capa@theme inlinejunto a los mostrados en SKILL.md. h-10da un objetivo de 40px; WCAG 2.2 AA (2.5.8) exige al menos 24x24 px CSS o un espaciado adecuado; más grande es mejor para el tacto.- Si el botón puede renderizarse como enlace, acepta una prop
as/toy renderiza<NuxtLink>; nunca pongas un<button>dentro de un<a>.
Dar estilo a contenido ajeno
/* Prose from CMS / markdown */
.prose-content {
@apply text-body text-fg;
& h2 { @apply mt-12 mb-4 font-display text-h2; }
& a { @apply text-accent underline underline-offset-4 hover:no-underline; }
}
O usa @tailwindcss/typography personalizado con tus tokens. Este es el hogar legítimo de @apply.
Container queries
<article class="@container rounded-card border border-line bg-surface p-4">
<div class="grid gap-4 @md:grid-cols-[8rem_1fr]">
<NuxtImg ... class="aspect-square w-full rounded-card object-cover" />
<div>...</div>
</div>
</article>
La tarjeta se adapta a su espacio (barra lateral frente a columna principal) sin conocer el viewport.
Migrar proyectos de v3 (resumen)
- Mueve los valores de
theme.extenda variables de@theme; borra la configuración en JS al terminar. - Revisa las utilidades renombradas (varias utilidades de sombra, radio, desenfoque y contorno se desplazaron un paso o se renombraron en la v4) y los cambios en el color por defecto de bordes y anillos; verifica con la guía oficial de actualización y ejecuta la herramienta de actualización, luego revisa el diff visualmente.