Tema claro y oscuro

Dos vistas de primera clase del mismo sistema, no un tema y su inversión: el oscuro tiene valores propios, afinados uno a uno. Los componentes no saben en qué tema están; los tokens sí.

Montar el tema

tokens.css declara el oscuro bajo la clase .dark y fija color-scheme en cada tema, así que las casillas, los selects y las barras de scroll nativas siguen al tema sin hacer nada. No hace falta ningún provider: ThemeToggle pone o quita la clase en <html> y guarda la elección en localStorage con la clave theme y los valores light, dark y system, los mismos que next-themes: si vienes de él, puedes quitarlo y nadie pierde su preferencia.

El selector aplica el tema al montarse; para que la primera pintura no salga en claro y salte, un script en el <head> hace lo mismo antes de pintar. Como cambia la clase antes de que React hidrate, suppressHydrationWarning calla esa diferencia, solo en ese elemento.

app/layout.tsx
import { brandFontVariables } from "@unozero-ai/design-tokens/fonts";
import "./globals.css";

// Lo mismo que hace <uz-theme-toggle> al montarse, pero antes de pintar.
const theme = `try{var t=localStorage.getItem("theme"),d=t==="dark"||(t!=="light"&&matchMedia("(prefers-color-scheme: dark)").matches),r=document.documentElement;r.classList.toggle("dark",d);r.style.colorScheme=d?"dark":"light"}catch(e){}`;

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="es" className={brandFontVariables} suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: theme }} />
      </head>
      <body className="font-sans antialiased">{children}</body>
    </html>
  );
}

El selector

ThemeToggle es un control segmentado de tres posiciones y no un interruptor a propósito: un interruptor solo aterriza en claro u oscuro, y el primer clic tiraría el modo automático con el que arranca la app sin forma de recuperarlo. En automático sigue a prefers-color-scheme en vivo. Varios selectores en la misma página se mueven juntos: pulsa la luna y cambia también el de la cabecera.

Dos vistas, no una inversión

Los dos valores de cada token, leídos del tokens.css instalado. Se ven los dos a la vez sea cual sea tu tema. El oscuro no invierte la rampa: el fondo es un neutro tintado, la tarjeta sube de luminosidad sobre él, los estados invierten texto y tinte y el foco pasa a cian.

TokenClaroOscuro
--backgroundNeutro tintado a H278, no negro
--cardEn oscuro sube sobre el fondo: la superficie se separa por luz
--mutedLienzo y zonas en reposo
--borderHairline
--grid-lineEn claro oscurece; en oscuro aclara
--foregroundTexto
--muted-foregroundTexto secundario
--primary-hoverEn claro oscurece; en oscuro aclara
--ringAzul en claro, cian en oscuro
--success-textTexto claro sobre tinte oscuro
--success-subtleTinte de superficie
--destructive-textTexto claro sobre tinte oscuro
--destructive-subtleTinte de superficie

En tus componentes

Pinta con tokens y el oscuro llega solo, igual que a los componentes del kit, que no saben en qué tema están. La variante dark: de Tailwind queda para excepciones de estructura, nunca para elegir un color. Revisa cada pantalla nueva en los dos temas antes de darla por terminada.

// Bien: el token ya tiene su valor oscuro
<div className="border border-border bg-card text-card-foreground">…</div>

// Mal: duplica el tema a mano y se separa del paquete en la próxima versión
<div className="border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">…</div>