Convenciones (do / don't)
Los patrones que mantienen al design system coherente — y los que silenciosamente lo rompen.
Estas reglas vienen de la migración que consolidó tres paletas drifteando en una. Están aplicadas por review, no por lint — leelas antes de mandar un cambio de UI.
Tokens
Do — usá clases semánticas de Tailwind
<div className="bg-card border border-border text-foreground">
<p className="text-muted-foreground">Helper copy</p>
<button className="bg-primary text-primary-foreground">Continue</button>Don't — hardcodear colores
// Todo esto rompe theme-switching y driftea con el tiempo.
<div className="bg-black border border-white/10 text-white">
<p className="text-zinc-400">Helper copy</p>
<button className="bg-white text-black">Continue</button>
<div style={{ background: 'oklch(0% 0 0)' }}>Incluso bg-white/[0.05] es un smell salvo que estés modelando un alpha overlay sobre una surface conocida (ej. card hover). El token (hover:bg-accent) se prefiere cuando existe uno.
Fonts
Do — apoyate en el root layout
apps/client/app/layout.tsx ya carga Geist Sans y Geist Mono vía next/font/google y los expone como --font-geist-sans / --font-geist-mono. Usá font-sans y font-mono en componentes.
Don't — re-cargar fonts por componente
// Don't.
import { Geist } from 'next/font/google';
const geist = Geist({ subsets: ['latin'] });
export function MyComponent() {
return <div className={geist.className}>...</div>;
}La carga de fonts por componente envía <link>s duplicados, anula el preload, y produce inconsistencias FOIT entre routes.
Brand primitives
Do — importá desde @/components/brand
import { BrandMark, GridGlowBackground } from '@/components/brand';
<header>
<BrandMark>
<VersionChip />
</BrandMark>
</header>Don't — re-rollar el wordmark, el grid, el chip
// Don't — tres páginas terminarán con tres wordmarks distintos.
<span className="font-semibold tracking-[-0.02em] text-lg">UseDeploy</span>
// Don't — el grid y el glow son intencionalmente un solo componente por una razón.
<div className="absolute inset-0 [background-image:linear-gradient(...)]" />Si necesitás una variante que el primitive no expone, agregá una prop al primitive — no lo bifurques.
Páginas de auth
Do — envolvé el contenido del form en <AuthCard>
// app/(auth)/login/page.tsx
export default function LoginPage() {
return (
<AuthCard
title="Sign in"
subtitle="Welcome back."
footer={<Link href="/register">Create an account</Link>}
>
<LoginForm />
</AuthCard>
);
}El (auth)/layout.tsx monta el chrome de la página (GridGlowBackground, container centrado). La página sólo declara su form.
Don't — re-implementar el chrome por página
// Don't.
return (
<div className="min-h-screen bg-black flex items-center justify-center">
<div className="rounded-lg border border-white/10 bg-white/5 p-8">
<h1>Sign in</h1>
<LoginForm />
</div>
</div>
);Este es el patrón de bug que produjo tres definiciones de wrapper antes de #145.
Auth client
Do — usá cookies vía apiClient con credentials
const { data, error } = await api.POST('/auth/login', { body: { email, password } });
// Cookie seteada por el server; sin token para guardar.Don't — alcanzar localStorage / bearer tokens
BetterAuth emite una cookie HTTP-only que el browser manda de vuelta automáticamente porque apiClient tiene credentials: 'include' (withCredentials: true para callsites de axios). No hay token para capturar.
Schemas de form
Do — importá desde @app/contracts, extendé para campos client-only
import { auth } from '@app/contracts';
export const registerFormSchema = auth.SignUpInput
.extend({ confirmPassword: z.string() })
.refine((d) => d.password === d.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
});Don't — re-definir campos conocidos del server
// Don't — driftea del server, manda 400 a los users, el typecheck no lo agarra.
export const registerFormSchema = z.object({
fullName: z.string().min(1),
emailAddress: z.string().email(),
password: z.string().min(8),
});Ver contracts para la historia del bug.
Cuando dudás
Abrí el source de un primitive — son chiquitos (todo el directorio brand/ está bajo 400 líneas). Los tokens son 250 líneas de globals.css. Leé el código; es la fuente de verdad.