1use tokenburn_core::{
8 Rgb, SYSTEM_THEME_NAME, THEME_COUNT, THEME_NAMES, ThemeChoice, Tool, settings, theme_by_index,
9};
10
11pub const SYSTEM_INDEX: usize = THEME_COUNT;
13
14pub 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 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
62pub 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
97pub 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
108pub 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
118pub fn names() -> Vec<&'static str> {
120 std::iter::once(SYSTEM_THEME_NAME)
121 .chain(THEME_NAMES.iter().copied())
122 .collect()
123}
124
125pub 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
134pub 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
144pub 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
153pub 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 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}