Skip to content

Themes and Skins

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

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.

Layers at a glance

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 map

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).

Defaults and the pre-mount step

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).

Storage keys (written by ThemeProvider)

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.

Tokens and house overrides (src/index.css)

  • 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-*) so bg-card, text-muted-foreground etc. work. Dark mode variant: @custom-variant dark (&:is(.dark *)).
  • Radius 14 px: :root, [data-theme], [data-theme].dark { --radius: 0.875rem !important }. The !important is needed to beat each theme's own .dark radius. Because the selector is [data-theme] (any value), installed tw-* themes get it too. Tailwind radii derive from it (--radius-sm/md/lg/xl = ±4/2 px).
  • One border colour: --input: var(--border) !important everywhere. Several tweakcn palettes make --input noticeably 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 base theme 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.625rem and the stock --input for [data-theme="base"] and .dark. ThemeProvider additionally renders base clean: 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 when theme === "base".
  • Density (10 px) and base text size text-xs are component-level conventions (p-2.5, gap-2.5, text-xs, text-[11px]) rather than tokens; see Conventions.
  • Scrollbars are themed (thin, color-mix of --primary into --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 to in oklab if a skin gives --card a hue).
  • Active segmented control is detected by aria-checked/aria-pressed, never data-state="on", because TooltipTrigger asChild overwrites the item's data-state.
  • prefers-reduced-motion: reduce disables all animations/transitions except animate-spin/animate-pulse (a frozen spinner looks like a hang).

UI scale

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).

Skins and materials

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 (no box-shadow animation, 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 ::after layer 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-mix of --card with 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 is var(--macup-shadow), written by ThemeProvider from the five-step shadow table (none, soft, normal, strong, very strong; forced to none for base). Dialogs were added to the material rules as one more surface (no position change on the dialog: it is fixed and would leave its centring).
  • fxSidebar ("Include the side panels") extends glass/matte to sidebar-inner and topbar slots.

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.

Modals are 100 % opaque

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.

The Settings controls (appearance.tsx)

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.

Installable tweakcn themes

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
Loading

Rust side (src-tauri/src/temas.rs)

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.

Commands (src-tauri/src/commands/temas.rs, rows in src/lib/ipc.ts)

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).

Serving the fonts

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).

Frontend side (src/lib/temas-instalados.ts)

  • BASE = "{{PBIMG}}": Rust cannot know the protocol root (it differs by platform), so the stored CSS holds this placeholder and aplicar() replaces it with raizProtocolo() (src/lib/image-url.ts).
  • aplicar(temas) writes all themes' CSS into one <style id="temas-tweakcn"> in <head> and rebuilds the tw-* tail of THEMES (removes all existing tw- entries, appends the current ones with swatch = muestra). THEMES is a mutable module array; the selector and ThemeProvider both read it.
  • useTemasInstalados: temas (null until 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 calls poner() = apply + remember + set state.
  • recordar() mirrors the installed list in localStorage["proxybuilder.temasInstalados"], and prepararTemasInstalados() (called in main.tsx before mount) replays it. Why: ThemeProvider only accepts a stored theme that is in THEMES; without the mirror, an installed theme would start with default colours until Rust answered. A corrupt mirror is ignored.
  • SincronizarTemas (mounted in App.tsx) calls cargar() at startup and, if the selected theme is a tw-* that no longer exists (removed, or the data folder deleted), switches to amber-minimal. TemasTweakcn.quitarTema also 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 with data-theme="tw-<id>" (plus dark when 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).

Adding a built-in theme

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.

Gotchas

  • ThemeProvider is read-only by convention; behaviour changes go in tema-inicial.ts, temas-instalados.ts and CSS.
  • The three storage-key constants and defaults exist in both theme-provider.tsx and tema-inicial.ts.
  • Radius and border-colour overrides use !important on [data-theme] selectors; a new theme cannot opt out except by being base.
  • Anything translucent over the page (glass, data-bg) needs something behind it to blur; with material glass/matte and 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).

See also

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