A political conference and discussion platform, in Rust and Dioxus
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326use dioxus::prelude::*;use material_colors::{ color::Argb, scheme::Scheme, theme::{ColorGroup, CustomColor, ThemeBuilder},};
#[derive(Clone, Debug, PartialEq)]pub enum ThemeMode { Light, Dark,}
impl ThemeMode { pub fn data_attr(&self) -> &'static str { match self { ThemeMode::Light => "light", ThemeMode::Dark => "dark", } }
fn from_attr(s: &str) -> Self { match s { "dark" => ThemeMode::Dark, _ => ThemeMode::Light, } }}
pub static THEME: GlobalSignal<ThemeMode> = Signal::global(|| ThemeMode::Light);
pub fn use_theme() -> Signal<ThemeMode> { THEME.signal()}
/// Apply theme to the document element, and sync the browser chrome colour/// (`<meta name="theme-color">`) to the active scheme's primary so the mobile/// address bar / status bar matches light vs dark (it was a static value).pub fn apply_theme(mode: &ThemeMode) { let Some(window) = web_sys::window() else { return; }; let Some(doc) = window.document() else { return; }; if let Some(el) = doc.document_element() { let _ = el.set_attribute("data-theme", mode.data_attr()); // Read the now-active --md-primary and push it to the theme-color meta. if let Ok(Some(style)) = window.get_computed_style(&el) { let color = style .get_property_value("--md-primary") .unwrap_or_default() .trim() .to_string(); if !color.is_empty() { if let Some(meta) = doc .query_selector("meta[name=\"theme-color\"]") .ok() .flatten() { let _ = meta.set_attribute("content", &color); } } } }}
/// Persist the chosen theme so it survives a reload.pub fn save_theme(mode: &ThemeMode) { if let Some(storage) = local_storage() { let _ = storage.set_item("wiki_theme", mode.data_attr()); }}
/// Load the persisted theme (defaults to Light) into the global signal.pub fn load_theme() { if let Some(storage) = local_storage() { if let Ok(Some(v)) = storage.get_item("wiki_theme") { *THEME.write() = ThemeMode::from_attr(&v); } }}
fn local_storage() -> Option<web_sys::Storage> { web_sys::window()?.local_storage().ok().flatten()}
// ─── Material Design 3 seed colours ──────────────────────────────────────────//// The app ships a baked M3 scheme (assets/m3-theme.css) generated by// scripts/gen-theme.ts from two brand seeds. Here we let a user re-skin the app// at runtime by picking their own primary + accent seeds: we regenerate the full// tonal scheme (the same material-color-utilities engine) and inject the// resulting `--md-sys-color-*` tokens as a late `<style>` that overrides the// baked ones (the app's `--md-*` aliases read them live, so everything re-skins).
/// Brand default seeds. MUST match the defaults in scripts/gen-theme.ts.pub const BRAND_PRIMARY: &str = "#02944F";pub const BRAND_ACCENT: &str = "#D2307E";
const STYLE_ID: &str = "wiki-user-theme";
/// The user's chosen seeds. `None` means "use the brand default" (no override).pub static SEED_PRIMARY: GlobalSignal<Option<String>> = Signal::global(|| None);pub static SEED_ACCENT: GlobalSignal<Option<String>> = Signal::global(|| None);
/// The active primary/accent (the user's override, else the brand default).pub fn effective_primary() -> String { SEED_PRIMARY .read() .clone() .unwrap_or_else(|| BRAND_PRIMARY.to_string())}pub fn effective_accent() -> String { SEED_ACCENT .read() .clone() .unwrap_or_else(|| BRAND_ACCENT.to_string())}
/// Set the primary seed, re-skin, and persist. Choosing the brand default clears/// the override, so selecting the first swatch restores the exact baked theme.pub fn set_primary_seed(hex: &str) { *SEED_PRIMARY.write() = brand_or_seed(hex, BRAND_PRIMARY); apply_seeds(); save_seeds();}
/// Set the accent seed, re-skin, and persist. Choosing the brand default clears/// the override.pub fn set_accent_seed(hex: &str) { *SEED_ACCENT.write() = brand_or_seed(hex, BRAND_ACCENT); apply_seeds(); save_seeds();}
/// `None` (use the baked default) when the pick equals the brand seed, else the/// pick. A runtime scheme can't reproduce the baked one byte-for-byte (that uses/// the legacy CorePalette engine), so a brand pick reverts rather than re-skins.fn brand_or_seed(hex: &str, brand: &str) -> Option<String> { if hex.eq_ignore_ascii_case(brand) { None } else { Some(hex.to_string()) }}
/// Regenerate + inject the scheme from the current seeds, or clear the override/// when neither is set. Also refreshes the browser chrome colour.pub fn apply_seeds() { let primary = SEED_PRIMARY.read().clone(); let accent = SEED_ACCENT.read().clone(); if primary.is_none() && accent.is_none() { clear_scheme_css(); } else if let Some(css) = generate_scheme_css( primary.as_deref().unwrap_or(BRAND_PRIMARY), accent.as_deref().unwrap_or(BRAND_ACCENT), ) { inject_scheme_css(&css); } // The override may change --md-primary, which drives the mobile chrome colour. apply_theme(&THEME.read());}
/// Persist the current seeds (removing the keys when unset).pub fn save_seeds() { let Some(storage) = local_storage() else { return; }; match SEED_PRIMARY.read().as_deref() { Some(p) => { let _ = storage.set_item("wiki_seed_primary", p); } None => { let _ = storage.remove_item("wiki_seed_primary"); } } match SEED_ACCENT.read().as_deref() { Some(a) => { let _ = storage.set_item("wiki_seed_accent", a); } None => { let _ = storage.remove_item("wiki_seed_accent"); } }}
/// Load persisted seeds into the signals and apply them. Call once at startup.pub fn load_seeds() { if let Some(storage) = local_storage() { if let Ok(Some(v)) = storage.get_item("wiki_seed_primary") { *SEED_PRIMARY.write() = Some(v); } if let Ok(Some(v)) = storage.get_item("wiki_seed_accent") { *SEED_ACCENT.write() = Some(v); } } apply_seeds();}
/// Build the light + dark `--md-sys-color-*` override CSS from a primary + accent/// seed. The primary sources the whole tonal scheme; the accent becomes the/// tertiary role (a fixed-tone custom colour, matching scripts/gen-theme.ts).fn generate_scheme_css(primary: &str, accent: &str) -> Option<String> { let source: Argb = primary.parse().ok()?; let accent: Argb = accent.parse().ok()?; let theme = ThemeBuilder::with_source(source) .custom_colors(vec![CustomColor { value: accent, name: String::from("accent"), blend: false, }]) .build(); let group = theme.custom_colors.first()?; // The `:root` on the end is not decoration — it outranks the baked theme. // // m3-theme.css uses these very selectors, so with matching specificity the // cascade falls back to document order, and the override only won when its // <style> happened to sit after the stylesheet. It does when the element is // created by a colour change (the app's <link>s are long since in the head) // and does not when it is created at startup from a saved seed, which runs // before them. Hence: changing colours worked in a session that began on the // brand defaults, and did nothing in one that began on a saved override. // // Repeating `:root` adds a pseudo-class, so these rules win on specificity // whatever the order — and stay winning if the stylesheets move again. let light = scheme_block( ":root:root,\nhtml[data-theme=\"light\"]:root", &theme.schemes.light, &group.light, ); let dark = scheme_block( "html[data-theme=\"dark\"]:root", &theme.schemes.dark, &group.dark, ); Some(format!("{light}\n{dark}"))}
/// One CSS rule mapping the scheme roles to `--md-sys-color-*`. Tertiary comes/// from the accent colour group; every other role from the generated scheme.fn scheme_block(selector: &str, s: &Scheme, accent: &ColorGroup) -> String { let h = |c: Argb| c.to_hex_with_pound(); let tokens = [ ("primary", h(s.primary)), ("on-primary", h(s.on_primary)), ("primary-container", h(s.primary_container)), ("on-primary-container", h(s.on_primary_container)), ("secondary", h(s.secondary)), ("on-secondary", h(s.on_secondary)), ("secondary-container", h(s.secondary_container)), ("on-secondary-container", h(s.on_secondary_container)), ("tertiary", h(accent.color)), ("on-tertiary", h(accent.on_color)), ("tertiary-container", h(accent.color_container)), ("on-tertiary-container", h(accent.on_color_container)), ("error", h(s.error)), ("on-error", h(s.on_error)), ("error-container", h(s.error_container)), ("on-error-container", h(s.on_error_container)), ("background", h(s.background)), ("on-background", h(s.on_background)), ("surface", h(s.surface)), ("on-surface", h(s.on_surface)), ("surface-variant", h(s.surface_variant)), ("on-surface-variant", h(s.on_surface_variant)), ("surface-dim", h(s.surface_dim)), ("surface-bright", h(s.surface_bright)), ("surface-container-lowest", h(s.surface_container_lowest)), ("surface-container-low", h(s.surface_container_low)), ("surface-container", h(s.surface_container)), ("surface-container-high", h(s.surface_container_high)), ("surface-container-highest", h(s.surface_container_highest)), ("outline", h(s.outline)), ("outline-variant", h(s.outline_variant)), ("shadow", h(s.shadow)), ("scrim", h(s.scrim)), ("inverse-surface", h(s.inverse_surface)), ("inverse-on-surface", h(s.inverse_on_surface)), ("inverse-primary", h(s.inverse_primary)), ("surface-tint", h(s.primary)), ]; let mut out = String::new(); out.push_str(selector); out.push_str(" {\n"); for (role, value) in &tokens { out.push_str(" --md-sys-color-"); out.push_str(role); out.push_str(": "); out.push_str(value); out.push_str(";\n"); } out.push_str("}\n"); out}
/// Insert or update the runtime override `<style>` in `<head>`.fn inject_scheme_css(css: &str) { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return; }; let style = match doc.get_element_by_id(STYLE_ID) { Some(el) => el, None => { let Ok(el) = doc.create_element("style") else { return; }; let _ = el.set_attribute("id", STYLE_ID); if let Some(head) = doc.head() { let _ = head.append_child(el.as_ref()); } el } }; style.set_text_content(Some(css));}
/// Empty the override `<style>`, reverting to the baked brand scheme.fn clear_scheme_css() { if let Some(doc) = web_sys::window().and_then(|w| w.document()) { if let Some(el) = doc.get_element_by_id(STYLE_ID) { el.set_text_content(Some("")); } }}