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::{
8    Rgb, SYSTEM_THEME_NAME, THEME_COUNT, THEME_NAMES, ThemeChoice, Tool, settings, theme_by_index,
9};
10
11/// Pseudo-index meaning "follow the system" (one past the last preset).
12pub const SYSTEM_INDEX: usize = THEME_COUNT;
13
14/// CSS custom properties for the theme at `index` (clamped), as one `:root{…}` rule.
15///
16/// [`SYSTEM_INDEX`] yields the dark preset plus a `prefers-color-scheme: light`
17/// override, so the browser follows the OS live (no server polling needed).
18pub fn css(index: usize) -> String {
19    if index == SYSTEM_INDEX {
20        let dark = css_for(tokenburn_core::system_theme_index(true));
21        let light = css_for(tokenburn_core::system_theme_index(false));
22        return format!("{dark}@media (prefers-color-scheme:light){{{light}}}");
23    }
24    css_for(index.min(THEME_COUNT - 1))
25}
26
27fn css_for(index: usize) -> String {
28    let t = theme_by_index(index);
29    let mut vars: Vec<(String, Rgb)> = vec![
30        ("bg".into(), t.background),
31        ("panel".into(), t.surface),
32        ("line".into(), t.border),
33        ("sel".into(), t.selection),
34        ("fg".into(), t.text_primary),
35        ("fg2".into(), t.text_secondary),
36        ("dim".into(), t.text_muted),
37        ("accent".into(), t.accent),
38        ("ok".into(), t.success),
39        ("warn".into(), t.warning),
40        ("err".into(), t.error),
41    ];
42    for (i, c) in t.series.iter().enumerate() {
43        vars.push((format!("s{i}"), *c));
44    }
45    let body: String = vars
46        .iter()
47        .map(|(k, c)| format!("--{k}:{};", c.hex()))
48        .collect();
49    // The extra rules hold quotes and `>` selectors, which HTML escaping would corrupt
50    // inside an inline `<style>`, so they are served at `/cyberpunk.css` and imported.
51    let import = if THEME_NAMES[index] == "Cyberpunk" {
52        "@import url(/cyberpunk.css);"
53    } else {
54        ""
55    };
56    format!(
57        "{import}:root{{color-scheme:{};{body}}}",
58        if t.is_dark { "dark" } else { "light" }
59    )
60}
61
62/// Extra component styling for the Cyberpunk theme (cyberpunk.net look): yellow
63/// slab buttons with clipped corners, uppercase display type, hard-edged panels
64/// with a cut corner, neon glow and a faint scanline overlay.
65pub const CYBERPUNK_CSS: &str = r#"
66body{font-family:"Rajdhani","Oxanium","Bahnschrift","Arial Narrow",system-ui,sans-serif;letter-spacing:.02em;background:var(--bg) repeating-linear-gradient(0deg,transparent 0 2px,rgba(252,238,10,.025) 2px 3px);}
67h1{text-transform:uppercase;letter-spacing:.14em;font-weight:700;text-shadow:2px 0 var(--err),-2px 0 var(--s4);}
68h2{color:var(--s4);letter-spacing:.18em;font-weight:700;}
69h2::before{content:"// ";color:var(--accent);}
70.card,.panel,.empty,.warn,table,.table-wrap,.selbar .chip,.badge,.tabs .tab,select,button,.selbar .clear,.bar,.bar>span{border-radius:0;}
71.card,.panel,.empty,.warn{border:1px solid var(--line);border-left:3px solid var(--accent);clip-path:polygon(0 0,calc(100% - 14px) 0,100% 14px,100% 100%,14px 100%,0 calc(100% - 14px));background:linear-gradient(135deg,var(--panel),var(--bg));}
72.card .value{text-shadow:0 0 10px currentColor;}
73button,.selbar .clear{background:var(--accent);color:#000;border:0;font-weight:700;text-transform:uppercase;letter-spacing:.12em;padding:7px 18px;cursor:pointer;clip-path:polygon(0 0,calc(100% - 10px) 0,100% 10px,100% 100%,10px 100%,0 calc(100% - 10px));transition:background .12s,box-shadow .12s,transform .12s;}
74button:hover,.selbar .clear:hover{background:var(--s4);color:#000;box-shadow:0 0 14px var(--s4);}
75button:active,.selbar .clear:active{transform:translate(1px,1px);background:var(--err);}
76select{background:#000;color:var(--accent);border:1px solid var(--accent);text-transform:uppercase;letter-spacing:.08em;padding:6px 10px;clip-path:polygon(0 0,calc(100% - 8px) 0,100% 8px,100% 100%,8px 100%,0 calc(100% - 8px));}
77select:hover,select:focus{outline:none;border-color:var(--s4);color:var(--s4);box-shadow:0 0 10px rgba(0,210,235,.5);}
78.tabs .tab{background:transparent;border:1px solid var(--line);text-transform:uppercase;letter-spacing:.14em;font-weight:700;clip-path:polygon(0 0,calc(100% - 8px) 0,100% 8px,100% 100%,0 100%);}
79.tabs .tab:hover{color:var(--accent);border-color:var(--accent);}
80.tabs .tab.active{background:var(--accent);color:#000;border-color:var(--accent);}
81table{border:1px solid var(--line);border-top:2px solid var(--accent);}
82th{color:#000;background:var(--accent);text-transform:uppercase;letter-spacing:.12em;}
83tr:hover td{background:var(--sel);box-shadow:inset 3px 0 var(--accent);}
84tr.total td{color:var(--accent);}
85.badge{text-transform:uppercase;letter-spacing:.12em;}
86.badge.live{box-shadow:0 0 8px var(--ok);}
87.bar{height:6px;background:#000;border:1px solid var(--line);}
88.bar>span{box-shadow:0 0 8px var(--accent);}
89.selbar .chip{background:transparent;border-color:var(--s4);color:var(--s4);text-transform:uppercase;letter-spacing:.1em;}
90.legend .item.sel{outline-color:var(--s4);}
91.legend .sw{border-radius:0;}
92a{color:var(--s4);text-decoration-color:var(--accent);}
93a:hover{color:var(--accent);}
94::selection{background:var(--accent);color:#000;}
95"#;
96
97/// `var(--sN)` for a tool (`pi`, `claude`, …); the accent for anything else.
98pub fn series_var(tool: &str) -> String {
99    Tool::ALL
100        .iter()
101        .position(|t| t.as_str() == tool)
102        .map_or_else(
103            || "var(--accent)".to_string(),
104            |i| format!("var(--s{})", i % 8),
105        )
106}
107
108/// Pick the theme: an explicit, valid `requested` name wins, otherwise `saved`.
109/// `"System"` resolves to [`SYSTEM_INDEX`].
110pub fn resolve(requested: Option<&str>, saved: usize) -> usize {
111    match requested.and_then(ThemeChoice::parse) {
112        Some(ThemeChoice::System) => SYSTEM_INDEX,
113        Some(ThemeChoice::Preset(i)) => i,
114        None => saved,
115    }
116}
117
118/// Theme names for the select: "System" first, then the presets in canonical order.
119pub fn names() -> Vec<&'static str> {
120    std::iter::once(SYSTEM_THEME_NAME)
121        .chain(THEME_NAMES.iter().copied())
122        .collect()
123}
124
125/// The name for a theme index ([`SYSTEM_INDEX`] → "System"; out of range is clamped).
126pub fn name(index: usize) -> &'static str {
127    if index == SYSTEM_INDEX {
128        SYSTEM_THEME_NAME
129    } else {
130        THEME_NAMES[index.min(THEME_COUNT - 1)]
131    }
132}
133
134/// The saved theme ([`SYSTEM_INDEX`] when following the system), shared with the TUI and GUI.
135pub fn saved_index() -> usize {
136    let s = settings::load();
137    if s.follow_system_theme() {
138        SYSTEM_INDEX
139    } else {
140        s.theme_index()
141    }
142}
143
144/// Remember `index` for the TUI and GUI (best effort).
145pub fn save_index(index: usize) {
146    let _ = if index == SYSTEM_INDEX {
147        settings::save_system_theme()
148    } else {
149        settings::save_theme(index)
150    };
151}
152
153/// Every theme with its colours, for `/api/themes`.
154pub fn catalogue() -> serde_json::Value {
155    (0..THEME_COUNT)
156        .map(|i| {
157            let t = theme_by_index(i);
158            serde_json::json!({
159                "name": THEME_NAMES[i],
160                "dark": t.is_dark,
161                "background": t.background.hex(),
162                "surface": t.surface.hex(),
163                "border": t.border.hex(),
164                "selection": t.selection.hex(),
165                "text": t.text_primary.hex(),
166                "text_secondary": t.text_secondary.hex(),
167                "text_muted": t.text_muted.hex(),
168                "accent": t.accent.hex(),
169                "success": t.success.hex(),
170                "warning": t.warning.hex(),
171                "error": t.error.hex(),
172                "series": t.series.iter().map(|c| c.hex()).collect::<Vec<_>>(),
173            })
174        })
175        .collect()
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn every_theme_yields_a_complete_rule() {
184        for i in 0..THEME_COUNT {
185            let c = css(i);
186            for v in [
187                "--bg:",
188                "--panel:",
189                "--line:",
190                "--fg:",
191                "--accent:",
192                "--ok:",
193                "--warn:",
194                "--err:",
195                "--s0:",
196                "--s7:",
197            ] {
198                assert!(c.contains(v), "theme {i} lacks {v}");
199            }
200            assert!(c.contains(":root{") && c.ends_with('}'));
201        }
202    }
203
204    #[test]
205    fn css_is_safe_to_inline_in_a_style_element() {
206        // The rule is interpolated as text into <style>: it must not contain anything
207        // HTML would escape (which would corrupt it) or that could close the element.
208        for i in 0..THEME_COUNT {
209            let c = css(i);
210            assert!(
211                c.chars()
212                    .all(|ch| ch.is_ascii_alphanumeric() || "#:;{}-,.@()/_ ".contains(ch)),
213                "theme {i}: unexpected characters in {c}"
214            );
215        }
216    }
217
218    #[test]
219    fn dark_and_light_themes_set_the_matching_color_scheme() {
220        assert!(css(0).contains("color-scheme:dark"));
221        let light = (0..THEME_COUNT)
222            .find(|i| !theme_by_index(*i).is_dark)
223            .unwrap();
224        assert!(css(light).contains("color-scheme:light"));
225    }
226
227    #[test]
228    fn out_of_range_index_is_clamped() {
229        assert_eq!(css(usize::MAX), css(THEME_COUNT - 1));
230    }
231
232    #[test]
233    fn requested_theme_wins_only_when_valid() {
234        assert_eq!(
235            resolve(Some("nord"), 3),
236            tokenburn_core::find_theme("Nord").unwrap()
237        );
238        assert_eq!(
239            resolve(Some("not a theme"), 3),
240            3,
241            "unknown names keep the saved theme"
242        );
243        assert_eq!(resolve(None, 7), 7);
244    }
245
246    #[test]
247    fn system_resolves_to_the_system_index_in_any_case() {
248        assert_eq!(resolve(Some("System"), 7), SYSTEM_INDEX);
249        assert_eq!(resolve(Some(" system "), 7), SYSTEM_INDEX);
250    }
251
252    #[test]
253    fn system_css_has_a_dark_base_and_a_light_media_override() {
254        let c = css(SYSTEM_INDEX);
255        assert!(c.starts_with(":root{color-scheme:dark;"), "{c}");
256        assert!(
257            c.contains("@media (prefers-color-scheme:light){:root{color-scheme:light;"),
258            "{c}"
259        );
260        assert!(c.ends_with("}}"), "{c}");
261        let dark = css(tokenburn_core::system_theme_index(true));
262        let light = css(tokenburn_core::system_theme_index(false));
263        assert_eq!(
264            c,
265            format!("{dark}@media (prefers-color-scheme:light){{{light}}}")
266        );
267        assert!(
268            c.chars()
269                .all(|ch| ch.is_ascii_alphanumeric() || "#:;{}-,. ()@".contains(ch)),
270            "safe to inline in <style>: {c}"
271        );
272    }
273
274    #[test]
275    fn names_lead_with_system_and_name_inverts_the_index() {
276        let n = names();
277        assert_eq!(n.len(), THEME_COUNT + 1);
278        assert_eq!(n[0], "System");
279        assert_eq!(n[1], "Default");
280        assert_eq!(name(SYSTEM_INDEX), "System");
281        assert_eq!(name(0), "Default");
282        assert_eq!(name(usize::MAX - 1), THEME_NAMES[THEME_COUNT - 1]);
283    }
284
285    #[test]
286    fn series_vars_are_distinct_per_tool() {
287        let vars: Vec<String> = Tool::ALL
288            .iter()
289            .take(8)
290            .map(|t| series_var(t.as_str()))
291            .collect();
292        for (i, v) in vars.iter().enumerate() {
293            assert!(!vars[..i].contains(v));
294        }
295        assert_eq!(series_var("mystery"), "var(--accent)");
296    }
297
298    #[test]
299    fn catalogue_lists_every_theme() {
300        let c = catalogue();
301        assert_eq!(c.as_array().unwrap().len(), THEME_COUNT);
302        assert_eq!(c[0]["name"], "Default");
303        assert_eq!(c[0]["series"].as_array().unwrap().len(), 8);
304    }
305}