Skip to main content

max / makeover

40.6 KB · 1100 lines History Blame Raw
1 //! Shared theme loading + intent resolution for TOML-based theme files.
2 //!
3 //! Used by GoingsOn, Balanced Breakfast (Tauri apps), audiofiles (egui), and the
4 //! MNW web server. Themes are authored by **intent** ("human design"): colors are
5 //! declared by role (surface / content / action / status / line / category), not
6 //! by hue. This crate is the single place that resolves an authored theme into a
7 //! full set of intent tokens — including the derived interactive states
8 //! (hover/active/selection/row-stripe/contrast) that each app used to recompute
9 //! itself — and emits them as CSS variables or RGB tuples.
10 //!
11 //! Theme file shape:
12 //! ```text
13 //! [meta]
14 //! name = "Nord"
15 //! variant = "dark" # or "light"
16 //!
17 //! [surface] # container backgrounds by role/elevation
18 //! page = "#2e3440"; raised = "#3b4252"; sunken = "#434c5e"; overlay = "#3b4252"
19 //!
20 //! [content] # text/ink by emphasis
21 //! primary = "#d8dee9"; secondary = "#e5e9f0"; muted = "#616e88"
22 //!
23 //! [action] # interactive / brand color
24 //! primary = "#81a1c1"
25 //!
26 //! [status] # state semantics
27 //! danger = "#bf616a"; success = "#a3be8c"; warning = "#ebcb8b"; info = "#88c0d0"
28 //!
29 //! [line]
30 //! border = "#4c566a"
31 //!
32 //! [category] # distinct decorative colors for tags/badges/charts
33 //! one = "#bf616a"; two = "#a3be8c"; three = "#81a1c1"
34 //! four = "#ebcb8b"; five = "#b48ead"; six = "#88c0d0"
35 //! ```
36
37 use serde::Serialize;
38 use std::collections::{BTreeMap, HashMap};
39 use std::path::{Path, PathBuf};
40
41 /// The color sections an authored theme may declare.
42 pub const COLOR_SECTIONS: &[&str] = &["surface", "content", "action", "status", "line", "category"];
43
44 /// Theme metadata parsed from the `[meta]` section.
45 #[derive(Debug, Clone, Serialize)]
46 #[serde(rename_all = "camelCase")]
47 pub struct ThemeMeta {
48 pub id: String,
49 pub name: String,
50 pub variant: String,
51 pub is_custom: bool,
52 }
53
54 /// A loaded theme: metadata plus the authored colors, flattened to dotted keys
55 /// (e.g. `"surface.page"`, `"status.danger"`, `"category.one"`).
56 #[derive(Debug, Serialize)]
57 #[serde(rename_all = "camelCase")]
58 pub struct ThemeColors {
59 pub meta: ThemeMeta,
60 pub colors: HashMap<String, String>,
61 }
62
63 // ============================================================================
64 // Color math — perceptual (OKLab) derivations + WCAG contrast.
65 //
66 // Interactive states (hover/active/selection/surfaces) are derived in OKLab so
67 // equal steps look equal across every theme's hues (Ottosson 2020; the modern
68 // CIELAB). Text-on-color is picked by the WCAG 2.x contrast ratio, not a naive
69 // luminance threshold, so the choice actually meets AA where achievable.
70 // This is the single source of truth shared by every product.
71 // ============================================================================
72
73 /// An sRGB color. Hex round-trips losslessly.
74 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
75 pub struct Rgb {
76 pub r: u8,
77 pub g: u8,
78 pub b: u8,
79 }
80
81 impl Rgb {
82 /// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise.
83 pub fn from_hex(s: &str) -> Option<Rgb> {
84 let h = s.strip_prefix('#')?;
85 let (r, g, b) = match h.len() {
86 6 => (
87 u8::from_str_radix(&h[0..2], 16).ok()?,
88 u8::from_str_radix(&h[2..4], 16).ok()?,
89 u8::from_str_radix(&h[4..6], 16).ok()?,
90 ),
91 3 => {
92 let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17);
93 (d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?)
94 }
95 _ => return None,
96 };
97 Some(Rgb { r, g, b })
98 }
99
100 /// Lowercase `#rrggbb`.
101 pub fn to_hex(self) -> String {
102 format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
103 }
104
105 pub fn tuple(self) -> (u8, u8, u8) {
106 (self.r, self.g, self.b)
107 }
108 }
109
110 /// A color in OKLab (perceptually uniform): `l` lightness in [0,1], `a`/`b` opponent axes.
111 #[derive(Clone, Copy, Debug)]
112 pub struct Oklab {
113 pub l: f32,
114 pub a: f32,
115 pub b: f32,
116 }
117
118 fn srgb_to_linear(c: u8) -> f32 {
119 let c = c as f32 / 255.0;
120 if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
121 }
122
123 fn linear_to_srgb(c: f32) -> u8 {
124 let c = c.clamp(0.0, 1.0);
125 let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 };
126 (v * 255.0).round().clamp(0.0, 255.0) as u8
127 }
128
129 impl Rgb {
130 /// Convert to OKLab (Ottosson's sRGB matrices).
131 pub fn to_oklab(self) -> Oklab {
132 let (r, g, b) = (srgb_to_linear(self.r), srgb_to_linear(self.g), srgb_to_linear(self.b));
133 let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
134 let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
135 let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
136 let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
137 Oklab {
138 l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
139 a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
140 b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
141 }
142 }
143
144 /// Convert from OKLab back to the nearest in-gamut sRGB.
145 pub fn from_oklab(c: Oklab) -> Rgb {
146 let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b;
147 let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b;
148 let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b;
149 let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
150 Rgb {
151 r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
152 g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
153 b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
154 }
155 }
156 }
157
158 /// WCAG 2.x relative luminance of an sRGB color.
159 fn rel_luminance(c: Rgb) -> f32 {
160 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
161 }
162
163 /// WCAG 2.x contrast ratio between two colors, in [1, 21].
164 pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 {
165 let (la, lb) = (rel_luminance(a), rel_luminance(b));
166 let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
167 (hi + 0.05) / (lo + 0.05)
168 }
169
170 /// Pick black or white for legible text on `bg`, by the higher WCAG contrast
171 /// ratio (so the choice meets AA wherever the background allows it).
172 pub fn readable_on(bg: Rgb) -> Rgb {
173 let white = Rgb { r: 255, g: 255, b: 255 };
174 let black = Rgb { r: 0, g: 0, b: 0 };
175 if wcag_contrast(white, bg) >= wcag_contrast(black, bg) { white } else { black }
176 }
177
178 /// Shift OKLab lightness by `delta` (perceptually uniform). Positive lightens.
179 pub fn lighten(c: Rgb, delta: f32) -> Rgb {
180 let mut lab = c.to_oklab();
181 lab.l = (lab.l + delta).clamp(0.0, 1.0);
182 Rgb::from_oklab(lab)
183 }
184
185 /// Shift OKLab lightness down by `delta` (perceptually uniform).
186 pub fn darken(c: Rgb, delta: f32) -> Rgb {
187 lighten(c, -delta)
188 }
189
190 /// Interpolate between `a` and `b` by `t` in [0,1] in OKLab (perceptual blend).
191 pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb {
192 let (x, y) = (a.to_oklab(), b.to_oklab());
193 Rgb::from_oklab(Oklab {
194 l: x.l + (y.l - x.l) * t,
195 a: x.a + (y.a - x.a) * t,
196 b: x.b + (y.b - x.b) * t,
197 })
198 }
199
200 // ============================================================================
201 // Intent resolution
202 // ============================================================================
203
204 /// Authored base intents: (TOML dotted source key, canonical token key).
205 /// These are read straight from the theme; the token key is the CSS-var stem
206 /// (`--{token}`) and the `rgb()` lookup key.
207 pub const BASE_INTENTS: &[(&str, &str)] = &[
208 ("surface.page", "surface-page"),
209 ("surface.raised", "surface-raised"),
210 ("surface.sunken", "surface-sunken"),
211 ("surface.overlay", "surface-overlay"),
212 ("content.primary", "content"),
213 ("content.secondary", "content-secondary"),
214 ("content.muted", "content-muted"),
215 ("action.primary", "action"),
216 ("status.danger", "danger"),
217 ("status.success", "success"),
218 ("status.warning", "warning"),
219 ("status.info", "info"),
220 ("line.border", "border"),
221 ("category.one", "category-one"),
222 ("category.two", "category-two"),
223 ("category.three", "category-three"),
224 ("category.four", "category-four"),
225 ("category.five", "category-five"),
226 ("category.six", "category-six"),
227 ];
228
229 /// A fully resolved intent layer: every token key → concrete `#rrggbb`.
230 /// Includes both authored base intents and the computed derived intents.
231 #[derive(Debug, Clone, Serialize)]
232 #[serde(rename_all = "camelCase")]
233 pub struct SemanticTokens {
234 pub meta: ThemeMeta,
235 /// token-key → resolved hex. Stable, deterministic ordering.
236 pub intents: BTreeMap<String, String>,
237 }
238
239 impl SemanticTokens {
240 /// Resolved hex for a token key, if present.
241 pub fn hex(&self, key: &str) -> Option<&str> {
242 self.intents.get(key).map(String::as_str)
243 }
244
245 /// Resolved RGB tuple for a token key (for egui / native consumers).
246 pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> {
247 self.intents.get(key).and_then(|h| Rgb::from_hex(h)).map(Rgb::tuple)
248 }
249 }
250
251 /// Resolve an authored theme into the full intent token set.
252 ///
253 /// 1. Copy each present base intent from the authored colors.
254 /// 2. Compute the derived interactive states from the base intents, using the
255 /// same math the apps used to apply individually (so output is identical).
256 /// Each derived token is emitted only when its source intents exist, mirroring
257 /// the skip-missing behavior of the rest of the crate.
258 pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
259 let mut intents: BTreeMap<String, String> = BTreeMap::new();
260
261 // 1. Base intents (authored). Copy only values that parse as a hex color and
262 // re-emit them in canonical `#rrggbb` form, so an authored value can never
263 // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined
264 // raw into a `<style>` block by the web server). A malformed value is skipped,
265 // mirroring the skip-missing behavior for absent intents.
266 for (src, token) in BASE_INTENTS {
267 if let Some(rgb) = theme.colors.get(*src).and_then(|v| Rgb::from_hex(v)) {
268 intents.insert((*token).to_string(), rgb.to_hex());
269 }
270 }
271
272 // Helper: parse an already-resolved token to Rgb.
273 let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));
274
275 // 2. Derived intents — perceptual (OKLab) steps + WCAG-picked text.
276 // Lightness deltas are in OKLab L units; mix ratios interpolate in OKLab.
277 let mut derived: Vec<(String, Rgb)> = Vec::new();
278 if let Some(action) = get(&intents, "action") {
279 derived.push(("action-hover".into(), lighten(action, 0.05)));
280 derived.push(("content-on-action".into(), readable_on(action)));
281 derived.push(("focus-ring".into(), action));
282 }
283 if let Some(page) = get(&intents, "surface-page") {
284 // Modal scrim: a near-black tone carrying a faint hint of the theme's
285 // hue, at 50% alpha. Anchored very dark (OKLab L=0.08) so it dims the
286 // page on light *and* dark themes. Emitted as rgba (not a flat hex), so
287 // it is inserted directly rather than through the hex loop below.
288 let mut o = page.to_oklab();
289 o.l = 0.08;
290 let s = Rgb::from_oklab(o);
291 intents.insert("overlay".into(), format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b));
292 }
293 if let Some(sunken) = get(&intents, "surface-sunken") {
294 derived.push(("hover-surface".into(), sunken));
295 }
296 if let Some(border) = get(&intents, "border") {
297 derived.push(("border-strong".into(), darken(border, 0.05)));
298 }
299
300 for (token, rgb) in derived {
301 intents.insert(token, rgb.to_hex());
302 }
303
304 SemanticTokens { meta: theme.meta.clone(), intents }
305 }
306
307 /// Emit the resolved intent layer as CSS declarations (no selector), one
308 /// ` --token: #hex;` line each, in deterministic (BTreeMap) order.
309 pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
310 let mut out = String::new();
311 for (token, hex) in &tokens.intents {
312 out.push_str(" --");
313 out.push_str(token);
314 out.push_str(": ");
315 out.push_str(hex);
316 out.push_str(";\n");
317 }
318 out
319 }
320
321 /// Emit the resolved intent layer as a `:root { … }` block — the single TOML →
322 /// CSS mapping every web surface injects.
323 pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
324 format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
325 }
326
327 // ============================================================================
328 // Loading / parsing
329 // ============================================================================
330
331 /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
332 pub fn validate_theme_id(id: &str) -> Result<(), String> {
333 if !id
334 .chars()
335 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
336 {
337 return Err(format!("Invalid theme ID: {}", id));
338 }
339 Ok(())
340 }
341
342 /// Parse the `[meta]` section into `ThemeMeta`.
343 ///
344 /// Falls back to the file ID as the name and `"dark"` as the variant.
345 pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
346 let meta = table.get("meta").and_then(|m| m.as_table());
347 let name = meta
348 .and_then(|m| m.get("name"))
349 .and_then(|v| v.as_str())
350 .unwrap_or(id)
351 .to_string();
352 let variant = meta
353 .and_then(|m| m.get("variant"))
354 .and_then(|v| v.as_str())
355 .unwrap_or("dark")
356 .to_string();
357
358 ThemeMeta { id: id.to_string(), name, variant, is_custom }
359 }
360
361 /// Extract the intent color sections into a flat `HashMap` with dotted keys
362 /// like `"surface.page"`, `"status.danger"`, `"category.one"`.
363 pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
364 let mut colors = HashMap::new();
365 for section in COLOR_SECTIONS {
366 if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
367 for (key, val) in sect {
368 if let Some(color) = val.as_str() {
369 colors.insert(format!("{}.{}", section, key), color.to_string());
370 }
371 }
372 }
373 }
374 colors
375 }
376
377 /// Scan directories for `.toml` theme files and return metadata for each.
378 ///
379 /// Directories are checked in order; later entries override earlier ones by ID.
380 /// Each entry in `dirs` is `(path, is_custom)`.
381 pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
382 let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
383
384 for (dir, is_custom) in dirs {
385 let entries = match std::fs::read_dir(dir) {
386 Ok(e) => e,
387 Err(_) => continue,
388 };
389
390 for entry in entries {
391 let entry = match entry {
392 Ok(e) => e,
393 Err(_) => continue,
394 };
395 let path = entry.path();
396 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
397 continue;
398 }
399
400 let id = path
401 .file_stem()
402 .and_then(|s| s.to_str())
403 .unwrap_or_default()
404 .to_string();
405
406 let content = match std::fs::read_to_string(&path) {
407 Ok(c) => c,
408 Err(_) => continue,
409 };
410 let table: toml::Table = match content.parse() {
411 Ok(t) => t,
412 Err(_) => continue,
413 };
414
415 seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
416 }
417 }
418
419 let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
420 themes.sort_by(|a, b| a.name.cmp(&b.name));
421 themes
422 }
423
424 /// Find a theme file by ID in the given directories.
425 ///
426 /// Checks directories in reverse order so the highest-priority directory wins.
427 /// Returns `(path, is_custom)` or `None` if not found.
428 pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> {
429 let filename = format!("{}.toml", id);
430
431 for (dir, is_custom) in dirs.iter().rev() {
432 let path = dir.join(&filename);
433 if path.is_file() {
434 return Some((path, *is_custom));
435 }
436 }
437
438 None
439 }
440
441 /// Parse a complete theme (metadata + colors) from raw TOML content, with no
442 /// filesystem access. For callers that embed themes at compile time.
443 pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
444 validate_theme_id(id)?;
445 let table: toml::Table = content
446 .parse()
447 .map_err(|e| format!("Failed to parse theme '{}': {}", id, e))?;
448 let meta = parse_meta(id, &table, is_custom);
449 let colors = extract_colors(&table);
450 Ok(ThemeColors { meta, colors })
451 }
452
453 /// Load a complete theme (metadata + colors) by ID from the given directories.
454 pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
455 validate_theme_id(id)?;
456
457 let (path, is_custom) =
458 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?;
459
460 let content = std::fs::read_to_string(&path)
461 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
462
463 let table: toml::Table = content
464 .parse()
465 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
466
467 let meta = parse_meta(id, &table, is_custom);
468 let colors = extract_colors(&table);
469
470 Ok(ThemeColors { meta, colors })
471 }
472
473 /// Load a theme and resolve it to the full intent token set in one step.
474 pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
475 Ok(resolve(&load_theme(dirs, id)?))
476 }
477
478 /// Import a theme TOML file into the custom themes directory.
479 ///
480 /// Validates that the file is parseable TOML with at least one intent color
481 /// section, then copies it to `custom_dir/{id}.toml`. Returns the theme metadata.
482 pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
483 let content = std::fs::read_to_string(source_path)
484 .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
485
486 let table: toml::Table = content
487 .parse()
488 .map_err(|e| format!("Invalid TOML: {}", e))?;
489
490 let has_colors = COLOR_SECTIONS
491 .iter()
492 .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
493 if !has_colors {
494 return Err(format!(
495 "Theme file must have at least one color section ({})",
496 COLOR_SECTIONS.join(", ")
497 ));
498 }
499
500 let id = source_path
501 .file_stem()
502 .and_then(|s| s.to_str())
503 .ok_or("Invalid file name")?
504 .to_string();
505 validate_theme_id(&id)?;
506
507 std::fs::create_dir_all(custom_dir)
508 .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;
509
510 let dest = custom_dir.join(format!("{}.toml", id));
511 std::fs::copy(source_path, &dest)
512 .map_err(|e| format!("Failed to copy theme: {}", e))?;
513
514 Ok(parse_meta(&id, &table, true))
515 }
516
517 /// Delete a custom theme by ID.
518 ///
519 /// Only operates on `custom_dir` — bundled themes are not deletable through
520 /// this entry point.
521 pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
522 validate_theme_id(id)?;
523
524 let path = custom_dir.join(format!("{}.toml", id));
525 if !path.is_file() {
526 return Err(format!("Custom theme '{}' not found", id));
527 }
528
529 std::fs::remove_file(&path)
530 .map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
531 }
532
533 /// A four-color preview for theme thumbnails: the representative swatch from
534 /// each of the principal roles.
535 #[derive(Debug, Clone, Serialize)]
536 #[serde(rename_all = "camelCase")]
537 pub struct ThemePreview {
538 pub meta: ThemeMeta,
539 /// Page background (`surface.page`).
540 pub background: Option<String>,
541 /// Body text (`content.primary`).
542 pub foreground: Option<String>,
543 /// Brand/interactive color (`action.primary`).
544 pub accent: Option<String>,
545 /// Divider/outline color (`line.border`).
546 pub border: Option<String>,
547 }
548
549 fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
550 table
551 .get(section)
552 .and_then(|s| s.as_table())
553 .and_then(|s| s.get(key))
554 .and_then(|v| v.as_str())
555 .map(|s| s.to_string())
556 }
557
558 /// Load just the preview swatches for a theme — for UI thumbnails.
559 pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
560 validate_theme_id(id)?;
561
562 let (path, is_custom) =
563 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?;
564
565 let content = std::fs::read_to_string(&path)
566 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
567
568 let table: toml::Table = content
569 .parse()
570 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
571
572 Ok(ThemePreview {
573 meta: parse_meta(id, &table, is_custom),
574 background: color_at(&table, "surface", "page"),
575 foreground: color_at(&table, "content", "primary"),
576 accent: color_at(&table, "action", "primary"),
577 border: color_at(&table, "line", "border"),
578 })
579 }
580
581 /// Export a theme to a user-chosen path.
582 pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
583 validate_theme_id(id)?;
584
585 let (source, _) =
586 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{}' not found", id))?;
587
588 std::fs::copy(&source, dest_path)
589 .map_err(|e| format!("Failed to export theme: {}", e))?;
590
591 Ok(())
592 }
593
594 /// The themes this crate ships, embedded at compile time.
595 ///
596 /// `include_dir` is an implementation detail: the public API hands back plain
597 /// `(id, toml_source)` pairs, so how the data is embedded can change without
598 /// a breaking release.
599 static EMBEDDED: include_dir::Dir<'static> =
600 include_dir::include_dir!("$CARGO_MANIFEST_DIR/themes");
601
602 /// The themes this crate ships, as `(id, toml_source)` pairs.
603 ///
604 /// This is the path-free way to reach the bundled set, for consumers that
605 /// cannot rely on a directory existing at runtime: a crate pulled from
606 /// crates.io lives in a registry checkout whose location is not knowable at
607 /// compile time, so `include_dir!` and asset-bundling globs in the depending
608 /// crate have nothing stable to point at. Embedding here and re-exporting the
609 /// contents gives them one source of truth without a path.
610 ///
611 /// Ordering follows the embedded directory and is not guaranteed; collect and
612 /// sort by id where a stable order matters (a theme picker, say).
613 pub fn embedded_themes() -> impl Iterator<Item = (&'static str, &'static str)> {
614 EMBEDDED.files().filter_map(|file| {
615 let path = file.path();
616 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
617 return None;
618 }
619 let id = path.file_stem()?.to_str()?;
620 Some((id, file.contents_utf8()?))
621 })
622 }
623
624 /// The theme directory this crate ships, for use as a build-from-source
625 /// fallback.
626 ///
627 /// Resolves against `makeover`'s own manifest directory, fixed at compile
628 /// time, so it works from a path dependency and from a cargo git checkout
629 /// alike. Installed systems should put their packaged theme directory ahead
630 /// of this in the search path; this is the entry that keeps `cargo run` in a
631 /// fresh clone from coming up with no themes at all.
632 ///
633 /// Returns `None` when the directory is absent — a cargo cache that has been
634 /// cleaned, or a vendored copy that dropped the data — so callers degrade to
635 /// their remaining search path rather than failing.
636 pub fn bundled_themes_dir() -> Option<PathBuf> {
637 let themes = Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
638 if themes.is_dir() { Some(themes) } else { None }
639 }
640
641 #[cfg(test)]
642 mod tests {
643 use super::*;
644 use std::fs;
645
646 // ---- id validation ----
647
648 #[test]
649 fn validate_theme_id_alphanumeric() {
650 assert!(validate_theme_id("darkmode").is_ok());
651 assert!(validate_theme_id("Theme123").is_ok());
652 }
653
654 #[test]
655 fn validate_theme_id_hyphens_underscores() {
656 assert!(validate_theme_id("dark-mode").is_ok());
657 assert!(validate_theme_id("my_theme_v2").is_ok());
658 }
659
660 #[test]
661 fn validate_theme_id_rejects_path_traversal() {
662 assert!(validate_theme_id("../etc/passwd").is_err());
663 assert!(validate_theme_id("foo/bar").is_err());
664 assert!(validate_theme_id("theme.toml").is_err());
665 }
666
667 // ---- meta ----
668
669 #[test]
670 fn parse_meta_with_name_and_variant() {
671 let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n".parse().unwrap();
672 let meta = parse_meta("nord", &table, false);
673 assert_eq!(meta.id, "nord");
674 assert_eq!(meta.name, "Nord");
675 assert_eq!(meta.variant, "light");
676 assert!(!meta.is_custom);
677 }
678
679 #[test]
680 fn parse_meta_defaults_to_id_and_dark() {
681 let table: toml::Table = "".parse().unwrap();
682 let meta = parse_meta("fallback", &table, true);
683 assert_eq!(meta.name, "fallback");
684 assert_eq!(meta.variant, "dark");
685 assert!(meta.is_custom);
686 }
687
688 // ---- color math (formulas must match the apps they came from) ----
689
690 #[test]
691 fn rgb_hex_roundtrip() {
692 assert_eq!(Rgb::from_hex("#6196FF").unwrap(), Rgb { r: 0x61, g: 0x96, b: 0xff });
693 assert_eq!(Rgb::from_hex("#abc").unwrap(), Rgb { r: 0xaa, g: 0xbb, b: 0xcc });
694 assert_eq!(Rgb { r: 0x61, g: 0x96, b: 0xff }.to_hex(), "#6196ff");
695 assert!(Rgb::from_hex("not-a-color").is_none());
696 }
697
698 #[test]
699 fn oklab_roundtrips_within_tolerance() {
700 for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] {
701 let c = Rgb::from_hex(hex).unwrap();
702 let back = Rgb::from_oklab(c.to_oklab());
703 // Gamut round-trip is near-exact (±1 per channel from rounding).
704 assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r");
705 assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g");
706 assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b");
707 }
708 }
709
710 #[test]
711 fn wcag_contrast_known_pairs() {
712 let white = Rgb { r: 255, g: 255, b: 255 };
713 let black = Rgb { r: 0, g: 0, b: 0 };
714 assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01);
715 assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01);
716 }
717
718 #[test]
719 fn readable_on_picks_by_wcag() {
720 assert_eq!(readable_on(Rgb { r: 255, g: 255, b: 255 }), Rgb { r: 0, g: 0, b: 0 });
721 assert_eq!(readable_on(Rgb { r: 0, g: 0, b: 0 }), Rgb { r: 255, g: 255, b: 255 });
722 // A light blue action -> black text reads better.
723 let action = Rgb::from_hex("#6196ff").unwrap();
724 assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 });
725 }
726
727 #[test]
728 fn lighten_darken_move_oklab_lightness() {
729 let c = Rgb::from_hex("#6196ff").unwrap();
730 let l0 = c.to_oklab().l;
731 assert!(lighten(c, 0.05).to_oklab().l > l0);
732 assert!(darken(c, 0.05).to_oklab().l < l0);
733 }
734
735 #[test]
736 fn mix_endpoints_and_midpoint() {
737 let a = Rgb::from_hex("#000000").unwrap();
738 let b = Rgb::from_hex("#6196ff").unwrap();
739 assert_eq!(mix(a, b, 0.0), a);
740 assert_eq!(mix(a, b, 1.0), b);
741 // Midpoint sits between the endpoints in OKLab lightness.
742 let mid = mix(a, b, 0.5).to_oklab().l;
743 assert!(mid > a.to_oklab().l && mid < b.to_oklab().l);
744 }
745
746 // ---- extract + resolve ----
747
748 fn nord_toml() -> &'static str {
749 r##"
750 [meta]
751 name = "Nord"
752 variant = "dark"
753
754 [surface]
755 page = "#2e3440"
756 raised = "#3b4252"
757 sunken = "#434c5e"
758 overlay = "#3b4252"
759
760 [content]
761 primary = "#d8dee9"
762 secondary = "#e5e9f0"
763 muted = "#616e88"
764
765 [action]
766 primary = "#81a1c1"
767
768 [status]
769 danger = "#bf616a"
770 success = "#a3be8c"
771 warning = "#ebcb8b"
772 info = "#88c0d0"
773
774 [line]
775 border = "#4c566a"
776
777 [category]
778 one = "#bf616a"
779 two = "#a3be8c"
780 three = "#81a1c1"
781 four = "#ebcb8b"
782 five = "#b48ead"
783 six = "#88c0d0"
784 "##
785 }
786
787 #[test]
788 fn extract_colors_reads_intent_sections() {
789 let table: toml::Table = nord_toml().parse().unwrap();
790 let colors = extract_colors(&table);
791 assert_eq!(colors.get("surface.page").unwrap(), "#2e3440");
792 assert_eq!(colors.get("content.primary").unwrap(), "#d8dee9");
793 assert_eq!(colors.get("action.primary").unwrap(), "#81a1c1");
794 assert_eq!(colors.get("status.danger").unwrap(), "#bf616a");
795 assert_eq!(colors.get("line.border").unwrap(), "#4c566a");
796 assert_eq!(colors.get("category.five").unwrap(), "#b48ead");
797 assert_eq!(colors.len(), 19);
798 }
799
800 #[test]
801 fn resolve_base_intents_passthrough() {
802 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
803 let t = resolve(&theme);
804 assert_eq!(t.hex("surface-page"), Some("#2e3440"));
805 assert_eq!(t.hex("content"), Some("#d8dee9")); // content.primary -> content
806 assert_eq!(t.hex("content-muted"), Some("#616e88"));
807 assert_eq!(t.hex("action"), Some("#81a1c1"));
808 assert_eq!(t.hex("danger"), Some("#bf616a"));
809 assert_eq!(t.hex("border"), Some("#4c566a"));
810 assert_eq!(t.hex("category-five"), Some("#b48ead"));
811 }
812
813 #[test]
814 fn resolve_derived_intents() {
815 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
816 let t = resolve(&theme);
817 let action = Rgb::from_hex("#81a1c1").unwrap();
818 let page = Rgb::from_hex("#2e3440").unwrap();
819 let _ = page;
820 assert_eq!(t.hex("action-hover").unwrap(), lighten(action, 0.05).to_hex());
821 assert_eq!(t.hex("content-on-action").unwrap(), readable_on(action).to_hex());
822 assert_eq!(t.hex("focus-ring"), Some("#81a1c1"));
823 assert_eq!(t.hex("hover-surface"), Some("#434c5e")); // = surface.sunken
824 // Pruned by the usage audit (0 consumers): action-active, the *-surface
825 // tints, selection, row-stripe. Apps that need them derive inline via
826 // the shared mix().
827 assert!(t.hex("action-active").is_none());
828 assert!(t.hex("danger-surface").is_none());
829 assert!(t.hex("selection").is_none());
830 assert!(t.hex("row-stripe").is_none());
831 }
832
833 #[test]
834 fn resolve_overlay_is_dark_translucent_scrim() {
835 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
836 let t = resolve(&theme);
837 let overlay = t.hex("overlay").unwrap();
838 assert!(overlay.starts_with("rgba("), "overlay is translucent: {overlay}");
839 assert!(overlay.ends_with(", 0.5)"));
840 // The scrim tone is anchored very dark regardless of theme.
841 let inner = overlay.trim_start_matches("rgba(").trim_end_matches(", 0.5)");
842 let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
843 let scrim = Rgb { r: parts[0], g: parts[1], b: parts[2] };
844 assert!(scrim.to_oklab().l < 0.2, "scrim must be near-black");
845 }
846
847 #[test]
848 fn resolve_drops_non_hex_base_intent() {
849 // A base intent that isn't a hex color must never reach the resolved
850 // token set (it would otherwise be inlined verbatim into a <style>
851 // block). Skipped like a missing intent; valid siblings survive.
852 let theme = parse_theme_str(
853 "x",
854 "[surface]\npage = \"</style><script>alert(1)</script>\"\n[content]\nprimary = \"#111111\"\n",
855 false,
856 )
857 .unwrap();
858 let t = resolve(&theme);
859 assert!(t.hex("surface-page").is_none(), "non-hex base intent leaked");
860 assert_eq!(t.hex("content").unwrap(), "#111111");
861 // The injected markup appears in no resolved value.
862 assert!(!t.intents.values().any(|v| v.contains('<')));
863 }
864
865 #[test]
866 fn resolve_skips_derived_when_source_missing() {
867 // No [action] => no action-derived tokens.
868 let theme = parse_theme_str(
869 "x",
870 "[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n",
871 false,
872 )
873 .unwrap();
874 let t = resolve(&theme);
875 assert!(t.hex("action").is_none());
876 assert!(t.hex("action-hover").is_none());
877 assert!(t.hex("selection").is_none());
878 assert_eq!(t.hex("border-strong").unwrap(), darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex());
879 }
880
881 #[test]
882 fn rgb_accessor_for_native_consumers() {
883 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
884 let t = resolve(&theme);
885 assert_eq!(t.rgb("action"), Some((0x81, 0xa1, 0xc1)));
886 assert_eq!(t.rgb("nonexistent"), None);
887 }
888
889 // ---- css emit ----
890
891 #[test]
892 fn intent_css_vars_wraps_root_and_includes_tokens() {
893 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
894 let css = intent_css_vars(&resolve(&theme));
895 assert!(css.starts_with(":root {\n"));
896 assert!(css.contains(" --surface-page: #2e3440;\n"));
897 assert!(css.contains(" --danger: #bf616a;\n"));
898 assert!(css.contains(" --action-hover: "));
899 assert!(css.trim_end().ends_with('}'));
900 }
901
902 // ---- loading / fs ----
903
904 #[test]
905 fn load_and_resolve_round_trip() {
906 let dir = tempfile::tempdir().unwrap();
907 fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
908 let dirs = vec![(dir.path().to_path_buf(), false)];
909 let t = load_semantic(&dirs, "nord").unwrap();
910 assert_eq!(t.meta.name, "Nord");
911 assert_eq!(t.hex("action"), Some("#81a1c1"));
912 }
913
914 #[test]
915 fn load_theme_rejects_invalid_id() {
916 assert!(load_theme(&[], "../evil").is_err());
917 }
918
919 #[test]
920 fn list_themes_from_dirs_finds_toml_files() {
921 let dir = tempfile::tempdir().unwrap();
922 fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap();
923 fs::write(dir.path().join("x.txt"), "ignored").unwrap();
924 let dirs = vec![(dir.path().to_path_buf(), false)];
925 let themes = list_themes_from_dirs(&dirs);
926 assert_eq!(themes.len(), 1);
927 assert_eq!(themes[0].id, "t");
928 }
929
930 #[test]
931 fn find_theme_path_reverse_priority() {
932 let d1 = tempfile::tempdir().unwrap();
933 let d2 = tempfile::tempdir().unwrap();
934 fs::write(d1.path().join("s.toml"), "[meta]\n").unwrap();
935 fs::write(d2.path().join("s.toml"), "[meta]\n").unwrap();
936 let dirs = vec![(d1.path().to_path_buf(), false), (d2.path().to_path_buf(), true)];
937 let (path, is_custom) = find_theme_path(&dirs, "s").unwrap();
938 assert!(is_custom);
939 assert_eq!(path, d2.path().join("s.toml"));
940 }
941
942 #[test]
943 fn import_theme_valid_and_rejects_empty() {
944 let src_dir = tempfile::tempdir().unwrap();
945 let custom_dir = tempfile::tempdir().unwrap();
946
947 let good = src_dir.path().join("my-theme.toml");
948 fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap();
949 let meta = import_theme(&good, custom_dir.path()).unwrap();
950 assert_eq!(meta.id, "my-theme");
951 assert!(custom_dir.path().join("my-theme.toml").exists());
952
953 let empty = src_dir.path().join("empty.toml");
954 fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap();
955 assert!(import_theme(&empty, custom_dir.path()).is_err());
956 }
957
958 #[test]
959 fn import_theme_rejects_invalid_toml() {
960 let src_dir = tempfile::tempdir().unwrap();
961 let custom_dir = tempfile::tempdir().unwrap();
962 let src = src_dir.path().join("bad.toml");
963 fs::write(&src, "this is not [valid toml [[[").unwrap();
964 assert!(import_theme(&src, custom_dir.path()).is_err());
965 }
966
967 #[test]
968 fn delete_theme_removes_and_guards() {
969 let custom = tempfile::tempdir().unwrap();
970 let path = custom.path().join("doomed.toml");
971 fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap();
972 delete_theme(custom.path(), "doomed").unwrap();
973 assert!(!path.exists());
974 assert!(delete_theme(custom.path(), "../etc/passwd").is_err());
975 assert!(delete_theme(custom.path(), "ghost").is_err());
976 }
977
978 #[test]
979 fn export_theme_copies_file() {
980 let src_dir = tempfile::tempdir().unwrap();
981 let dest_dir = tempfile::tempdir().unwrap();
982 let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n";
983 fs::write(src_dir.path().join("e.toml"), content).unwrap();
984 let dirs = vec![(src_dir.path().to_path_buf(), false)];
985 let dest = dest_dir.path().join("out.toml");
986 export_theme(&dirs, "e", &dest).unwrap();
987 assert_eq!(fs::read_to_string(&dest).unwrap(), content);
988 assert!(export_theme(&dirs, "missing", &dest).is_err());
989 }
990
991 #[test]
992 fn load_theme_preview_returns_role_swatches() {
993 let dir = tempfile::tempdir().unwrap();
994 fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
995 let dirs = vec![(dir.path().to_path_buf(), false)];
996 let p = load_theme_preview(&dirs, "nord").unwrap();
997 assert_eq!(p.background.as_deref(), Some("#2e3440")); // surface.page
998 assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); // content.primary
999 assert_eq!(p.accent.as_deref(), Some("#81a1c1")); // action.primary
1000 assert_eq!(p.border.as_deref(), Some("#4c566a")); // line.border
1001 }
1002
1003 #[test]
1004 fn bundled_themes_dir_resolves_to_shipped_themes() {
1005 // The crate ships its themes, so this must resolve in-tree and the
1006 // Akari defaults the console falls back to must be present.
1007 let dir = bundled_themes_dir().expect("makeover ships a themes/ directory");
1008 assert!(dir.join("akari-dawn.toml").is_file());
1009 assert!(dir.join("akari-night.toml").is_file());
1010 }
1011
1012 #[test]
1013 fn every_theme_is_accounted_for_in_third_party_notices() {
1014 // Attribution is a redistribution obligation, not a nicety: adding a
1015 // theme without a notice entry silently ships someone's work
1016 // uncredited. Fail here instead.
1017 let notices = std::fs::read_to_string(
1018 Path::new(env!("CARGO_MANIFEST_DIR")).join("THIRD-PARTY-NOTICES.md"),
1019 )
1020 .expect("THIRD-PARTY-NOTICES.md must exist");
1021 let missing: Vec<&str> = embedded_themes()
1022 .map(|(id, _)| id)
1023 .filter(|id| !notices.contains(*id))
1024 .collect();
1025 assert!(
1026 missing.is_empty(),
1027 "themes missing from THIRD-PARTY-NOTICES.md: {missing:?}"
1028 );
1029 }
1030
1031 #[test]
1032 fn adapted_themes_carry_inline_attribution() {
1033 // Each adapted file must name its upstream in-file, so the credit
1034 // survives someone copying a single .toml out of the crate.
1035 const ORIGINALS: [&str; 5] =
1036 ["makenotwork", "goingson", "audiofiles", "high-contrast", "neobrute"];
1037 for (id, source) in embedded_themes() {
1038 if ORIGINALS.contains(&id) {
1039 continue;
1040 }
1041 assert!(
1042 source.contains("adapted from"),
1043 "adapted theme `{id}` is missing its inline attribution header"
1044 );
1045 }
1046 }
1047
1048 #[test]
1049 fn embedded_themes_match_the_directory() {
1050 // The embedded copy and themes/ are two views of one source. If they
1051 // ever disagree, path-based and path-free consumers render different
1052 // theme sets, which is exactly the drift shipping the data was meant
1053 // to prevent.
1054 let dir = bundled_themes_dir().unwrap();
1055 let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
1056 .unwrap()
1057 .filter_map(|e| {
1058 let path = e.ok()?.path();
1059 if path.extension()? != "toml" {
1060 return None;
1061 }
1062 Some(path.file_stem()?.to_str()?.to_string())
1063 })
1064 .collect();
1065 let mut embedded: Vec<String> =
1066 embedded_themes().map(|(id, _)| id.to_string()).collect();
1067 on_disk.sort();
1068 embedded.sort();
1069 assert_eq!(embedded, on_disk, "embedded theme set drifted from themes/");
1070 }
1071
1072 #[test]
1073 fn every_embedded_theme_parses() {
1074 // Guards the path-free consumers (MNW server, the Tauri build steps)
1075 // the same way every_shipped_theme_loads guards the path-based ones.
1076 let mut count = 0;
1077 for (id, source) in embedded_themes() {
1078 parse_theme_str(id, source, false)
1079 .unwrap_or_else(|e| panic!("embedded theme `{id}` failed to parse: {e}"));
1080 count += 1;
1081 }
1082 assert!(count >= 30, "expected the full theme set, got {count}");
1083 }
1084
1085 #[test]
1086 fn every_shipped_theme_loads() {
1087 // Guards the data, not just the loader: a malformed or truncated
1088 // .toml in themes/ is a shipping bug, and it should fail here rather
1089 // than at a user's first launch.
1090 let dir = bundled_themes_dir().unwrap();
1091 let dirs = vec![(dir.clone(), false)];
1092 let themes = list_themes_from_dirs(&dirs);
1093 assert!(themes.len() >= 30, "expected the full theme set, got {}", themes.len());
1094 for meta in &themes {
1095 load_theme(&dirs, &meta.id)
1096 .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id));
1097 }
1098 }
1099 }
1100