-
Notifications
You must be signed in to change notification settings - Fork 9
Guide 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 likeaction.primary.bgviacx.theme().get_color(…), and your code can do the same. The two bundled system themes (system-light.json/system-dark.json) ship insidecrates/yororen-ui-default-renderer/themes/. The brutalism renderer ships its own themes incrates/yororen-ui-brutalism-renderer/themes/.
- The active palette lives in a
gpui::GlobalcalledGlobalTheme(re-exported asyororen_ui::theme). - Components access it via
ActiveTheme(cx.theme()), which works in bothAppandContext<'_, 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'sOption<Hsla>return type.
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_rendererscomment 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 incrates/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.
The bundled themes/system-light.json (verified at
crates/yororen-ui-default-renderer/themes/system-light.json)
has this top-level shape:
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 |
- Copy
crates/yororen-ui-default-renderer/themes/system-light.jsontothemes/my-brand.jsonin your app crate. - Edit the colors / tokens you want to override.
- 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.
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.
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.
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) fromcrates/yororen-ui-brutalism-renderer/themes/. - Registers a parallel set of
BrutalXxxRendererimpls 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.
When you provide a custom theme, the renderers need at least these keys. Missing keys fall back to defaults.
-
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
-
primary: primary text -
secondary: secondary text -
tertiary: tertiary / hint (placeholders) -
disabled: disabled text -
on_primary: text onaction.primary.bg -
on_status: text onstatus.*.bg
-
default: default border -
muted: muted border -
focus: focus ring -
divider: divider line
bghover_bgactive_bgfgdisabled_bgdisabled_fg
bgfg
elevation_1elevation_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.
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.
- 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 — theThemeglobal andcx.theme() -
codex-skills/yororen-ui-recipes§6 —theme_showcasefor live theme switching
Yororen UI v0.3.0 · repository · Apache-2.0 · This wiki documents Yororen UI v0.3.0.
This wiki documents Yororen UI v0.3.0 — the headless-core, swappable-renderer build.
{ "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 } } }