Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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");
        }
    }
}