Skip to content

Temas y skins

Pablo Martínez López edited this page Oct 23, 2026 · 2 revisions

Temas y skins

English · Español

El aspecto de la app es un sistema por capas: un tema de color (una paleta de tweakcn, seleccionada con data-theme en <html>), un modo (clase claro/oscuro), un skin opcional (Zeus o Hera, data-skin), un material de tarjetas/diálogos (sólido, cristal, mate, data-material), un fondo (data-bg), además de deslizadores de efectos y una escala de la UI. El sistema se hereda de un proyecto hermano y se mantiene intacto; además, los usuarios pueden instalar en ejecución cualquier tema de tweakcn. Todo el estado es del lado del cliente (localStorage); nada del aspecto se guarda en un mazo.

Las capas de un vistazo

Capa Gancho en <html> Valores Fuente de verdad
Tema de color data-theme="<nombre>" 43 nombres integrados (THEMES), más tw-<id> para los instalados src/themes.css, src/lib/themes.ts
Modo clase dark light, dark, system (guardado); se resuelve a claro/oscuro ThemeProvider
Skin data-skin ausente («Plano», liso), zeus, hera src/styles/skin-zeus.css, skin-hera.css
Material data-material ausente (= solid), glass, matte src/styles/materials.css
Fondo data-bg ausente (= liso), gradient, aurora src/styles/materials.css
Efectos variables CSS --macup-blur, --macup-noise, --macup-shadow desenfoque en px, grano 0..1, nivel de sombra 0..4 efectos de ThemeProvider
Flags data-fx-sidebar="1", data-hera-filigree="1" activado/desactivado ThemeProvider
Escala de la UI html { font-size: N% } 70 a 150 % ThemeProvider

Mapa de ficheros

