Skip to main content

tokenburn_web/
theme.rs

1//! Colour themes for the dashboard: the core's palettes as CSS custom properties.
2//!
3//! `/style.css` is written against `var(--bg)`, `var(--accent)` … with the Default
4//! theme as fallback; a `<style>` block from [`css`] (re-emitted by the live shard
5//! whenever the theme select changes) overrides those variables.
6
7use tokenburn_core::{Rgb, THEME_COUNT, THEME_NAMES, Tool, find_theme, theme_by_index};
8
9/// CSS custom properties for the theme at `index` (clamped), as one `:root{…}` rule.
10pub fn css(index: usize) -> String {
11    let t = theme_by_index(index.min(THEME_COUNT - 1));
12    let mut vars: Vec<(String, Rgb)> = vec![
13        ("bg".into(), t.background),
14        ("panel".into(), t.surface),
15        ("line".into(), t.border),
16        ("sel".into(), t.selection),
17        ("fg".into(), t.text_primary),
18        ("fg2".into(), t.text_secondary),
19        ("dim".into(), t.text_muted),
20        ("accent".into(), t.accent),
21        ("ok".into(), t.success),
22        ("warn".into(), t.warning),
23        ("err".into(), t.error),
24    ];
25    for (i, c) in t.series.iter().enumerate() {
26        vars.push((format!("s{i}"), *c));
27    }
28    let body: String = vars
29        .iter()
30        .map(|(k, c)| format!("--{k}:{};", c.hex()))
31        .collect();
32    format!(
33        ":root{{color-scheme:{};{body}}}",
34        if t.is_dark { "dark" } else { "light" }
35    )
36}
37
38/// `var(--sN)` for a tool (`pi`, `claude`, …); the accent for anything else.
39pub fn series_var(tool: &str) -> String {
40    Tool::ALL
41        .iter()
42        .position(|t| t.as_str() == tool)
43        .map_or_else(
44            || "var(--accent)".to_string(),
45            |i| format!("var(--s{})", i % 8),
46        )
47}
48
49/// Pick the theme: an explicit, valid `requested` name wins, otherwise `saved`.
50pub fn resolve(requested: Option<&str>, saved: usize) -> usize {
51    requested.and_then(find_theme).unwrap_or(saved)
52}
53
54/// Theme names in canonical order (for the select).
55pub fn names() -> &'static [&'static str] {
56    THEME_NAMES
57}
58
59/// Every theme with its colours, for `/api/themes`.
60pub fn catalogue() -> serde_json::Value {
61    (0..THEME_COUNT)
62        .map(|i| {
63            let t = theme_by_index(i);
64            serde_json::json!({
65                "name": THEME_NAMES[i],
66                "dark": t.is_dark,
67                "background": t.background.hex(),
68                "surface": t.surface.hex(),
69                "border": t.border.hex(),
70                "selection": t.selection.hex(),
71                "text": t.text_primary.hex(),
72                "text_secondary": t.text_secondary.hex(),
73                "text_muted": t.text_muted.hex(),
74                "accent": t.accent.hex(),
75                "success": t.success.hex(),
76                "warning": t.warning.hex(),
77                "error": t.error.hex(),
78                "series": t.series.iter().map(|c| c.hex()).collect::<Vec<_>>(),
79            })
80        })
81        .collect()
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn every_theme_yields_a_complete_rule() {
90        for i in 0..THEME_COUNT {
91            let c = css(i);
92            for v in [
93                "--bg:",
94                "--panel:",
95                "--line:",
96                "--fg:",
97                "--accent:",
98                "--ok:",
99                "--warn:",
100                "--err:",
101                "--s0:",
102                "--s7:",
103            ] {
104                assert!(c.contains(v), "theme {i} lacks {v}");
105            }
106            assert!(c.starts_with(":root{") && c.ends_with('}'));
107        }
108    }
109
110    #[test]
111    fn css_is_safe_to_inline_in_a_style_element() {
112        // The rule is interpolated as text into <style>: it must not contain anything
113        // HTML would escape (which would corrupt it) or that could close the element.
114        for i in 0..THEME_COUNT {
115            let c = css(i);
116            assert!(
117                c.chars()
118                    .all(|ch| ch.is_ascii_alphanumeric() || "#:;{}-,. ".contains(ch)),
119                "theme {i}: unexpected characters in {c}"
120            );
121        }
122    }
123
124    #[test]
125    fn dark_and_light_themes_set_the_matching_color_scheme() {
126        assert!(css(0).contains("color-scheme:dark"));
127        let light = (0..THEME_COUNT)
128            .find(|i| !theme_by_index(*i).is_dark)
129            .unwrap();
130        assert!(css(light).contains("color-scheme:light"));
131    }
132
133    #[test]
134    fn out_of_range_index_is_clamped() {
135        assert_eq!(css(usize::MAX), css(THEME_COUNT - 1));
136    }
137
138    #[test]
139    fn requested_theme_wins_only_when_valid() {
140        assert_eq!(resolve(Some("nord"), 3), find_theme("Nord").unwrap());
141        assert_eq!(
142            resolve(Some("not a theme"), 3),
143            3,
144            "unknown names keep the saved theme"
145        );
146        assert_eq!(resolve(None, 7), 7);
147    }
148
149    #[test]
150    fn series_vars_are_distinct_per_tool() {
151        let vars: Vec<String> = Tool::ALL.iter().map(|t| series_var(t.as_str())).collect();
152        for (i, v) in vars.iter().enumerate() {
153            assert!(!vars[..i].contains(v));
154        }
155        assert_eq!(series_var("mystery"), "var(--accent)");
156    }
157
158    #[test]
159    fn catalogue_lists_every_theme() {
160        let c = catalogue();
161        assert_eq!(c.as_array().unwrap().len(), THEME_COUNT);
162        assert_eq!(c[0]["name"], "Default");
163        assert_eq!(c[0]["series"].as_array().unwrap().len(), 8);
164    }
165}