Skip to content

Guide Theming

MeowLynxSea edited this page Jun 13, 2026 · 9 revisions

Theming

v0.3 note: The theme is now a JSON-backed pub struct Theme(pub serde_json::Value). There is no Rust schema — renderers read paths like action.primary.bg via cx.theme().get_color(…), and your code can do the same. The two bundled system themes (system-light.json / system-dark.json) ship inside crates/yororen-ui-default-renderer/themes/. The brutalism renderer ships its own themes in crates/yororen-ui-brutalism-renderer/themes/.

How theming works

  • The active palette lives in a gpui::Global called GlobalTheme (re-exported as yororen_ui::theme).
  • Components access it via ActiveTheme (cx.theme()), which works in both App and Context<'_, T> so any render closure can call it without casting.
  • The renderer reads the same paths. If a key is missing, it falls back to renderer defaults via get_color's Option<Hsla> return type.

Recommended install (v0.3 default)

use gpui::App;
use yororen_ui::renderer;

fn init_renderer(cx: &mut App) {
    renderer::install(cx, cx.window_appearance());
}

This is the one-call bootstrap. It picks system-light.json or system-dark.json by OS appearance, installs the global Theme, and registers the 54 default XxxRenderer impls (one per headless component) against the core RendererRegistry.

Note on "38": the default-renderer and brutalism-renderer crate-level docs and the brutalism register_brutal_renderers comment both self-describe as "38" — that number is a stale comment from the v0.2 design phase. The actual source-of-truth count is 54 markers, listed in crates/yororen-ui-core/src/renderer/markers.rs (see Design-Three-Layer-Architecture).

If you build your own theme (brand palette, high-contrast variant, dark mode for a specific vertical), depend on yororen-ui-default-renderer directly and call install_with:

use gpui::App;
use yororen_ui_default_renderer::{Theme, install_with};

const MY_THEME: &str = include_str!("../themes/my-brand.json");

fn init_renderer(cx: &mut App) {
    let theme = Theme::from_json(MY_THEME).expect("valid theme JSON");
    install_with(cx, theme);
}

Theme::from_json accepts any JSON object that has the keys your renderer reads (see "Theme contract" below). Missing keys fall back to renderer defaults.

JSON structure

The bundled themes/system-light.json (verified at crates/yororen-ui-default-renderer/themes/system-light.json) has this top-level shape:

{
  "surface":   { "base": "#FFFFFF", "canvas": "#F4F4F6", "raised": "#FBFBFD",
                 "sunken": "#EFEFF2", "hover": "#E6E6EA", "popover": "#EFEFF2" },
  "content":   { "primary": "#141416", "secondary": "#3E3E45",
                 "tertiary": "#6B6B73", "disabled": "#9A9AA2",
                 "on_primary": "#FFFFFF", "on_status": "#0B0B0D" },
  "border":    { "default": "#D8D8DD", "muted": "#E3E3E8",
                 "focus": "#2F63FF", "divider": "#E3E3E8" },
  "action": {
    "neutral": { "bg": "#F1F1F3", "hover_bg": "#E6E6EA",
                 "active_bg": "#DADADF", "fg": "#141416",
                 "disabled_bg": "#E7E7EA", "disabled_fg": "#9A9AA2" },
    "primary": { "bg": "#121214", "hover_bg": "#2A2A2E",
                 "active_bg": "#404045", "fg": "#FFFFFF",
                 "disabled_bg": "#2A2A2E", "disabled_fg": "#D0D0D6" },
    "danger":  { "bg": "#FFB4AE", "hover_bg": "#FFA099",
                 "active_bg": "#FF8A82", "fg": "#0B0B0D",
                 "disabled_bg": "#F0CBC7", "disabled_fg": "#9A9AA2" }
  },
  "status": {
    "neutral": { "bg": "#E7E7EA", "fg": "#0B0B0D" },
    "success": { "bg": "#B9F5C9", "fg": "#0B0B0D" },
    "warning": { "bg": "#FFE1A6", "fg": "#0B0B0D" },
    "danger":  { "bg": "#FFB4AE", "fg": "#0B0B0D" },
    "error":   { "bg": "#FFB4AE", "fg": "#0B0B0D" },
    "info":    { "bg": "#B6D9FF", "fg": "#0B0B0D" }
  },
  "shadow": { "elevation_1": "rgba(0,0,0,0.18)",
              "elevation_2": "rgba(0,0,0,0.30)" },
  "tokens": {
    "sizes":      { "control_h_sm": 28, "control_h_md": 32, /**/ },
    "radii":      { "none": 0, "xs": 2, "sm": 4, "md": 6, "lg": 8, "xl": 12, "pill": 9999 },
    "spacing":    { "gap_1": 4, "gap_2": 8, "gap_3": 12, /**/ },
    "typography": { "font_size_md": 14, "font_size_lg": 16, /**/ },
    "motion":     { "duration_fast": 150, "duration_normal": 200, /**/ },
    "control":    {
      "button":   { "min_height": 36, "horizontal_padding": 16, "radius": 6 },
      "input":    { "min_height": 32, "horizontal_padding": 12, "radius": 4 },
      "modal":    { "min_width": 320, "max_width": 520, "padding": 24, "border_radius": 12 },
      "select":   { "min_height": 32, "menu_max_height": 260 },
      "switch":   { "track_w": 34, "track_h": 18, "knob_size": 14 }
      // … one sub-object per component
    }
  }
}

