Repository navigation
Themes and Skins
English · Español
The look of the app is a layered system: a colour theme (a tweakcn palette, selected by data-theme on <html>), a mode (light/dark class), an optional skin (Zeus or Hera, data-skin), a card/dialog material (solid, glass, matte, data-material), a background (data-bg), plus effect sliders and a UI scale. The system is inherited from a sibling project and kept intact; users can additionally install any theme from tweakcn at runtime. All state is client-side (localStorage); nothing about the look is stored in a deck.
| Layer |
<html> hook |
Values | Source of truth |
|---|---|---|---|
| Colour theme | data-theme="<name>" |
43 built-in names (THEMES), plus tw-<id> for installed ones |
src/themes.css, src/lib/themes.ts
|
| Mode | class dark
|
light, dark, system (stored); resolved to light/dark |
ThemeProvider |
| Skin | data-skin |
absent (flat "Plano"), zeus, hera
|
src/styles/skin-zeus.css, skin-hera.css
|
| Material | data-material |
absent (= solid), glass, matte
|
src/styles/materials.css |
| Background | data-bg |
absent (= plain), gradient, aurora
|
src/styles/materials.css |
| Effects | CSS vars --macup-blur, --macup-noise, --macup-shadow
|
blur px, noise 0..1, shadow level 0..4 |
ThemeProvider effects |
| Flags |
data-fx-sidebar="1", data-hera-filigree="1"
|
on/off | ThemeProvider |
| UI scale | html { font-size: N% } |
70 to 150 % | ThemeProvider |
| File | Role |
|---|---|
src/index.css |
Tailwind 4 entry (@import "tailwindcss", tw-animate-css, fonts.css), :root/.dark default tokens, @theme inline colour mapping, imports of themes.css and styles/*, house overrides (radius, border colour, base-theme exception), base layer (selection, cursor), scrollbars, toast colours, modal opacity rule. |
src/themes.css |
One [data-theme="<name>"] block (light) and one [data-theme="<name>"].dark block per theme, 43 themes. Pure CSS variables exported from tweakcn (the last ones were produced with the project's own generator; base was added by hand). |
src/lib/themes.ts |
Mode, TemaDeSerie (union of the 43 names), ThemeName = TemaDeSerie | \tw-${string}`, and THEMES: { name, label, swatch }[](the selector list). The first entry isbaseon purpose. The file is edited by hand; order and names must matchthemes.css`. |
src/components/theme-provider.tsx |
ThemeProvider / useTheme(). Read-only by project rule: it is inherited code and is not modified here; extend around it instead. |
src/lib/tema-inicial.ts |
ponerTemaAntesDeMontar(): applies the stored look to <html> before first paint. |
src/lib/temas-instalados.ts |
Installed tweakcn themes: useTemasInstalados, prepararTemasInstalados, esTemaInstalado. |
src/features/settings/appearance.tsx |
The Appearance section of Settings (all the controls). |
src/features/settings/temas-tweakcn.tsx |
The "More themes..." dialog and SincronizarTemas. |
src/styles/materials.css, skin-zeus.css, skin-hera.css
|
Material/background and skin CSS, keyed on data-slot attributes of the shared components. |
src/fonts.css + public/fonts/*.woff2
|
19 local variable-weight font faces used by the built-in themes (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). Auto-generated header; do not hand-edit. |
src-tauri/src/temas.rs, src-tauri/src/commands/temas.rs
|
Rust half of installable themes. |
biome.json excludes src/index.css, src/themes.css, src/fonts.css and src/styles from lint/format, so those files keep their generator formatting (two-space indentation).
Factory defaults: theme amber-minimal, mode dark, skin none, background none, material matte, blur 25 px, noise 0.25, shadow level 3 ("Strong"), scale 100 %, smooth scroll on, fxSidebar and heraFiligree off. amber-minimal was chosen because it is the closest built-in to the gold-on-black look of the site the app reproduces.
ThemeProvider applies the theme from a useEffect, which runs after the first paint. With the window shown immediately (see Frontend Architecture) that would flash the light default palette. So main.tsx calls ponerTemaAntesDeMontar() first. It mirrors the provider's logic: read proxybuilder.theme (accepted only if present in THEMES, else amber-minimal, else the first theme), proxybuilder.mode (default dark; system uses matchMedia), set data-theme and toggle dark; for any theme except base also set data-skin, data-bg and data-material (material defaults to matte, attribute omitted for solid). The constants and storage keys are duplicated from the provider: if you change one, change the other (the file says so). Guarded by src/lib/__tests__/tema-inicial.test.ts (factory dark + matte, stored values, system mode, unknown theme falls back, base is clean).
| Key | Value |
|---|---|
proxybuilder.theme |
theme name |
proxybuilder.mode |
light / dark / system
|
proxybuilder.scale |
number (1 = 100 %) |
proxybuilder.skin |
none / zeus / hera
|
proxybuilder.bg |
none / gradient / aurora
|
proxybuilder.material |
solid / glass / matte
|
proxybuilder.blur, .noise, .shadow
|
numbers (shadow clamped 0 to 4) |
proxybuilder.fxSidebar, .heraFiligree
|
"1" / "0"
|
proxybuilder.smoothScroll |
"1" / "0" (on unless exactly "0") |
proxybuilder.temasInstalados |
JSON mirror of installed themes (written by temas-instalados.ts) |
Numeric reads use numeroGuardado(clave, defecto, valido) rather than Number(localStorage.getItem(k)), because Number(null) === 0 is a valid number and would silently defeat every factory default.
-
Colour tokens are the shadcn set (
--background,--card,--primary,--muted,--accent,--destructive,--border,--input,--ring,--chart-1..5,--sidebar-*), mapped to Tailwind with@theme inline(--color-*) sobg-card,text-muted-foregroundetc. work. Dark mode variant:@custom-variant dark (&:is(.dark *)). -
Radius 14 px:
:root, [data-theme], [data-theme].dark { --radius: 0.875rem !important }. The!importantis needed to beat each theme's own.darkradius. Because the selector is[data-theme](any value), installedtw-*themes get it too. Tailwind radii derive from it (--radius-sm/md/lg/xl= ±4/2 px). -
One border colour:
--input: var(--border) !importanteverywhere. Several tweakcn palettes make--inputnoticeably lighter than--border, so a field's border would not match its card; the rule is "borders are always the same; distinguish a field with its background". -
The
basetheme is the exception: it is the plain shadcn theme kept as a comparison reference ("is this ugliness the theme's or the component's?"). A later rule with equal specificity restores--radius: 0.625remand the stock--inputfor[data-theme="base"]and.dark.ThemeProvideradditionally rendersbaseclean: skin, background, material, extra effects and shadow are all not applied while it is active (user choices stay stored and return when another theme is selected). The Settings "Surface style" card shows an amber note about this whentheme === "base". -
Density (10 px) and base text size
text-xsare component-level conventions (p-2.5,gap-2.5,text-xs,text-[11px]) rather than tokens; see Conventions. -
Scrollbars are themed (thin,
color-mixof--primaryinto--card), including[data-slot="scroll-area-thumb"], so no white native bar appears. -
Toasts: neutral background, colour only in the text (success/warning/info/error), error also tints background and border. Mixes use
oklch, which interpolates hue; a neutral grey has hue 0 which would turn a green border orange, hence the border is never tinted except for errors (documented inline; switch toin oklabif a skin gives--carda hue). -
Active segmented control is detected by
aria-checked/aria-pressed, neverdata-state="on", becauseTooltipTrigger asChildoverwrites the item'sdata-state. -
prefers-reduced-motion: reducedisables all animations/transitions exceptanimate-spin/animate-pulse(a frozen spinner looks like a hang).
ThemeProvider sets document.documentElement.style.fontSize = "<scale*100>%" (empty at 100 %) and explicitly removes any zoom. Tailwind is rem-based so everything scales together. CSS zoom is never used: in WKWebView it breaks position: fixed and Radix's getBoundingClientRect-based popper positioning (centred modals drift, overlays do not cover the window, dropdowns land away from their trigger). The Settings control steps by 5 % between 70 % and 150 % (ESCALA_MIN/ESCALA_MAX in appearance.tsx).
Both skins and the materials style components through data-slot attributes that the shared components emit (card, button with data-variant, input, select-trigger, select-content, popover-content, dropdown-menu-content, tooltip-content, toggle-group, toggle-group-item, dialog-content, dialog-overlay, badge). They consume the active theme's tokens through color-mix, so any palette (including installed ones) gets a coherent accent.
-
Zeus (
skin-zeus.css): neumorphic relief. Defines--ef-*variables (radius, edge, panel/button/pressed/well shadows, gradient surfaces, "filigree" vertical hairlines) and applies them to cards, buttons (nobox-shadowanimation, which janks in WKWebView), fields, menus, toggles, dialogs. -
Hera (
skin-hera.css): floating glass design:--hera-*variables (glass, border, hairline, vertical pinstripe--hera-lines, sheen). The optional filigree flag (data-hera-filigree="1") overlays the pinstripe on every Hera surface; on cards it is drawn on a::afterlayer above the content so GPU layer scaling does not blur it, and the content panel deliberately has none (it would moire against the glass cards). -
Materials (
materials.css):glass=color-mixof--cardwith transparency +backdrop-filter: blur(var(--macup-blur));matte= glass plus an SVG fractal-noise grain on::before(opacity--macup-noise,mix-blend-mode: overlay). Card shadow isvar(--macup-shadow), written byThemeProviderfrom the five-step shadow table (none, soft, normal, strong, very strong; forced tononeforbase). Dialogs were added to the material rules as one more surface (nopositionchange on the dialog: it isfixedand would leave its centring). -
fxSidebar("Include the side panels") extends glass/matte tosidebar-innerandtopbarslots.
Inferred: this app has no element with the sidebar-wrapper, sidebar-inset, sidebar-inner or topbar slots (I found none under src/), because those rules were inherited from the sibling project. So the "Background" setting (gradient/aurora target sidebar-inset), the Hera canvas aurora, and the fxSidebar flag currently have no visible target here; card, button, input, menu, toggle and dialog styling do apply. Check grep -rn 'data-slot="sidebar' src before relying on them.
Decision: dialogs are always opaque, whatever the material or skin. Why: with matte/glass material or the Hera skin the dialog showed what was behind it and was hard to read. The last rule of src/index.css sets [data-slot="dialog-content"] { background-color: var(--card) !important; backdrop-filter: none !important }; the grain and filigree images (drawn above the colour) still show, but nothing underneath leaks through. It is last in the file and uses !important to win over both materials.css and the skin rules. The confirm-exit sub-dialog (ConfirmarSalida in dialog.tsx) is also data-slot="dialog-content", so it follows the same rule, and the same selector list includes the tutorial card ([data-slot="tutorial-tarjeta"], see Tutorials), which floats over the dimmed screen and must stay readable for the same reason.
Cards, each backed by a useTheme() setter: Appearance (mode), Interface size (+ "Smooth scroll" check), Surface style (Plano / Zeus / Hera, + Filigree check when Hera), Background (Plain / Gradient / Aurora), Card material (Solid / Glass / Matte), Effects (Blur 0 to 40 enabled unless solid; Grain 0 to 0.5 enabled only for matte; Shadow 0 to 4; "Include the side panels" check), and Colour theme (a grid of THEMES with swatches, plus the "More themes..." button). More in Settings and Shell.
Users can add themes beyond the 43 built-in ones: the official tweakcn registry or any community theme by link/id. The web view never touches the network; Rust downloads, validates and stores them.
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 pbimg:// protocol
UI->>IPC: theme_fetch(id or link)
IPC->>RS: traer()
RS->>NET: GET /r/themes/id
RS-->>UI: TemaCatalogo (css for preview, colours)
UI->>ST: instalar(id)
ST->>IPC: theme_install
RS->>NET: theme JSON + fonts CSS + woff2 files
RS->>RS: validate vars, write temas/id.json, temas/fuentes/hash.woff2
RS-->>ST: TemaInstalado (id tw-id, css with BASE placeholder)
ST->>ST: aplicar(): replace BASE, update style#temas-tweakcn, push to THEMES, mirror in localStorage
UI->>UI: setTheme(tw-id)
Note over PR: fonts served from /f/hash.woff2
| Function | What it does |
|---|---|
id_de(texto) |
Extracts a theme id from what the user pastes: a bare id, /themes/<id>, /r/themes/<id>.json, or /editor/theme?theme=<id>. Anything that looks like a link must contain tweakcn.com/. Ids are 2 to 64 chars of [A-Za-z0-9_-]. Failure is InvalidInput with motivo = "tema_enlace". |
catalogo(externo) |
Downloads https://tweakcn.com/r/themes/registry.json (max 4 MiB) into Vec<TemaCatalogo> (id, name, 4 sample colours for light and dark, main font, preview CSS without fonts). |
traer(externo, texto) |
One theme by id/link for previewing. |
instalar(externo, datos, texto) |
Downloads the theme JSON, converts it to CSS, downloads the fonts it needs, writes <app_data>/temas/<id>.json (atomically), returns TemaInstalado { id: "tw-<id>", nombre, css, muestra }. |
instalados(datos) |
Reads all temas/*.json, sorted by name. |
quitar(datos, id) |
Deletes temas/<id>.json; font files stay (they may belong to another theme). |
letra(datos, nombre) |
Returns bytes of temas/fuentes/<sha256>.woff2 for the protocol; the name must be a 64-hex hash. |
Safety (no free-form CSS ever reaches the page): only cssVars (theme, light, dark maps) are used. Variable names must match [a-z0-9-]{1,40}; values at most 400 chars and must not contain ; { } < > \ @ or url(. The CSS is generated by the app in the exact shape of themes.css: [data-theme="tw-<id>"] { --name: value; } and the same with .dark. Any offending variable fails the whole theme with 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 in temas.rs.
Fonts: for font-sans, font-serif, font-mono the first family of the stack is taken; names are restricted to [A-Za-z0-9 -] and skipped when already shipped (YA_ESTAN, which mirrors src/fonts.css plus system fonts). For each remaining family it requests Google Fonts CSS (API v1, weights 300 to 700, latin and latin-ext) presenting as a browser (Google only serves woff2 to browsers it knows), downloads each woff2 (max 2 MiB, must start with the wOF2 magic), stores it by SHA-256 hash and rewrites the url(...) to {{PBIMG}}f/<hash>.woff2. A font that cannot be downloaded only logs a warning and the theme falls back to its stack.
Network allowlist: all downloads go through fuentes::Externo (see External Sources) whose PERMITIDOS list includes tweakcn.com /r/themes/, fonts.googleapis.com /css and fonts.gstatic.com /s/; redirects are re-checked and sizes are capped.
themes_catalog, theme_fetch({id}), theme_install({id}), themes_installed, theme_remove({id}), themes_open_community (opens https://tweakcn.com/community in the system browser with tauri_plugin_opener; the community gallery has no public listing API, so users browse there and paste a link).
The pbimg protocol (src-tauri/src/protocol.rs) has a route /f/<64-hex>.woff2 (Ruta::Letra) that returns font/woff2 with Cache-Control: max-age=31536000, immutable (the URL is a content hash). The CSP in src-tauri/tauri.conf.json allows font-src 'self' data: pbimg: http://pbimg.localhost and style-src 'unsafe-inline' (needed for the injected <style>). Path traversal is blocked by requiring the stem to be a valid hash (es_huella).
-
BASE = "{{PBIMG}}": Rust cannot know the protocol root (it differs by platform), so the stored CSS holds this placeholder andaplicar()replaces it withraizProtocolo()(src/lib/image-url.ts). -
aplicar(temas)writes all themes' CSS into one<style id="temas-tweakcn">in<head>and rebuilds thetw-*tail ofTHEMES(removes all existingtw-entries, appends the current ones withswatch = muestra).THEMESis a mutable module array; the selector andThemeProviderboth read it. -
useTemasInstalados:temas(nulluntil Rust answers),cargar(),instalar(id)(sorts by name, replaces an existing entry with the same id so reinstalling does not duplicate),quitar(id). Each mutation callsponer()= apply + remember + set state. -
recordar()mirrors the installed list inlocalStorage["proxybuilder.temasInstalados"], andprepararTemasInstalados()(called inmain.tsxbefore mount) replays it. Why:ThemeProvideronly accepts a stored theme that is inTHEMES; without the mirror, an installed theme would start with default colours until Rust answered. A corrupt mirror is ignored. -
SincronizarTemas(mounted inApp.tsx) callscargar()at startup and, if the selected theme is atw-*that no longer exists (removed, or the data folder deleted), switches toamber-minimal.TemasTweakcn.quitarTemaalso switches away first if the removed theme is the active one. - The preview in the dialog (
Muestra) injects the candidate's CSS in a<style>and renders a sample card inside an element withdata-theme="tw-<id>"(plusdarkwhen the app is in dark mode), so the real theme is not applied to the app until installed. - The catalogue list excludes ids that already match a built-in theme name.
Guard: src/lib/__tests__/temas-instalados.test.ts (install/remove do not duplicate or leave leftovers; the root placeholder is replaced; the memory restores before mount; a broken memory does not break boot).
Paste the tweakcn export as [data-theme="<name>"] plus [data-theme="<name>"].dark blocks into src/themes.css, add the name to TemaDeSerie and an entry to THEMES in src/lib/themes.ts (name, label, swatch colour). If it needs a new font family, add its woff2 under public/fonts/, an @font-face in src/fonts.css, and the lowercase family name to YA_ESTAN in src-tauri/src/temas.rs so installed themes do not re-download it. Do not edit theme-provider.tsx.
-
ThemeProvideris read-only by convention; behaviour changes go intema-inicial.ts,temas-instalados.tsand CSS. - The three storage-key constants and defaults exist in both
theme-provider.tsxandtema-inicial.ts. - Radius and border-colour overrides use
!importanton[data-theme]selectors; a new theme cannot opt out except by beingbase. - Anything translucent over the page (glass,
data-bg) needs something behind it to blur; with materialglass/matteand no background the stylesheet adds a faint radial veil for that reason. - Test skins in WebKitGTK, not Chrome (blur, backdrop-filter and GPU-layer behaviour differ).
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Set-Browser · Explorador de sets
Reference · Referencia