Design system
Los tres niveles del sistema (tokens, clases .ds-*, temas de componentes) y las reglas para no salirse de él.
El look del template es Claude Amber: lienzo crema, acento terracota, radios generosos y títulos con serif. La regla que lo sostiene todo es una sola:
Nada hardcodea un color. Cada valor visual vive en un token de
app/assets/css/main.cssy se consume con una clase.ds-*o una utilidad semántica (bg-default,border-default,text-muted). Como claro y oscuro tienen su propio bloque de tokens, un cambio se hace una vez y se ve bien en los dos modos.
Dónde vive cada cosa
| Pieza | Archivo | Rol |
|---|---|---|
Tokens y clases .ds-* | app/assets/css/main.css | La única fuente de colores, radios, sombras y tipografías |
| Temas globales de componentes | app/app.config.ts | Router de slots de Nuxt UI (ui.card, ui.modal, …) |
| Guía completa (repo) | DESIGN_SYSTEM.md | Recetas, verificación y troubleshooting paso a paso |
Los tres niveles
1. Tokens
- Tipografía:
--font-sans(Outfit) para todo el texto de interfaz,--font-heading(Fraunces) solo para títulos reales (h1…h6, títulos de page card, modal y navbar),--font-mono(Geist Mono) para código e identificadores. Si un título se reutiliza como etiqueta pequeña, añadefont-sansa esa instancia. - Acento: la escala
--color-claude-50…950(el 500 es el terracota de marca) másui.colors.primary = 'claude'enapp.config.ts. No hay un segundo color de marca. - Superficies:
--ds-card,--ds-panely--ds-field, con valor claro en:rooty oscuro en.dark. - Semánticos:
--ui-bg,--ui-border,--ui-text*se sobrescriben en ambos modos.success/info/warning/errorse quedan como vienen: son señales de estado y deben seguir siendo reconocibles.
2. Clases .ds-*
| Clase | Efecto | Cuándo usarla |
|---|---|---|
ds-card | Fondo de tarjeta + borde de 1px + sombra suave | Tarjetas y page cards |
ds-panel | Fondo elevado + borde de 1px | Modales, popovers, dropdowns, selects, toasts, tooltips |
ds-field | Fondo de campo | UInput, UTextarea y triggers de USelect / USelectMenu |
Están declaradas fuera de cualquier @layer, y por eso ganan a las utilidades de Tailwind (bg-default, bg-elevated, shadow-lg) sin !important. Dos consecuencias:
- No las muevas a
@layerni las conviertas a@utility: perderían esa prioridad. - Al ser CSS plano no aceptan variantes (
md:ds-panelno existe). Si necesitas una variante, escribe una regla@mediaexplícita.
3. Temas de componentes
app/app.config.ts solo enruta slots hacia tokens y clases; nunca declara colores literales:
export default defineAppConfig({
ui: {
colors: { primary: 'claude', neutral: 'stone' },
card: { slots: { root: 'ds-card', title: 'font-heading' } }
}
})
Para ajustar una sola instancia no toques el global: usa la prop :ui="{ slot: '...' }" sobre esa instancia. Y recuerda que las .ds-* ganan a las utilidades: para anular una superficie puntual necesitas una regla propia.
Patrones de la casa
Card con cabecera de icono
El patrón que usan las páginas reales (icono en squircle, título de una línea, subtítulo de una):
<UPageCard variant="subtle">
<template #header>
<div class="flex items-center gap-4 p-1">
<div class="flex items-center justify-center w-11 h-11 rounded-xl bg-primary/10 shrink-0">
<UIcon name="i-lucide-lock-keyhole" class="w-6 h-6 text-primary" />
</div>
<div>
<h3 class="text-base font-semibold">Título</h3>
<p class="text-sm text-muted">Una frase de subtítulo</p>
</div>
</div>
</template>
</UPageCard>
Formulario
<UForm class="space-y-5"> → UFormField con :label y :description arriba del campo → UInput class="w-full" → fila de submit a la derecha con :loading y :disabled enlazados al estado de la acción. El help de UFormField es el que va debajo del input.
Estado de acción (regla del proyecto)
Toda acción asíncrona declara un ref con nombre de verbo, se protege contra doble clic y se resetea en finally:
const saving = ref(false)
async function save() {
if (saving.value) return
saving.value = true
try {
await saveMutation.mutateAsync(data)
} finally {
saving.value = false
}
}
<UButton :loading="saving" :disabled="saving" @click="save">Guardar</UButton>
Qué no hacer
<!-- ❌ Colores y sombras literales, se rompen en el modo contrario -->
<div class="bg-white text-black shadow-[0_1px_3px_rgba(0,0,0,0.2)]">…</div>
<!-- ✅ Superficie del sistema + utilidades semánticas -->
<div class="ds-card rounded-lg p-5 text-highlighted">…</div>
<!-- ❌ Un segundo acento inventado -->
<UBadge class="bg-purple-500">…</UBadge>
<!-- ✅ El acento es `primary` (o un color de estado para señales) -->
<UBadge color="primary">…</UBadge>
Verificación
- Cambia con el toggle de tema (menú del usuario o controles de apariencia de las páginas públicas): un cambio está hecho solo cuando se ve bien en claro y oscuro.
pnpm lintno valida colores; la prueba real es el navegador.- Si tocaste tokens, revisa también el
theme-colordeapp/app.vue(claro#faf9f5, oscuro#1f1e1b). - Para colores nuevos: la paleta necesita los 11 pasos (
50…950) y se registra como alias enapp.config.ts, no como hex suelto.