Ir al contenido

Temas

Un tema de Claritas son, en lo esencial, dos colores y un modo. Todo lo demás —los estados de hover, los colores de texto contrastados, las superficies del modo oscuro, la escala del acento— se deriva. Cualquier derivada se puede sobreescribir, pero rara vez hace falta.

import { applyTheme } from '@openfactu/ui';
applyTheme({
mode: 'dark',
colors: { primary: '#1E102C', accent: '#EC4899' },
});

Los componentes nunca leen el contexto de React: consumen variables CSS. Por eso cambiar el tema de una empresa entera es reescribir unas cuantas variables sobre <html>, sin recompilar ni volver a montar nada.

Escribe el tema como variables CSS sobre un elemento y devuelve el tema ya resuelto. Es idempotente: llamarla dos veces con lo mismo no acumula estado.

const resuelto = applyTheme({ mode: 'light', colors: { primary: '#0A1628', accent: '#0D9488' } });

Sin target escribe en <html>, que es lo habitual. Pasándole un elemento tematiza solo ese subárbol, que es como se previsualiza un tema dentro de la propia interfaz:

const previewRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (previewRef.current) applyTheme(temaEnPruebas, previewRef.current);
}, [temaEnPruebas]);
<div ref={previewRef}>
<VistaPreviaDeLaAplicacion />
</div>
applyTheme(theme, target?, options?): ResolvedTheme
Opción Tipo Por defecto Descripción
applyDarkClass boolean true Alterna la clase dark en el elemento
injectFontLink boolean true Inyecta el <link> de Google Fonts de la tipografía elegida
setBackground boolean true Fija style.background en modo oscuro, para que no se vea el fondo claro entre el primer pintado y el del layout. Solo aplica sobre <html>

clearTheme(target?) retira las variables en línea que puso applyTheme.

Un tema no son solo colores: también la redondez y la tipografía, y todos los componentes las siguen.

applyTheme({
mode: 'light',
colors: { primary: '#0A1628', accent: '#0D9488' },
radius: 'lg', // 'none' | 'sm' | 'md' | 'lg', o { xs, sm, md, lg, full }
typography: { fontFamily: 'geist' }, // id de FONT_OPTIONS
});
Campo Tipo Descripción
mode 'light' | 'dark' Modo
colors ThemeColors Solo primary y accent son obligatorios
typography { fontFamily?, sans?, display?, mono? } fontFamily es un id de FONT_OPTIONS y gana sobre sans/display
radius 'none' | 'sm' | 'md' | 'lg' | ThemeRadius Redondez
shadows { sm?, md?, lg?, overlay? } Sombras
zIndex { sticky?, dropdown?, overlay?, modal?, popover?, toast?, tooltip? } Capas
vars Record<string, string | number> Variables CSS crudas, aplicadas al final. Punto de extensión

De primary y accent salen solos: primaryHover, primaryFg, accentFg, la escala accentScale, la escala de tinta ink, las superficies (bgApp, bgCard, bgSidebar, bgMuted, bgHover), los textos (fgDefault, fgBody, fgMuted, fgSubtle), los bordes y los semánticos de estado con sus versiones -fg, -bg y -on. Todos se pueden fijar a mano si el cálculo no te convence.

La capa del sidebar (sidebarFg, sidebarFgMuted, sidebarHover, sidebarActive) se deriva de bgSidebar midiendo contraste real, de modo que un sidebar claro siga teniendo la navegación legible.

FONT_OPTIONS trae seis opciones: sans (DM Sans, la de Keirost), roboto, roboto-flex, geist, serif y mono. Las que necesitan Google Fonts inyectan su <link> solas.

import { FONT_OPTIONS, fontOptionFor, googleFontsUrl } from '@openfactu/ui';
FONT_OPTIONS.map((f) => ({ id: f.id, label: f.label }));
fontOptionFor('geist'); // la opción, o la primera si el id no existe
googleFontsUrl(fontOptionFor('geist'));

Provider opcional, para manejar el tema con estado de React. Los componentes no lo necesitan: montarlo no es requisito para usar la librería.

import { ThemeProvider } from '@openfactu/ui';
<ThemeProvider
defaultTheme={{ mode: 'light', colors: { primary: '#0A1628', accent: '#0D9488' } }}
storageKey="keirost-tema"
>
<App />
</ThemeProvider>
Prop Tipo Por defecto Descripción
children ReactNode La aplicación
theme ThemeInput Tema controlado; manda sobre el estado interno
defaultTheme ThemeInput Tema inicial cuando no está controlado
onThemeChange (theme: ResolvedTheme) => void Cambio de tema
target HTMLElement | null | (() => HTMLElement | null) document.documentElement Dónde escribir las variables
applyDarkClass boolean true Alterna la clase dark
injectFontLink boolean true Inyecta el <link> de la tipografía
storageKey string | false false Clave de localStorage donde persistir

Dentro se lee con useTheme():

const { theme, mode, toggleMode, applyPreset, activePresetId } = useTheme();

UiThemeProvider y useUiTheme son alias con prefijo, para aplicaciones que ya tienen su propio ThemeProvider/useTheme y necesitan importar los dos en el mismo fichero durante una migración.

resolveTheme es una función pura, así que el tema se puede precalcular y aplicar antes de que arranque React:

import { resolveTheme, themeToCssText } from '@openfactu/ui';
const css = themeToCssText(resolveTheme(temaDelTenant)); // string de `:root { … }`

Métela en un <style> del HTML servido y la primera pintura ya sale con el tema correcto.

Función Firma Para qué
resolveTheme (theme?: ThemeInput) => ResolvedTheme Deriva el tema completo. Pura
themeToCssText (theme: ResolvedTheme) => string El bloque :root { … } como texto
themeToCssVars (theme: ResolvedTheme) => ThemeCssVars Mapa --variable → valor
themeToStyle (theme: ResolvedTheme) => CSSProperties Listo para el atributo style
DEFAULT_THEME ResolvedTheme El tema por defecto ya resuelto

THEME_PRESETS trae nueve temas de fábrica:

id Nombre Modo
keirost-classic Keirost Clásico claro
keirost-teal Teal Fuerte claro
keirost-slate Slate Monocromo claro
keirost-midnight Midnight oscuro
keirost-carbon Carbon oscuro
keirost-deep-ocean Deep Ocean oscuro
keirost-forest Forest oscuro
keirost-plum Plum oscuro
keirost-nebula Nebula oscuro
import { THEME_PRESETS, themePresetById, detectPreset } from '@openfactu/ui';
applyTheme(themePresetById('keirost-midnight')!.theme);
detectPreset(temaActual); // 'keirost-midnight', o null si el tema es a medida

detectPreset compara solo modo, primario y acento, que es lo que define un preset.

Un plugin puede aportar sus propios temas en tiempo de ejecución y retirarlos al desactivarse.

import { registerThemePreset, registerTokens, unregisterThemeSource } from '@openfactu/ui';
// Al activar el plugin
const bajaTema = registerThemePreset(
{
id: 'acme-corporativo',
label: 'Acme corporativo',
description: 'Azul de marca sobre fondo claro.',
theme: { mode: 'light', colors: { primary: '#1e3a8a', accent: '#2563eb' } },
},
{ source: 'plugin-acme' },
);
registerTokens('plugin-acme', { '--acme-ancho-panel': '280px' });
// Al desactivarlo
bajaTema();
unregisterThemeSource('plugin-acme'); // o de golpe, todo lo suyo

Los tokens de plugin se aplican después de los de la librería, así que sirven tanto para añadir variables propias como para pisar una concreta.

Función Firma Descripción
registerThemePreset (preset, { source?, replace? }?) => () => void Añade un tema. Devuelve la función de baja
unregisterThemePreset (id: string) => void Da de baja un tema
unregisterThemeSource (source: string) => void Da de baja todo lo de una misma fuente
getThemePresets () => RegisteredThemePreset[] Catálogo completo: integrados y registrados
getThemePreset (id: string) => RegisteredThemePreset | undefined Uno por id
subscribeThemePresets (listener) => () => void Avisa cuando entra o sale un tema
registerTokens (namespace, vars) => () => void Variables CSS propias de un plugin
unregisterTokens (namespace: string) => void Las retira
getRegisteredTokens () => Record<string, string> Todos los tokens registrados, mezclados

RegisteredThemePreset es un ThemePreset más source, que vale 'builtin' en los integrados.

En React, useThemePresets() devuelve el catálogo y se repinta solo en cuanto entra o sale un tema:

const presets = useThemePresets();
const { applyPreset, activePresetId } = useTheme();
{presets.map((p) => (
<button key={p.id} onClick={() => applyPreset(p.id)} aria-pressed={p.id === activePresetId}>
{p.label}
</button>
))}
import { validateTheme } from '@openfactu/ui';
const informe = validateTheme({ colors: { primary: '#1e3a8a', accent: '#2563eb' } });
if (!informe.ok) {
informe.issues.forEach((i) => console.log(i.level, i.field, i.message));
}

ThemeValidation es { ok, issues, resolved? }, y cada issue es { level: 'error' | 'warning', field, message }. Es el mismo informe que usa el registro por dentro, así que puedes enseñarlo en tu propia interfaz de personalización en lugar de dejar que avise por consola.

Los errores son colores mal formados o la falta de primary/accent; los avisos, el contraste por debajo del mínimo WCAG AA para texto normal.

Las funciones de bajo nivel con las que se construyen las derivadas. Rara vez hacen falta, pero se exportan porque a veces hay que calcular un color en JavaScript.

Símbolo Firma Para qué
normalizeHex (hex: string) => string Normaliza #rgb a #rrggbb
isValidHex (hex: string) => boolean Comprueba el formato
hexToRgb (hex: string) => RGB De hexadecimal a triplete
rgbToHex (rgb: RGB) => string De triplete a hexadecimal
rgbToSpaceString (rgb: RGB) => string '12 34 56', para rgb(var(--x) / .3)
mixRgb (a, b, t) => RGB Mezcla dos colores
lighten / lightenRgb (color, amount) => … Aclara
darken / darkenRgb (color, amount) => … Oscurece
alpha (hex, a) => string Color con transparencia
luminance / relativeLuminance (color) => number Luminancia
contrastRatio (a, b) => number Relación de contraste entre dos colores
contrastingFg / contrastingFgRgb (bg) => … El texto que más contrasta sobre un fondo
mutedOn (bg) => string Variante atenuada legible sobre un fondo
clampToDarkSurface (color) => string Recorta un color para que valga como superficie oscura
DARK_SURFACE_MAX_LUMINANCE number El techo de luminancia que usa clampToDarkSurface