Path scheme

Anything reachable via cx.theme().get_color("path.to.color") or cx.theme().get_number("path.to.number") works. The renderer reads the same paths. Common ones:

Path What it is Used by
surface.base default card / input background Card, TextInput, Panel
surface.canvas window background root
surface.raised elevated surface Popover, Modal panel
surface.popover popover background Popover, DropdownMenu
content.primary primary text Text, Label, Heading
content.tertiary placeholder / hint text TextInput, SearchInput
content.on_primary text on action.primary.bg Primary Button
content.on_status text on status.*.bg Badge, Toast
border.focus focus ring color every focusable primitive
border.divider divider line Divider
action.primary.bg primary button background Primary Button
action.primary.hover_bg primary button hover Primary Button
action.danger.bg danger button background Danger Button
status.success.bg / .fg success color Toast, Notification
status.error.bg / .fg error color Toast, Notification
tokens.control.button.min_height button geometry Button
tokens.control.input.min_height input geometry every text input
tokens.control.modal.padding modal inner padding Modal
tokens.control.switch.knob_size switch thumb size Switch
tokens.motion.duration_fast 150 ms fast duration Tooltip, Menu open
tokens.motion.duration_normal 200 ms default most transitions

Authoring a custom theme

  1. Copy crates/yororen-ui-default-renderer/themes/system-light.json to themes/my-brand.json in your app crate.
  2. Edit the colors / tokens you want to override.
  3. Embed and pass to install_with:
use yororen_ui_default_renderer::{Theme, install_with};

const MY_THEME: &str = include_str!("../themes/my-brand.json");

fn main() {
    let app = gpui::Application::new().with_assets(yororen_ui::assets::UiAsset);
    app.run(|cx: &mut gpui::App| {
        install_with(cx, Theme::from_json(MY_THEME).expect("valid theme JSON"));
        // … rest of bootstrap …
    });
}

A minimal valid theme covers the top-level keys the renderers read: surface, content, border, action.{neutral,primary,danger}, status.{success,warning,danger,error,info,neutral}, tokens.control.<component>. Missing keys fall back to renderer defaults — so you can ship a theme that overrides only the colors that matter to your brand and inherit the rest.

Light + dark variants

The bundled system themes are light / dark variants of the same shape. Pick one based on cx.window_appearance():

use yororen_ui_default_renderer::{Theme, install_with, system_light, system_dark};

fn init_renderer(cx: &mut gpui::App) {
    let theme = match cx.window_appearance() {
        gpui::WindowAppearance::Dark | gpui::WindowAppearance::VibrantDark => system_dark(),
        gpui::WindowAppearance::Light | gpui::WindowAppearance::VibrantLight => system_light(),
    };
    install_with(cx, theme);
}

system_light() / system_dark() are exported from yororen-ui-default-renderer and parse the bundled JSON files themselves (see crates/yororen-ui-default-renderer/src/themes.rs:43-53).

For a custom brand, ship both files and switch the same way. The renderer itself is the same; only the JSON differs.

Live theme switching

For "Next theme" toolbar buttons, call yororen_ui::theme::install(cx, theme) inside the render closure on every frame. It's idempotent and cheap. The theme_showcase demo does this:

impl Render for ThemeApp {
    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        // Re-install on every render — cheap, lets a toolbar
        // button advance `self.current` and retheme the window.
        yororen_ui::theme::install(cx, self.current_theme());
        let surface = cx.theme().get_color("surface.base").unwrap_or_default();
        // … layout using surface …
    }
}

The four themes in theme_showcase are usually system-light, system-dark, and two inline const CATPPUCCIN: &str = r##"{…}"##; JSON strings. Bump self.current = (self.current + 1) % themes.len() in a click handler, cx.notify(), and the next render re-themes the window.

The brutalism renderer

For a completely different visual (sharp corners, hard shadows, monospace, high-contrast), swap renderers:

use yororen_ui::brutalism_renderer;

fn init_renderer(cx: &mut gpui::App) {
    brutalism_renderer::install(cx);
}

This is feature-gated and pulls in the yororen-ui-brutalism-renderer crate, which:

  • Loads its own JSON themes (brutalism-light.json, brutalism-dark.json) from crates/yororen-ui-brutalism-renderer/themes/.
  • Registers a parallel set of BrutalXxxRenderer impls that replace (not layer with) the default ones.
  • Picks light/dark by cx.window_appearance().

Use brutalism_renderer::install_with_default_theme(cx) to force the light variant regardless of OS appearance. Use brutalism_renderer::install_with(cx, theme) to ship a custom brutalism JSON.

The same headless::button("save", cx).on_click(...).render(cx) call works in all three renderers (default, brutalism, custom). The visual is what changes.

Theme contract (the keys renderers read)

When you provide a custom theme, the renderers need at least these keys. Missing keys fall back to defaults.

surface

  • base: default surface (cards / inputs)
  • canvas: window background
  • raised: elevated surface (popovers, title areas)
  • sunken: recessed surface (tracks, wells)
  • hover: hover surface fill
  • popover: popover / dropdown panel background

content

  • primary: primary text
  • secondary: secondary text
  • tertiary: tertiary / hint (placeholders)
  • disabled: disabled text
  • on_primary: text on action.primary.bg
  • on_status: text on status.*.bg

border

  • default: default border
  • muted: muted border
  • focus: focus ring
  • divider: divider line

action.{neutral, primary, danger} — each is an ActionVariant

  • bg
  • hover_bg
  • active_bg
  • fg
  • disabled_bg
  • disabled_fg

status.{success, warning, danger, error, info, neutral} — each is a StatusVariant

  • bg
  • fg

shadow

  • elevation_1
  • elevation_2

tokens.sizes, tokens.radii, tokens.spacing, tokens.typography, tokens.motion, tokens.control.<component>

The control sub-objects cover all components (button, input, switch, checkbox, radio, select, combo_box, slider, toast, modal, popover, dropdown, badge, tag, skeleton, progress, avatar, tooltip, disclosure, keybinding_input, split_button, search_input, number_input, file_path_input, icon_button, toggle_button, empty_state, list_item, tree_item, card, divider, form, notification, focus_ring). The system-light.json file has every key; ship it as your starting point.

Reading the theme from app code

ActiveTheme is implemented for both App and Context<'_, T>, so cx.theme() works in any render closure without casting.

use yororen_ui::theme::ActiveTheme;

let surface = cx.theme().get_color("surface.base").unwrap_or_default();
let pad     = cx.theme().get_number("tokens.control.button.horizontal_padding").unwrap_or(16.0);

get_color parses hex (#RRGGBB / #RRGGBBAA), named CSS colors, and rgba(…) strings. get_number reads f64 JSON values. Both return Option, so missing keys are safe.

See also

  • Guide-Quick-Start — bootstrap that picks up these themes
  • Guide-Composing-UI — examples of reading the theme via cx.theme() in render closures
  • codex-skills/yororen-ui-app-core §4 — the Theme global and cx.theme()
  • codex-skills/yororen-ui-recipes §6 — theme_showcase for live theme switching

Clone this wiki locally