Created
August 26, 2026 13:18
-
-
Save airstrike/895995c8e608ebafc82dec4e5207cd2b to your computer and use it in GitHub Desktop.
elevato theme as a drop-in theme.rs for iced 0.14 - Mode/Seed, semantic Palette, named style fns for text/container/button/text_editor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! elevato theme - warm paper, ink text, one lamp-green accent, all mono. | |
| //! | |
| //! The theme behind <https://elevato.rs>, lifted out as a single file you | |
| //! can drop into an iced project. Copy to `src/theme.rs`, add `mod theme;`, | |
| //! then: | |
| //! | |
| //! ```ignore | |
| //! // in your application setup | |
| //! .theme(|state| state.mode.to_theme()) | |
| //! .default_font(theme::MONO) | |
| //! | |
| //! // at call sites - always a named fn from this module, never an | |
| //! // inline style closure | |
| //! button("Start").style(theme::button::primary) | |
| //! container(pane).style(theme::container::pane) | |
| //! text("label").style(theme::text::secondary) | |
| //! ``` | |
| //! | |
| //! Two layers: | |
| //! | |
| //! - [`Mode::to_theme`] builds an iced [`Theme`] from six seed colors, so | |
| //! stock widgets (pick_list, slider, the text ladders) inherit the | |
| //! design instead of iced's defaults. | |
| //! - [`palette`] resolves a hand-picked [`Palette`] of named semantic | |
| //! colors for the current theme's brightness - for canvases and the | |
| //! style fns below, which cover surfaces beyond what a seed expresses. | |
| //! | |
| //! Solid colors only: gradients silently no-op on the wasm build. | |
| //! | |
| //! Requires iced 0.14+ (`Theme::custom(name, Seed)`, `border::rounded`). | |
| //! On wasm, [`Mode::from_env`] reads `prefers-color-scheme` through | |
| //! `web-sys` (features `Window`, `MediaQueryList`); natively it reads | |
| //! the `THEME` env var. Delete `from_env` if you'd rather decide yourself. | |
| //! | |
| //! The face is Geist Mono (OFL). It is not bundled here - embed it with | |
| //! `include_bytes!` and register it via `.font(...)`, or point [`MONO`] | |
| //! at whatever monospace you ship. | |
| //! | |
| //! MIT - Andy Terra | |
| use iced::theme::palette::Seed; | |
| use iced::{Color, Font, Theme, color}; | |
| /// Light or dark, the app-level choice driving [`Mode::to_theme`]. | |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| pub enum Mode { | |
| Light, | |
| #[default] | |
| Dark, | |
| } | |
| impl Mode { | |
| /// Reads `THEME=light|dark` natively; on the web, the browser's | |
| /// `prefers-color-scheme`. | |
| pub fn from_env() -> Self { | |
| #[cfg(not(target_arch = "wasm32"))] | |
| if let Ok(value) = std::env::var("THEME") { | |
| if value.eq_ignore_ascii_case("light") { | |
| return Self::Light; | |
| } | |
| } | |
| #[cfg(target_arch = "wasm32")] | |
| if web_sys::window() | |
| .and_then(|window| window.match_media("(prefers-color-scheme: light)").ok()) | |
| .flatten() | |
| .is_some_and(|query| query.matches()) | |
| { | |
| return Self::Light; | |
| } | |
| Self::default() | |
| } | |
| /// The other mode. | |
| pub fn toggle(self) -> Self { | |
| match self { | |
| Self::Dark => Self::Light, | |
| Self::Light => Self::Dark, | |
| } | |
| } | |
| /// Builds the iced theme from the seed colors, so stock widget | |
| /// defaults inherit the design. Surface colors beyond the seed are | |
| /// hand-picked in [`palette`]. | |
| pub fn to_theme(self) -> Theme { | |
| let (name, seed) = match self { | |
| Self::Light => ( | |
| "elevato", | |
| Seed { | |
| background: color!(0xf2efe9), | |
| text: color!(0x2b2a27), | |
| primary: color!(0x2e8c5f), | |
| success: color!(0x2e7d43), | |
| warning: color!(0xb08a2e), | |
| danger: color!(0xc0473a), | |
| }, | |
| ), | |
| Self::Dark => ( | |
| "elevato dark", | |
| Seed { | |
| background: color!(0x191816), | |
| text: color!(0xece7db), | |
| primary: color!(0x50b481), | |
| success: color!(0x5fbf77), | |
| warning: color!(0xd9c26a), | |
| danger: color!(0xd95f4e), | |
| }, | |
| ), | |
| }; | |
| Theme::custom(name.to_string(), seed) | |
| } | |
| } | |
| /// The one face. Geist Mono if you ship it; otherwise iced's monospace. | |
| pub const MONO: Font = Font::MONOSPACE; | |
| /// Named semantic colors, resolved per theme brightness by [`palette`]. | |
| /// | |
| /// The names are generic where elevato's were literal: `surface` was | |
| /// the elevator cab, `surface_strong` its header band, `figure` a | |
| /// passenger, `line` a floor baseline. | |
| #[derive(Debug, Clone, Copy)] | |
| pub struct Palette { | |
| /// Headline and value text. | |
| pub text_primary: Color, | |
| /// Labels and captions. | |
| pub text_secondary: Color, | |
| /// The world: canvas or page backdrop. | |
| pub background: Color, | |
| /// Toolbar, stats bar, and banner surfaces; the shade behind panes. | |
| pub panel: Color, | |
| /// Hairline borders and baselines. | |
| pub line: Color, | |
| /// A raised object drawn on the canvas. | |
| pub surface: Color, | |
| /// A stronger band on that object. | |
| pub surface_strong: Color, | |
| /// Text drawn on `surface`. | |
| pub text_on_surface: Color, | |
| /// A lit indicator: the accent, on. | |
| pub accent: Color, | |
| /// An unlit indicator: the accent, off. | |
| pub accent_off: Color, | |
| /// A lit button that should read warm rather than green. | |
| pub highlight: Color, | |
| /// Figures (people) on the canvas. | |
| pub figure: Color, | |
| /// Success green (banner text). | |
| pub success: Color, | |
| /// Failure red (banner text, errors). | |
| pub failure: Color, | |
| /// Editor tokens: comments. | |
| pub syntax_comment: Color, | |
| /// Editor tokens: string literals. | |
| pub syntax_string: Color, | |
| /// Editor tokens: numeric literals. | |
| pub syntax_number: Color, | |
| /// Editor tokens: `true`/`false`. | |
| pub syntax_constant: Color, | |
| /// Editor tokens: operators. | |
| pub syntax_operator: Color, | |
| /// Editor tokens: keywords. | |
| pub syntax_keyword: Color, | |
| } | |
| /// Resolves the semantic palette for the current theme's brightness. | |
| pub fn palette(theme: &Theme) -> Palette { | |
| if is_dark(theme) { | |
| // Night lobby: near-black warm surfaces, lamp-green accents. | |
| Palette { | |
| text_primary: color!(0xece7db), | |
| text_secondary: color!(0x98948a), | |
| background: color!(0x191816), | |
| panel: color!(0x22211e), | |
| line: color!(0x35332e), | |
| surface: color!(0x3b3a36), | |
| surface_strong: color!(0x2e2d2a), | |
| text_on_surface: color!(0xf0ecdf), | |
| accent: color!(0x6fd692), | |
| accent_off: color!(0x413f3a), | |
| highlight: color!(0xf2eecf), | |
| figure: color!(0xd4cfc2), | |
| success: color!(0x5fbf77), | |
| failure: color!(0xd95f4e), | |
| syntax_comment: color!(0x8a867c), | |
| syntax_string: color!(0x9ec978), | |
| syntax_number: color!(0xc9a45c), | |
| syntax_constant: color!(0x62c1c9), | |
| syntax_operator: color!(0xaaa69b), | |
| syntax_keyword: color!(0x50b481), | |
| } | |
| } else { | |
| // Daylight lobby: warm paper, ink text, the same lamp-green. | |
| Palette { | |
| text_primary: color!(0x2b2a27), | |
| text_secondary: color!(0x6f6c64), | |
| background: color!(0xf2efe9), | |
| panel: color!(0xe8e4da), | |
| line: color!(0xcfcabd), | |
| surface: color!(0xdad4c6), | |
| surface_strong: color!(0xbfb9a8), | |
| text_on_surface: color!(0x2b2a27), | |
| accent: color!(0x2e8c5f), | |
| accent_off: color!(0xbfbaab), | |
| highlight: color!(0x8a6d1f), | |
| figure: color!(0x3f3d37), | |
| success: color!(0x2e7d43), | |
| failure: color!(0xc0473a), | |
| syntax_comment: color!(0x928d80), | |
| syntax_string: color!(0x4e8d3e), | |
| syntax_number: color!(0xa07617), | |
| syntax_constant: color!(0x137e8a), | |
| syntax_operator: color!(0x6f6c64), | |
| syntax_keyword: color!(0x2e8c5f), | |
| } | |
| } | |
| } | |
| fn is_dark(theme: &Theme) -> bool { | |
| let background = theme.palette().background.base.color; | |
| 0.299 * background.r + 0.587 * background.g + 0.114 * background.b < 0.5 | |
| } | |
| pub mod text { | |
| //! Text colors for `.style()` on `text` widgets. | |
| use iced::Theme; | |
| use iced::widget::text::Style; | |
| use super::palette; | |
| /// Headline and value text. | |
| pub fn primary(theme: &Theme) -> Style { | |
| Style { | |
| color: Some(palette(theme).text_primary), | |
| } | |
| } | |
| /// Labels and captions. | |
| pub fn secondary(theme: &Theme) -> Style { | |
| Style { | |
| color: Some(palette(theme).text_secondary), | |
| } | |
| } | |
| /// Error text. | |
| pub fn failure(theme: &Theme) -> Style { | |
| Style { | |
| color: Some(palette(theme).failure), | |
| } | |
| } | |
| /// Green on success, red on failure. | |
| pub fn outcome(success: bool) -> impl Fn(&Theme) -> Style { | |
| move |theme| { | |
| let palette = palette(theme); | |
| Style { | |
| color: Some(if success { | |
| palette.success | |
| } else { | |
| palette.failure | |
| }), | |
| } | |
| } | |
| } | |
| } | |
| pub mod container { | |
| //! Surface styles for `.style()` on `container` widgets. | |
| use iced::widget::container::Style; | |
| use iced::{Theme, border}; | |
| use super::palette; | |
| /// The root backdrop behind everything: the panel shade, so panes | |
| /// (drawn in the background shade) read as raised cards against it. | |
| pub fn root(theme: &Theme) -> Style { | |
| let palette = palette(theme); | |
| Style { | |
| background: Some(palette.panel.into()), | |
| text_color: Some(palette.text_primary), | |
| ..Style::default() | |
| } | |
| } | |
| /// A workspace card. Give every pane the same rounded surface so | |
| /// the halves of a split are twins. | |
| pub fn pane(theme: &Theme) -> Style { | |
| let palette = palette(theme); | |
| Style { | |
| background: Some(palette.background.into()), | |
| border: border::rounded(8.0).color(palette.line).width(1), | |
| ..Style::default() | |
| } | |
| } | |
| /// Toolbar and stats-bar strips. | |
| pub fn panel(theme: &Theme) -> Style { | |
| Style { | |
| background: Some(palette(theme).panel.into()), | |
| ..Style::default() | |
| } | |
| } | |
| /// A card floating over the canvas. | |
| pub fn banner(theme: &Theme) -> Style { | |
| let palette = palette(theme); | |
| Style { | |
| background: Some(palette.panel.into()), | |
| border: border::rounded(8.0).color(palette.line).width(1), | |
| ..Style::default() | |
| } | |
| } | |
| /// A strip that carries an error. | |
| pub fn error_panel(theme: &Theme) -> Style { | |
| let palette = palette(theme); | |
| Style { | |
| background: Some(palette.panel.into()), | |
| border: border::rounded(4.0).color(palette.failure).width(1), | |
| ..Style::default() | |
| } | |
| } | |
| } | |
| pub mod text_editor { | |
| //! Styles for `.style()` on `text_editor` widgets. | |
| use iced::widget::text_editor::{Status, Style}; | |
| use iced::{Border, Color, Theme}; | |
| use super::palette; | |
| /// A chromeless code editor: the pane it sits in provides the | |
| /// surface and border, so editor and canvas dress identically. | |
| pub fn code(theme: &Theme, _status: Status) -> Style { | |
| let palette = palette(theme); | |
| Style { | |
| background: Color::TRANSPARENT.into(), | |
| border: Border::default(), | |
| placeholder: palette.text_secondary, | |
| value: palette.text_primary, | |
| selection: Color { | |
| a: 0.35, | |
| ..palette.surface | |
| }, | |
| } | |
| } | |
| } | |
| pub mod button { | |
| //! Button styles for `.style()` on `button` widgets: a deliberate | |
| //! hierarchy - one filled `primary` per surface, `outline` for | |
| //! secondary actions, `ghost` for tertiary ones. | |
| use iced::widget::button::{Status, Style}; | |
| use iced::{Border, Theme, border}; | |
| use super::palette; | |
| /// Filled accent - the surface's main action. | |
| pub fn primary(theme: &Theme, status: Status) -> Style { | |
| let ladder = theme.palette(); | |
| let base = Style { | |
| background: Some(ladder.primary.base.color.into()), | |
| text_color: ladder.primary.base.text, | |
| border: border::rounded(6), | |
| ..Style::default() | |
| }; | |
| match status { | |
| Status::Active | Status::Pressed => base, | |
| Status::Hovered => Style { | |
| background: Some(ladder.primary.strong.color.into()), | |
| ..base | |
| }, | |
| Status::Disabled => faded(base), | |
| } | |
| } | |
| /// Outlined, quiet - secondary actions. | |
| pub fn outline(theme: &Theme, status: Status) -> Style { | |
| let palette = palette(theme); | |
| let base = Style { | |
| background: None, | |
| text_color: palette.text_primary, | |
| border: Border { | |
| color: palette.line, | |
| width: 1.0, | |
| radius: 6.0.into(), | |
| }, | |
| ..Style::default() | |
| }; | |
| match status { | |
| Status::Active | Status::Pressed => base, | |
| Status::Hovered => Style { | |
| background: Some(palette.panel.into()), | |
| border: Border { | |
| color: palette.text_secondary, | |
| ..base.border | |
| }, | |
| ..base | |
| }, | |
| Status::Disabled => faded(base), | |
| } | |
| } | |
| /// Bare - tertiary actions. | |
| pub fn ghost(theme: &Theme, status: Status) -> Style { | |
| let palette = palette(theme); | |
| let base = Style { | |
| background: None, | |
| text_color: palette.text_secondary, | |
| border: border::rounded(6), | |
| ..Style::default() | |
| }; | |
| match status { | |
| Status::Active | Status::Pressed => base, | |
| Status::Hovered => Style { | |
| background: Some(palette.panel.into()), | |
| text_color: palette.text_primary, | |
| ..base | |
| }, | |
| Status::Disabled => faded(base), | |
| } | |
| } | |
| /// [`ghost`], except a disabled button disappears entirely while | |
| /// still occupying its space - for paired affordances (back/forward | |
| /// arrows) whose layout must not shift as each end is reached. | |
| pub fn vanishing(theme: &Theme, status: Status) -> Style { | |
| match status { | |
| Status::Disabled => Style { | |
| background: None, | |
| text_color: iced::Color::TRANSPARENT, | |
| border: border::rounded(6), | |
| ..Style::default() | |
| }, | |
| status => ghost(theme, status), | |
| } | |
| } | |
| /// A text link: bare accent text that brightens on hover. | |
| pub fn link(theme: &Theme, status: Status) -> Style { | |
| let ladder = theme.palette(); | |
| let base = Style { | |
| background: None, | |
| text_color: ladder.primary.base.color, | |
| border: border::rounded(6), | |
| ..Style::default() | |
| }; | |
| match status { | |
| Status::Active | Status::Pressed => base, | |
| Status::Hovered => Style { | |
| text_color: ladder.primary.strong.color, | |
| ..base | |
| }, | |
| Status::Disabled => faded(base), | |
| } | |
| } | |
| /// A tab: quiet when inactive, raised on the panel shade when active. | |
| pub fn tab(active: bool) -> impl Fn(&Theme, Status) -> Style { | |
| move |theme, status| { | |
| let palette = palette(theme); | |
| let base = if active { | |
| Style { | |
| background: Some(palette.panel.into()), | |
| text_color: palette.text_primary, | |
| border: border::rounded(6), | |
| ..Style::default() | |
| } | |
| } else { | |
| Style { | |
| background: None, | |
| text_color: palette.text_secondary, | |
| border: border::rounded(6), | |
| ..Style::default() | |
| } | |
| }; | |
| match status { | |
| Status::Active | Status::Pressed => base, | |
| Status::Hovered => Style { | |
| text_color: palette.text_primary, | |
| background: Some(palette.panel.into()), | |
| ..base | |
| }, | |
| Status::Disabled => faded(base), | |
| } | |
| } | |
| } | |
| fn faded(base: Style) -> Style { | |
| Style { | |
| text_color: base.text_color.scale_alpha(0.4), | |
| background: base | |
| .background | |
| .map(|background| background.scale_alpha(0.4)), | |
| border: Border { | |
| color: base.border.color.scale_alpha(0.4), | |
| ..base.border | |
| }, | |
| ..base | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment