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.css y 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

PiezaArchivoRol
Tokens y clases .ds-*app/assets/css/main.cssLa única fuente de colores, radios, sombras y tipografías
Temas globales de componentesapp/app.config.tsRouter de slots de Nuxt UI (ui.card, ui.modal, …)
Guía completa (repo)DESIGN_SYSTEM.mdRecetas, 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ñade font-sans a esa instancia.
  • Acento: la escala --color-claude-50…950 (el 500 es el terracota de marca) más ui.colors.primary = 'claude' en app.config.ts. No hay un segundo color de marca.
  • Superficies: --ds-card, --ds-panel y --ds-field, con valor claro en :root y oscuro en .dark.
  • Semánticos: --ui-bg, --ui-border, --ui-text* se sobrescriben en ambos modos. success / info / warning / error se quedan como vienen: son señales de estado y deben seguir siendo reconocibles.

2. Clases .ds-*

ClaseEfectoCuándo usarla
ds-cardFondo de tarjeta + borde de 1px + sombra suaveTarjetas y page cards
ds-panelFondo elevado + borde de 1pxModales, popovers, dropdowns, selects, toasts, tooltips
ds-fieldFondo de campoUInput, 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 @layer ni las conviertas a @utility: perderían esa prioridad.
  • Al ser CSS plano no aceptan variantes (md:ds-panel no existe). Si necesitas una variante, escribe una regla @media explí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 lint no valida colores; la prueba real es el navegador.
  • Si tocaste tokens, revisa también el theme-color de app/app.vue (claro #faf9f5, oscuro #1f1e1b).
  • Para colores nuevos: la paleta necesita los 11 pasos (50…950) y se registra como alias en app.config.ts, no como hex suelto.