Skip to content

Guide Theming

MeowLynxSea edited this page Jun 2, 2026 · 9 revisions

Theming

v0.3: The default light/dark palette now ships in the yororen-ui-theme-system crate (re-exported from the meta-crate as yororen_ui::theme_system). Components read colors and design tokens from the same cx.theme(); tokens let you resize the entire UI without touching component code.

How theming works

  • Yororen UI stores the active palette in a gpui::Global called GlobalTheme.
  • Components access it via ActiveTheme (cx.theme()).
  • Theme::tokens exposes DesignTokens (sizes, radii, spacing, typography, motion, and per-control geometry).

Recommended install (v0.3)

use gpui::App;
use yororen_ui::theme_system;

fn init_theme(cx: &mut App) {
    theme_system::install(cx, cx.window_appearance());
}

theme_system::install builds a ThemeSet containing the default light and dark palettes and registers it as the global theme. If your app has its own asset bundle you can still ship your own colors — depend on yororen-ui-core directly and call GlobalTheme::new_with_themes(...) yourself (see below).

Default behavior (core only)

If you depend on yororen-ui-core directly (without the theme package) and want the default palette without the helper, you can still call:

use gpui::App;
use yororen_ui::theme::GlobalTheme;

fn init_theme(cx: &mut App) {
    cx.set_global(GlobalTheme::new(cx.window_appearance()));
}

Yororen UI will choose Theme::default_light() or Theme::default_dark() based on the current WindowAppearance.

Custom palettes

Use ThemeSet + GlobalTheme::new_with_themes.

  • Light theme is required.
  • Dark theme is optional.
  • If you do not provide a dark theme, Yororen UI will always use the light theme (even when the system/window appearance is dark).
use gpui::App;
use yororen_ui::theme::{GlobalTheme, Theme, ThemeSet};

fn init_theme(cx: &mut App) {
    let light = Theme::default_light();

    // Optional:
    let dark = Theme::default_dark();

    cx.set_global(GlobalTheme::new_with_themes(
        cx.window_appearance(),
        ThemeSet::new(light).dark(dark),
    ));
}

Light-only (force light mode)

use gpui::App;
use yororen_ui::theme::{GlobalTheme, Theme, ThemeSet};

fn init_theme(cx: &mut App) {
    let light = Theme::default_light();

    // No `.dark(...)`.
    cx.set_global(GlobalTheme::new_with_themes(
        cx.window_appearance(),
        ThemeSet::new(light),
    ));
}

Customizing specific colors

Start from a default theme and override individual fields. The example below changes the focus ring to indigo and uses a tinted canvas background:

use gpui::{rgb, App};
use yororen_ui::theme::{GlobalTheme, Theme, ThemeSet};

fn init_theme(cx: &mut App) {
    let mut light = Theme::default_light();

    // Brand-colored focus ring
    light.border.focus = rgb(0x6366F1).into();

    // Slightly tinted canvas background
    light.surface.canvas = rgb(0xF0F4FF).into();

    // Custom primary action color (e.g. indigo button)
    light.action.primary.bg = rgb(0x6366F1).into();
    light.action.primary.hover_bg = rgb(0x4F46E5).into();
    light.action.primary.active_bg = rgb(0x4338CA).into();

    cx.set_global(GlobalTheme::new_with_themes(
        cx.window_appearance(),
        ThemeSet::new(light).dark(Theme::default_dark()),
    ));
}

Tip: You can apply the same technique to build a fully custom dark theme by starting from Theme::default_dark() and overriding the fields you need.

Theme contract (required colors)

Yororen UI's Theme is not just a "primary color". It's a full color contract used across components.

When you provide a custom theme, you must provide a complete Theme value for the light palette (and optionally another complete Theme for the dark palette).

Theme

  • surface

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

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

    • default: default border
    • muted: muted border
    • focus: focus ring / focused border
    • divider: divider line
  • action

    • neutral, primary, danger (each is an ActionVariant)
  • status

    • success, warning, error, info (each is a StatusVariant)
  • shadow

    • elevation_1
    • elevation_2
  • text_direction: text direction (Ltr or Rtl). See Internationalization for RTL support.

ActionVariant

Each action variant must provide:

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

StatusVariant

Each status variant must provide:

  • bg
  • fg

Accessibility and consistency tips

  • Ensure adequate contrast for at least these pairs:
    • surface.base vs content.primary
    • action.neutral.bg vs action.neutral.fg
    • action.primary.bg vs action.primary.fg
    • status.*.bg vs status.*.fg
  • Keep content.on_primary / content.on_status as "text on top of a solid color" values.

Validating a theme (CI)

The theme::validate function returns a list of Issues for a given Theme. The bundled light/dark palettes always pass with zero issues — run it on your custom themes in CI to catch low-contrast text and out-of-range token values before shipping.

use yororen_ui::theme::{validate, IssueKind};

let issues = validate(&theme);
for issue in &issues {
    eprintln!("[theme] {}", issue.message);
}
assert!(issues.is_empty(), "theme validation failed");

Currently validate checks:

  • Contrast ratio for surface.base/content.primary, action.{neutral,primary,danger}, all status.* (min 4.5:1), and border.focus on surface.base (min 3.0:1).
  • Switch geometry: tokens.control.switch.knob_size must fit within track_h - padding*2.
  • Range checks on motion.pulse_min_opacity / motion.pulse_max_opacity (must lie in [0.0, 1.0]).

IssueKind is a public enum (ContrastTooLow, TokenOutOfRange, StatusTextInvisible, MalformedControlGeometry) so you can write asserts in tests.

Design tokens (v0.3)

Every Theme carries a DesignTokens struct that components read instead of hardcoded px(...) literals. Override fields in your custom theme to reshape the entire UI without editing component code.

use gpui::px;
use yororen_ui::theme::{GlobalTheme, ThemeSet};

let mut dark = yororen_ui::theme_system::dark();

// Larger switch thumb for a denser, more "physical" feel
dark.tokens.control.switch.knob_size = px(18.);

// Slightly tighter spacing globally
dark.tokens.spacing.gap_2 = px(6.);

// Slow down modal motion
dark.tokens.motion.duration_modal_fade = std::time::Duration::from_millis(280);

cx.set_global(GlobalTheme::new_with_themes(
    cx.window_appearance(),
    ThemeSet::new(yororen_ui::theme_system::light()).dark(dark),
));

Available namespaces (see yororen_ui::theme::tokens):

  • tokens.sizes — control heights (control_h_xxscontrol_h_xl), icon sizes (icon_xxsicon_xl), avatar sizes, status_dot.
  • tokens.radiinone, xs (2), sm (4), md (6), lg (8), xl (12), pill (9999).
  • tokens.spacinggap_0gap_7 and inset_xsinset_xl.
  • tokens.typographyfont_size_xxsfont_size_2xl, line heights, weight_*, family_default, family_mono.
  • tokens.motionduration_* (in Duration) and easing_* (EasingFn = fn(f32) -> f32), plus pulse_*_opacity, slide_distance, bounce_distance.
  • tokens.control — one sub-struct per component (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).

Defaults are pinned to the v0.2 pixel values so existing apps see no visual change after the v0.3 migration.

Clone this wiki locally