Fichero Papel
src/index.css Entrada de Tailwind 4 (@import "tailwindcss", tw-animate-css, fonts.css), tokens por defecto de :root/.dark, mapeo de colores con @theme inline, imports de themes.css y styles/*, anulaciones de la casa (radio, color de borde, excepción del tema base), capa base (selección, cursor), barras de scroll, colores de los toasts, regla de opacidad de los modales.
src/themes.css Un bloque [data-theme="<nombre>"] (claro) y un bloque [data-theme="<nombre>"].dark por tema, 43 temas. Variables CSS puras exportadas de tweakcn (los últimos se produjeron con el generador propio del proyecto; base se añadió a mano).
src/lib/themes.ts Mode, TemaDeSerie (unión de los 43 nombres), ThemeName = TemaDeSerie | \tw-${string}`yTHEMES: { name, label, swatch }[](la lista del selector). La primera entrada esbase, a propósito. El fichero se edita a mano; el orden y los nombres deben coincidir con themes.css`.
src/components/theme-provider.tsx ThemeProvider / useTheme(). De solo lectura por regla del proyecto: es código heredado y aquí no se modifica; se amplía alrededor.
src/lib/tema-inicial.ts ponerTemaAntesDeMontar(): aplica el aspecto guardado a <html> antes del primer pintado.
src/lib/temas-instalados.ts Temas de tweakcn instalados: useTemasInstalados, prepararTemasInstalados, esTemaInstalado.
src/features/settings/appearance.tsx La sección Apariencia de Ajustes (todos los controles).
src/features/settings/temas-tweakcn.tsx El diálogo «Más temas…» y SincronizarTemas.
src/styles/materials.css, skin-zeus.css, skin-hera.css CSS de materiales/fondo y de los skins, ligado a los atributos data-slot de los componentes compartidos.
src/fonts.css + public/fonts/*.woff2 19 familias de letra locales de peso variable que usan los temas integrados (Inter, Plus Jakarta Sans, Open Sans, DM Sans, Montserrat, Poppins, Geist, Geist Mono, Fira Code, JetBrains Mono, IBM Plex Mono, Roboto Mono, Source Code Pro, Source Serif 4, Lora, Merriweather, Libre Baskerville, Antic, Architects Daughter). Cabecera autogenerada; no se edita a mano.
src-tauri/src/temas.rs, src-tauri/src/commands/temas.rs La mitad de Rust de los temas instalables.

biome.json excluye src/index.css, src/themes.css, src/fonts.css y src/styles del lint/formato, de modo que esos ficheros conservan el formato de su generador (indentación de dos espacios).

Valores de fábrica y el paso previo al montaje

Valores de fábrica: tema amber-minimal, modo dark, sin skin, sin fondo, material matte, desenfoque de 25 px, grano 0.25, nivel de sombra 3 («Fuerte»), escala 100 %, scroll suave activado, fxSidebar y heraFiligree desactivados. Se eligió amber-minimal porque es el tema integrado más cercano al aspecto dorado sobre negro del sitio que reproduce la app.

ThemeProvider aplica el tema desde un useEffect, que se ejecuta después del primer pintado. Con la ventana mostrándose de inmediato (véase Arquitectura del frontend) eso haría destellar la paleta clara por defecto. Por eso main.tsx llama primero a ponerTemaAntesDeMontar(). Esta refleja la lógica del proveedor: lee proxybuilder.theme (solo se acepta si está en THEMES; si no, amber-minimal; si no, el primer tema), proxybuilder.mode (por defecto dark; system usa matchMedia), pone data-theme y conmuta dark; para cualquier tema salvo base también pone data-skin, data-bg y data-material (el material por defecto es matte; el atributo se omite para solid). Las constantes y las claves de almacenamiento están duplicadas respecto al proveedor: si cambias una, cambia la otra (el propio fichero lo dice). Lo protege src/lib/__tests__/tema-inicial.test.ts (oscuro + mate de fábrica, valores guardados, modo sistema, un tema desconocido recurre al de fábrica, base queda limpio).

Claves de almacenamiento (las escribe ThemeProvider)

Clave Valor
proxybuilder.theme nombre del tema
proxybuilder.mode light / dark / system
proxybuilder.scale número (1 = 100 %)
proxybuilder.skin none / zeus / hera
proxybuilder.bg none / gradient / aurora
proxybuilder.material solid / glass / matte
proxybuilder.blur, .noise, .shadow números (sombra limitada de 0 a 4)
proxybuilder.fxSidebar, .heraFiligree "1" / "0"
proxybuilder.smoothScroll "1" / "0" (activado salvo que sea exactamente "0")
proxybuilder.temasInstalados espejo en JSON de los temas instalados (lo escribe temas-instalados.ts)

Las lecturas numéricas usan numeroGuardado(clave, defecto, valido) en vez de Number(localStorage.getItem(k)), porque Number(null) === 0 es un número válido y anularía en silencio todos los valores de fábrica.

Tokens y anulaciones de la casa (src/index.css)

  • Los tokens de color son el conjunto de shadcn (--background, --card, --primary, --muted, --accent, --destructive, --border, --input, --ring, --chart-1..5, --sidebar-*), mapeados a Tailwind con @theme inline (--color-*) para que funcionen bg-card, text-muted-foreground, etc. Variante para el modo oscuro: @custom-variant dark (&:is(.dark *)).
  • Radio de 14 px: :root, [data-theme], [data-theme].dark { --radius: 0.875rem !important }. El !important hace falta para vencer al radio .dark de cada tema. Como el selector es [data-theme] (cualquier valor), los temas tw-* instalados también lo reciben. Los radios de Tailwind derivan de él (--radius-sm/md/lg/xl = ±4/2 px).
  • Un solo color de borde: --input: var(--border) !important en todas partes. Varias paletas de tweakcn hacen --input notablemente más claro que --border, de modo que el borde de un campo no coincidiría con el de su tarjeta; la regla es «los bordes son siempre iguales; un campo se distingue por su fondo».
  • El tema base es la excepción: es el tema shadcn simple que se conserva como referencia de comparación («¿esta fealdad es del tema o del componente?»). Una regla posterior con la misma especificidad restaura --radius: 0.625rem y el --input de serie para [data-theme="base"] y .dark. Además, ThemeProvider renderiza base limpio: skin, fondo, material, efectos extra y sombra no se aplican mientras está activo (las elecciones del usuario siguen guardadas y vuelven al elegir otro tema). La tarjeta «Estilo de superficie» de Ajustes muestra una nota ámbar sobre esto cuando theme === "base".
  • La densidad (10 px) y el tamaño de texto base text-xs son convenciones a nivel de componente (p-2.5, gap-2.5, text-xs, text-[11px]) y no tokens; véase Convenciones.
  • Las barras de scroll están tematizadas (finas, color-mix de --primary en --card), incluido [data-slot="scroll-area-thumb"], para que no aparezca ninguna barra nativa blanca.
  • Toasts: fondo neutro, color solo en el texto (éxito/aviso/info/error); el error además tiñe el fondo y el borde. Las mezclas usan oklch, que interpola el tono; un gris neutro tiene tono 0, lo que volvería naranja un borde verde, así que el borde nunca se tiñe salvo en los errores (documentado en línea; cámbialo a in oklab si un skin da un tono a --card).
  • El control segmentado activo se detecta por aria-checked/aria-pressed, nunca por data-state="on", porque TooltipTrigger asChild sobrescribe el data-state del item.
  • prefers-reduced-motion: reduce desactiva todas las animaciones/transiciones salvo animate-spin/animate-pulse (un spinner congelado parece un cuelgue).

Escala de la UI

ThemeProvider pone document.documentElement.style.fontSize = "<escala*100>%" (vacío al 100 %) y elimina explícitamente cualquier zoom. Tailwind se basa en rem, así que todo escala junto. El zoom de CSS no se usa nunca: en WKWebView rompe position: fixed y el posicionamiento de los popper de Radix basado en getBoundingClientRect (los modales centrados se desplazan, las superposiciones no cubren la ventana, los desplegables caen lejos de su disparador). El control de Ajustes avanza de 5 en 5 % entre 70 % y 150 % (ESCALA_MIN/ESCALA_MAX en appearance.tsx).

Skins y materiales

Tanto los skins como los materiales dan estilo a los componentes mediante atributos data-slot que emiten los componentes compartidos (card, button con data-variant, input, select-trigger, select-content, popover-content, dropdown-menu-content, tooltip-content, toggle-group, toggle-group-item, dialog-content, dialog-overlay, badge). Consumen los tokens del tema activo mediante color-mix, de modo que cualquier paleta (incluidas las instaladas) obtiene un acento coherente.

  • Zeus (skin-zeus.css): relieve neumórfico. Define variables --ef-* (radio, borde, sombras de panel/botón/pulsado/hueco, superficies con degradado, líneas verticales finas de «filigrana») y las aplica a tarjetas, botones (sin animación de box-shadow, que da tirones en WKWebView), campos, menús, interruptores y diálogos.
  • Hera (skin-hera.css): diseño de cristal flotante: variables --hera-* (cristal, borde, línea fina, rayado vertical --hera-lines, brillo). El flag opcional de filigrana (data-hera-filigree="1") superpone el rayado en cada superficie de Hera; en las tarjetas se dibuja en una capa ::after por encima del contenido para que el escalado de capas de la GPU no lo desenfoque, y el panel de contenido deliberadamente no lo lleva (haría moiré contra las tarjetas de cristal).
  • Materiales (materials.css): glass = color-mix de --card con transparencia + backdrop-filter: blur(var(--macup-blur)); matte = cristal más un grano de ruido fractal SVG en ::before (opacidad --macup-noise, mix-blend-mode: overlay). La sombra de las tarjetas es var(--macup-shadow), escrita por ThemeProvider a partir de la tabla de sombras de cinco pasos (ninguna, suave, normal, fuerte, muy fuerte; forzada a none para base). Los diálogos se añadieron a las reglas de material como una superficie más (sin cambiar position en el diálogo: es fixed y perdería su centrado).
  • fxSidebar («Incluye los paneles») extiende el cristal/mate a los slots sidebar-inner y topbar.

Deducido: esta app no tiene ningún elemento con los slots sidebar-wrapper, sidebar-inset, sidebar-inner o topbar (no encontré ninguno en src/), porque esas reglas se heredaron del proyecto hermano. Así que el ajuste «Fondo de pantalla» (degradado/aurora apuntan a sidebar-inset), la aurora del lienzo de Hera y el flag fxSidebar no tienen hoy un destino visible aquí; el estilo de tarjetas, botones, inputs, menús, interruptores y diálogos sí se aplica. Comprueba grep -rn 'data-slot="sidebar' src antes de fiarte de ellos.

Los modales son 100 % opacos

Decisión: los diálogos son siempre opacos, sea cual sea el material o el skin. Motivo: con el material mate/cristal o el skin Hera, el diálogo dejaba ver lo que había detrás y costaba leerlo. La última regla de src/index.css fija [data-slot="dialog-content"] { background-color: var(--card) !important; backdrop-filter: none !important }; las imágenes de grano y filigrana (dibujadas por encima del color) siguen viéndose, pero nada de lo que hay debajo se transparenta. Va la última del fichero y usa !important para ganar tanto a materials.css como a las reglas de los skins. El subdiálogo de confirmar la salida (ConfirmarSalida en dialog.tsx) también es data-slot="dialog-content", así que sigue la misma regla, y la misma lista de selectores incluye la tarjeta del tutorial ([data-slot="tutorial-tarjeta"], véase Tutoriales), que flota sobre la pantalla oscurecida y tiene que leerse bien por el mismo motivo.

Los controles de Ajustes (appearance.tsx)

Tarjetas, cada una respaldada por un setter de useTheme(): Apariencia (modo), Tamaño de la interfaz (+ casilla «Scroll suave»), Estilo de superficie (Plano / Zeus / Hera, + casilla Filigrana cuando es Hera), Fondo de pantalla (Liso / Degradado / Aurora), Material de las tarjetas (Sólido / Cristal / Mate), Efectos (Desenfoque de 0 a 40, activado salvo en sólido; Grano de 0 a 0.5, activado solo en mate; Sombra de 0 a 4; casilla «Incluye los paneles») y Tema de color (una cuadrícula de THEMES con muestras, más el botón «Más temas…»). Más en Ajustes y shell.

Temas de tweakcn instalables

Los usuarios pueden añadir temas más allá de los 43 integrados: el registro oficial de tweakcn o cualquier tema de la comunidad por enlace/id. La vista web nunca toca la red; Rust los descarga, valida y guarda.

sequenceDiagram
    participant UI as temas-tweakcn.tsx
    participant ST as useTemasInstalados
    participant IPC as call() / ipc.ts
    participant RS as temas.rs (Rust)
    participant NET as tweakcn.com / Google Fonts
    participant PR as protocolo pbimg://
    UI->>IPC: theme_fetch(id o enlace)
    IPC->>RS: traer()
    RS->>NET: GET /r/themes/id
    RS-->>UI: TemaCatalogo (css para la vista previa, colores)
    UI->>ST: instalar(id)
    ST->>IPC: theme_install
    RS->>NET: JSON del tema + CSS de letras + ficheros woff2
    RS->>RS: validar variables, escribir temas/id.json, temas/fuentes/hash.woff2
    RS-->>ST: TemaInstalado (id tw-id, css con marcador BASE)
    ST->>ST: aplicar(): sustituir BASE, actualizar style#temas-tweakcn, añadir a THEMES, espejo en localStorage
    UI->>UI: setTheme(tw-id)
    Note over PR: letras servidas desde /f/hash.woff2
Loading

Lado de Rust (src-tauri/src/temas.rs)

Función Qué hace
id_de(texto) Extrae el id de un tema de lo que pega el usuario: un id suelto, /themes/<id>, /r/themes/<id>.json o /editor/theme?theme=<id>. Cualquier cosa que parezca un enlace debe contener tweakcn.com/. Los ids tienen de 2 a 64 caracteres de [A-Za-z0-9_-]. El fallo es InvalidInput con motivo = "tema_enlace".
catalogo(externo) Descarga https://tweakcn.com/r/themes/registry.json (máx. 4 MiB) en un Vec<TemaCatalogo> (id, nombre, 4 colores de muestra para claro y oscuro, letra principal, CSS de vista previa sin letras).
traer(externo, texto) Un tema por id/enlace para previsualizarlo.
instalar(externo, datos, texto) Descarga el JSON del tema, lo convierte a CSS, descarga las letras que necesita, escribe <app_data>/temas/<id>.json (de forma atómica) y devuelve TemaInstalado { id: "tw-<id>", nombre, css, muestra }.
instalados(datos) Lee todos los temas/*.json, ordenados por nombre.
quitar(datos, id) Borra temas/<id>.json; los ficheros de letras se quedan (pueden pertenecer a otro tema).
letra(datos, nombre) Devuelve los bytes de temas/fuentes/<sha256>.woff2 para el protocolo; el nombre debe ser un hash de 64 hex.

Seguridad (nunca llega CSS libre a la página): solo se usan cssVars (los mapas theme, light, dark). Los nombres de variable deben cumplir [a-z0-9-]{1,40}; los valores, como máximo 400 caracteres y no pueden contener ; { } < > \ @ ni url(. El CSS lo genera la app con la forma exacta de themes.css: [data-theme="tw-<id>"] { --name: value; } y lo mismo con .dark. Cualquier variable infractora hace fallar el tema entero con InvalidInput. Tests: el_id_de_lo_que_se_pegue, el_css_con_el_formato_de_la_app, nada_de_css_libre, solo_se_bajan_las_letras_que_faltan en temas.rs.

Letras: para font-sans, font-serif, font-mono se toma la primera familia de la pila; los nombres se restringen a [A-Za-z0-9 -] y se saltan cuando ya vienen incluidas (YA_ESTAN, que refleja src/fonts.css más las letras del sistema). Para cada familia restante se pide el CSS de Google Fonts (API v1, pesos 300 a 700, latin y latin-ext) presentándose como un navegador (Google solo sirve woff2 a los navegadores que conoce), se descarga cada woff2 (máx. 2 MiB, debe empezar con la firma wOF2), se guarda por su hash SHA-256 y se reescribe el url(...) a {{PBIMG}}f/<hash>.woff2. Una letra que no se pueda descargar solo registra un aviso y el tema recurre a su pila.

Lista de destinos de red permitidos: todas las descargas pasan por fuentes::Externo (véase Fuentes externas), cuya lista PERMITIDOS incluye tweakcn.com /r/themes/, fonts.googleapis.com /css y fonts.gstatic.com /s/; las redirecciones se vuelven a comprobar y los tamaños tienen tope.

Comandos (src-tauri/src/commands/temas.rs, filas en src/lib/ipc.ts)

themes_catalog, theme_fetch({id}), theme_install({id}), themes_installed, theme_remove({id}), themes_open_community (abre https://tweakcn.com/community en el navegador del sistema con tauri_plugin_opener; la galería de la comunidad no tiene una API pública de listado, así que los usuarios navegan allí y pegan un enlace).

Servir las letras

El protocolo pbimg (src-tauri/src/protocol.rs) tiene una ruta /f/<64-hex>.woff2 (Ruta::Letra) que devuelve font/woff2 con Cache-Control: max-age=31536000, immutable (la URL es un hash del contenido). La CSP de src-tauri/tauri.conf.json permite font-src 'self' data: pbimg: http://pbimg.localhost y style-src 'unsafe-inline' (necesario para el <style> inyectado). El path traversal se bloquea exigiendo que el nombre sea un hash válido (es_huella).

Lado del frontend (src/lib/temas-instalados.ts)

  • BASE = "{{PBIMG}}": Rust no puede conocer la raíz del protocolo (cambia según la plataforma), así que el CSS guardado lleva este marcador y aplicar() lo sustituye por raizProtocolo() (src/lib/image-url.ts).
  • aplicar(temas) escribe el CSS de todos los temas en un único <style id="temas-tweakcn"> en <head> y reconstruye la cola tw-* de THEMES (elimina todas las entradas tw- existentes y añade las actuales con swatch = muestra). THEMES es un array de módulo mutable; lo leen tanto el selector como ThemeProvider.
  • useTemasInstalados: temas (null hasta que responde Rust), cargar(), instalar(id) (ordena por nombre, sustituye una entrada existente con el mismo id para que reinstalar no duplique), quitar(id). Cada mutación llama a poner() = aplicar + recordar + fijar el estado.
  • recordar() refleja la lista de instalados en localStorage["proxybuilder.temasInstalados"], y prepararTemasInstalados() (llamada en main.tsx antes de montar) la reproduce. Por qué: ThemeProvider solo acepta un tema guardado que esté en THEMES; sin el espejo, un tema instalado arrancaría con los colores por defecto hasta que respondiera Rust. Un espejo corrupto se ignora.
  • SincronizarTemas (montado en App.tsx) llama a cargar() al arrancar y, si el tema seleccionado es un tw-* que ya no existe (quitado, o carpeta de datos borrada), cambia a amber-minimal. TemasTweakcn.quitarTema también cambia antes de quitarlo si el tema quitado es el activo.
  • La vista previa del diálogo (Muestra) inyecta el CSS del candidato en un <style> y renderiza una carta de muestra dentro de un elemento con data-theme="tw-<id>" (más dark cuando la app está en modo oscuro), de modo que el tema real no se aplica a la app hasta que se instala.
  • La lista del catálogo excluye los ids que ya coinciden con el nombre de un tema integrado.

Protección: src/lib/__tests__/temas-instalados.test.ts (instalar/quitar no duplican ni dejan restos; el marcador de la raíz se sustituye; la memoria se restaura antes de montar; una memoria rota no rompe el arranque).

Añadir un tema integrado

Pega la exportación de tweakcn como bloques [data-theme="<nombre>"] más [data-theme="<nombre>"].dark en src/themes.css, añade el nombre a TemaDeSerie y una entrada a THEMES en src/lib/themes.ts (nombre, etiqueta, color de muestra). Si necesita una familia de letra nueva, añade su woff2 en public/fonts/, un @font-face en src/fonts.css y el nombre de la familia en minúsculas a YA_ESTAN en src-tauri/src/temas.rs para que los temas instalados no la vuelvan a descargar. No edites theme-provider.tsx.

Trampas conocidas

  • ThemeProvider es de solo lectura por convención; los cambios de comportamiento van en tema-inicial.ts, temas-instalados.ts y CSS.
  • Las tres constantes de claves de almacenamiento y los valores por defecto existen tanto en theme-provider.tsx como en tema-inicial.ts.
  • Las anulaciones del radio y del color de borde usan !important en selectores [data-theme]; un tema nuevo no puede excluirse salvo siendo base.
  • Todo lo translúcido sobre la página (cristal, data-bg) necesita algo detrás que desenfocar; con material glass/matte y sin fondo, la hoja de estilos añade un velo radial tenue por ese motivo.
  • Prueba los skins en WebKitGTK, no en Chrome (el desenfoque, el backdrop-filter y el comportamiento de las capas de GPU difieren).

Véase también

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally