app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit e226b1870fb65e1a3a1e182149d04830eb546239
parent bda3fa80b703901f6f29e2bcafabd4859d9a1fce
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 07:59:54 +0000

app-lib: add theme helpers

- add theme mode and layer types

- add get_system_theme and theme_set helpers

- add theme error handling and unit tests

- enable media query support in web sys

Diffstat:
MCargo.toml | 1+
Mcrates/app-lib/src/lib.rs | 4++++
Acrates/app-lib/src/theme.rs | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 132 insertions(+), 0 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -53,6 +53,7 @@ web-sys = { version = "0.3.77", features = [ "CacheStorage", "Headers", "Geolocation", + "MediaQueryList", "Navigator", "Notification", "NotificationOptions", diff --git a/crates/app-lib/src/lib.rs b/crates/app-lib/src/lib.rs @@ -9,6 +9,7 @@ pub mod query; pub mod sleep; pub mod storage; pub mod symbols; +pub mod theme; pub use browser::{browser_platform, BrowserPlatformInfo}; pub use fetch::{fetch_json, FetchJsonError, FetchJsonErrorKind, FetchJsonResult}; @@ -21,3 +22,6 @@ pub use storage::{build_storage_key, build_storage_key_with_prefix, fmt_id, fmt_ pub use symbols::{ fmt_cl, value_constrain, SYMBOL_BULLET, SYMBOL_DASH, SYMBOL_DOWN, SYMBOL_PERCENT, SYMBOL_UP, }; +pub use theme::{ + get_system_theme, parse_layer, theme_set, ThemeError, ThemeLayer, ThemeMode, +}; diff --git a/crates/app-lib/src/theme.rs b/crates/app-lib/src/theme.rs @@ -0,0 +1,127 @@ +#![forbid(unsafe_code)] + +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemeMode { + Light, + Dark, +} + +impl ThemeMode { + pub const fn as_str(self) -> &'static str { + match self { + ThemeMode::Light => "light", + ThemeMode::Dark => "dark", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemeLayer { + Layer0, + Layer1, + Layer2, +} + +impl ThemeLayer { + pub const fn as_u8(self) -> u8 { + match self { + ThemeLayer::Layer0 => 0, + ThemeLayer::Layer1 => 1, + ThemeLayer::Layer2 => 2, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemeError { + WindowUnavailable, + DocumentUnavailable, + ElementUnavailable, +} + +impl fmt::Display for ThemeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ThemeError::WindowUnavailable => f.write_str("error.app.theme.window_unavailable"), + ThemeError::DocumentUnavailable => f.write_str("error.app.theme.document_unavailable"), + ThemeError::ElementUnavailable => f.write_str("error.app.theme.element_unavailable"), + } + } +} + +impl std::error::Error for ThemeError {} + +pub fn parse_layer(layer: Option<i32>, fallback: Option<ThemeLayer>) -> ThemeLayer { + match layer { + Some(0) => ThemeLayer::Layer0, + Some(1) => ThemeLayer::Layer1, + Some(2) => ThemeLayer::Layer2, + _ => fallback.unwrap_or(ThemeLayer::Layer0), + } +} + +pub fn get_system_theme(fallback: ThemeMode) -> ThemeMode { + #[cfg(target_arch = "wasm32")] + { + if let Some(window) = web_sys::window() { + if let Ok(Some(query)) = window.match_media("(prefers-color-scheme: dark)") { + if query.matches() { + return ThemeMode::Dark; + } + return ThemeMode::Light; + } + } + fallback + } + #[cfg(not(target_arch = "wasm32"))] + { + fallback + } +} + +pub fn theme_set(theme_key: &str, color_mode: ThemeMode) -> Result<(), ThemeError> { + #[cfg(target_arch = "wasm32")] + { + let window = web_sys::window().ok_or(ThemeError::WindowUnavailable)?; + let document = window.document().ok_or(ThemeError::DocumentUnavailable)?; + let element = document.document_element().ok_or(ThemeError::ElementUnavailable)?; + let value = format!("{theme_key}_{}", color_mode.as_str()); + element + .set_attribute("data-theme", &value) + .map_err(|_| ThemeError::ElementUnavailable)?; + Ok(()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = theme_key; + let _ = color_mode; + Err(ThemeError::WindowUnavailable) + } +} + +#[cfg(test)] +mod tests { + use super::{get_system_theme, parse_layer, theme_set, ThemeError, ThemeLayer, ThemeMode}; + + #[test] + fn parse_layer_handles_fallback() { + assert_eq!(parse_layer(Some(2), None).as_u8(), 2); + assert_eq!( + parse_layer(Some(4), Some(ThemeLayer::Layer1)), + ThemeLayer::Layer1 + ); + } + + #[test] + fn get_system_theme_uses_fallback() { + assert_eq!(get_system_theme(ThemeMode::Dark), ThemeMode::Dark); + } + + #[test] + fn theme_set_errors_on_non_wasm() { + let err = theme_set("radroots", ThemeMode::Light).expect_err("non-wasm error"); + assert_eq!(err, ThemeError::WindowUnavailable); + } +}