crates/gunnar-ui-web/src/theme.rs
//! **The console's three themes and the order the switch walks them**, on the
//! host side of the wasm boundary (owner, 2026-10-05: "a third theme,
//! surprise"): ☀ light → ☾ dark → ✦ surprise → ☀ light.
//!
//! Each theme is a block of token values in `styles/console.css`, selected by
//! the `data-theme` attribute on `<html>`; light is the ABSENCE of the
//! attribute, so a console that never touched the switch is the console it
//! always was. The choice is the viewer's, remembered in `localStorage` under
//! [`KEY`], and applied twice: by the four lines in `index.html` before the
//! first paint, and by the header's switch (`app.rs`) once the wasm is up. The
//! three must agree on every name, which is what the tests below hold them to.
//!
//! Nothing here decides anything else: the switch only paints and remembers.
/// The name the viewer's choice is remembered under, in `localStorage`.
pub const KEY: &str = "gunnar-theme";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Theme {
Light,
Dark,
Surprise,
}
impl Theme {
/// Every theme, in the order the switch walks them.
pub const ALL: [Theme; 3] = [Theme::Light, Theme::Dark, Theme::Surprise];
/// The theme a press of the switch moves to.
pub fn next(self) -> Theme {
match self {
Theme::Light => Theme::Dark,
Theme::Dark => Theme::Surprise,
Theme::Surprise => Theme::Light,
}
}
/// The word stored under [`KEY`] and written as `data-theme`.
pub fn name(self) -> &'static str {
match self {
Theme::Light => "light",
Theme::Dark => "dark",
Theme::Surprise => "surprise",
}
}
/// The `data-theme` value on `<html>`; `None` for light, whose palette is
/// bare `:root`.
pub fn attr(self) -> Option<&'static str> {
match self {
Theme::Light => None,
other => Some(other.name()),
}
}
/// A stored word read back. Anything else — nothing stored, a private
/// window, a word from a later console — is the default, light.
pub fn from_stored(word: Option<&str>) -> Theme {
match word {
Some("dark") => Theme::Dark,
Some("surprise") => Theme::Surprise,
_ => Theme::Light,
}
}
/// The switch's label: what a press DOES — the theme it moves to — never
/// what the console is now.
pub fn label(self) -> &'static str {
match self.next() {
Theme::Light => "☀ Light",
Theme::Dark => "☾ Dark",
Theme::Surprise => "✦ Surprise",
}
}
/// The switch's tooltip, which says the same in a sentence.
pub fn title(self) -> &'static str {
match self.next() {
Theme::Light => "Switch to the light theme",
Theme::Dark => "Switch to the dark theme",
Theme::Surprise => "Switch to the surprise theme",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_switch_walks_light_dark_surprise_and_back() {
let mut t = Theme::Light;
let mut seen = Vec::new();
for _ in 0..3 {
seen.push(t.name());
t = t.next();
}
assert_eq!(seen, ["light", "dark", "surprise"]);
assert_eq!(t, Theme::Light, "three presses come home");
assert_eq!(Theme::ALL.map(|t| t.label()), ["☾ Dark", "✦ Surprise", "☀ Light"], "each label names the press");
}
#[test]
fn a_stored_word_comes_back_as_its_theme_and_anything_else_is_light() {
for t in Theme::ALL {
assert_eq!(Theme::from_stored(Some(t.name())), t);
}
assert_eq!(Theme::from_stored(None), Theme::Light);
assert_eq!(Theme::from_stored(Some("solarized")), Theme::Light);
assert_eq!(Theme::Light.attr(), None, "light is the absence of the attribute");
}
/// ★ **On a phone the brand, Repositories and the switch share the first line** (round 5.3, UI row
/// the-header-mark-is-big-enough-and-the-bar-keeps-its-height): the health badge, drawn between
/// Repositories and the switch, is ordered after the menus at the bar's wrapping width, and the
/// search below them all.
#[test]
fn the_phone_bar_orders_the_badge_after_the_menus() {
let css = include_str!("../styles/console.css");
let phone = css.find("@media (max-width: 860px) {\n .topbar {").expect("the phone bar's block");
let block = &css[phone..phone + css[phone..].find("\n}\n").expect("its end")];
assert!(block.contains(".topbar > .badge {\n order: 5;"), "the badge is not ordered after the menus: {block}");
assert!(block.contains(".top-search {\n order: 10;"), "the search is not the last line");
}
/// The pre-paint script and the stylesheet name every theme the switch
/// can store: a word the script does not know is a white flash on every
/// load, and a word the stylesheet does not know is the light palette
/// under a dark switch.
#[test]
fn index_html_and_console_css_know_every_theme() {
let html = include_str!("../index.html");
let css = include_str!("../styles/console.css");
assert!(html.contains(&format!("localStorage.getItem(\"{KEY}\")")), "index.html reads another key");
for t in Theme::ALL {
let Some(attr) = t.attr() else { continue };
assert!(html.contains(&format!("\"{attr}\"")), "index.html's pre-paint script does not apply {attr}");
assert!(css.contains(&format!(":root[data-theme=\"{attr}\"] {{")), "console.css has no {attr} block");
}
}
}