//! Shared theme loading + intent resolution for TOML-based theme files. //! //! Used by GoingsOn, Balanced Breakfast (Tauri apps), audiofiles (egui), and the //! MNW web server. Themes are authored by **intent** ("human design"): colors are //! declared by role (surface / content / action / status / line / category), not //! by hue. This crate is the single place that resolves an authored theme into a //! full set of intent tokens — including the derived interactive states //! (hover/active/selection/row-stripe/contrast) that each app used to recompute //! itself — and emits them as CSS variables or RGB tuples. //! //! Theme file shape: //! ```text //! [meta] //! name = "Nord" //! variant = "dark" # or "light" //! //! [surface] # container backgrounds by role/elevation //! page = "#2e3440"; raised = "#3b4252"; sunken = "#434c5e"; overlay = "#3b4252" //! //! [content] # text/ink by emphasis //! primary = "#d8dee9"; secondary = "#e5e9f0"; muted = "#616e88" //! //! [action] # interactive / brand color //! primary = "#81a1c1" //! //! [status] # state semantics //! danger = "#bf616a"; success = "#a3be8c"; warning = "#ebcb8b"; info = "#88c0d0" //! //! [line] //! border = "#4c566a" //! //! [category] # distinct decorative colors for tags/badges/charts //! one = "#bf616a"; two = "#a3be8c"; three = "#81a1c1" //! four = "#ebcb8b"; five = "#b48ead"; six = "#88c0d0" //! ``` use serde::Serialize; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; /// The color sections an authored theme may declare. pub const COLOR_SECTIONS: &[&str] = &["surface", "content", "action", "status", "line", "category"]; /// Theme metadata parsed from the `[meta]` section. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeMeta { pub id: String, pub name: String, pub variant: String, pub is_custom: bool, } /// A loaded theme: metadata plus the authored colors, flattened to dotted keys /// (e.g. `"surface.page"`, `"status.danger"`, `"category.one"`). #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeColors { pub meta: ThemeMeta, pub colors: HashMap, } // ============================================================================ // Color math — perceptual (OKLab) derivations + WCAG contrast. // // Interactive states (hover/active/selection/surfaces) are derived in OKLab so // equal steps look equal across every theme's hues (Ottosson 2020; the modern // CIELAB). Text-on-color is picked by the WCAG 2.x contrast ratio, not a naive // luminance threshold, so the choice actually meets AA where achievable. // This is the single source of truth shared by every product. // ============================================================================ /// An sRGB color. Hex round-trips losslessly. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Rgb { pub r: u8, pub g: u8, pub b: u8, } impl Rgb { /// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise. pub fn from_hex(s: &str) -> Option { let h = s.strip_prefix('#')?; let (r, g, b) = match h.len() { 6 => ( u8::from_str_radix(&h[0..2], 16).ok()?, u8::from_str_radix(&h[2..4], 16).ok()?, u8::from_str_radix(&h[4..6], 16).ok()?, ), 3 => { let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17); (d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?) } _ => return None, }; Some(Rgb { r, g, b }) } /// Lowercase `#rrggbb`. pub fn to_hex(self) -> String { format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) } pub fn tuple(self) -> (u8, u8, u8) { (self.r, self.g, self.b) } } /// A color in OKLab (perceptually uniform): `l` lightness in [0,1], `a`/`b` opponent axes. #[derive(Clone, Copy, Debug)] pub struct Oklab { pub l: f32, pub a: f32, pub b: f32, } fn srgb_to_linear(c: u8) -> f32 { let c = c as f32 / 255.0; if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } fn linear_to_srgb(c: f32) -> u8 { let c = c.clamp(0.0, 1.0); let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }; (v * 255.0).round().clamp(0.0, 255.0) as u8 } impl Rgb { /// Convert to OKLab (Ottosson's sRGB matrices). pub fn to_oklab(self) -> Oklab { let (r, g, b) = (srgb_to_linear(self.r), srgb_to_linear(self.g), srgb_to_linear(self.b)); let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt()); Oklab { l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, } } /// Convert from OKLab back to the nearest in-gamut sRGB. pub fn from_oklab(c: Oklab) -> Rgb { let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b; let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b; let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b; let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_); Rgb { r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), } } } /// WCAG 2.x relative luminance of an sRGB color. fn rel_luminance(c: Rgb) -> f32 { 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b) } /// WCAG 2.x contrast ratio between two colors, in [1, 21]. pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 { let (la, lb) = (rel_luminance(a), rel_luminance(b)); let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; (hi + 0.05) / (lo + 0.05) } /// Pick black or white for legible text on `bg`, by the higher WCAG contrast /// ratio (so the choice meets AA wherever the background allows it). pub fn readable_on(bg: Rgb) -> Rgb { let white = Rgb { r: 255, g: 255, b: 255 }; let black = Rgb { r: 0, g: 0, b: 0 }; if wcag_contrast(white, bg) >= wcag_contrast(black, bg) { white } else { black } } /// Shift OKLab lightness by `delta` (perceptually uniform). Positive lightens. pub fn lighten(c: Rgb, delta: f32) -> Rgb { let mut lab = c.to_oklab(); lab.l = (lab.l + delta).clamp(0.0, 1.0); Rgb::from_oklab(lab) } /// Shift OKLab lightness down by `delta` (perceptually uniform). pub fn darken(c: Rgb, delta: f32) -> Rgb { lighten(c, -delta) } /// Interpolate between `a` and `b` by `t` in [0,1] in OKLab (perceptual blend). pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb { let (x, y) = (a.to_oklab(), b.to_oklab()); Rgb::from_oklab(Oklab { l: x.l + (y.l - x.l) * t, a: x.a + (y.a - x.a) * t, b: x.b + (y.b - x.b) * t, }) } // ============================================================================ // Intent resolution // ============================================================================ /// Authored base intents: (TOML dotted source key, canonical token key). /// These are read straight from the theme; the token key is the CSS-var stem /// (`--{token}`) and the `rgb()` lookup key. pub const BASE_INTENTS: &[(&str, &str)] = &[ ("surface.page", "surface-page"), ("surface.raised", "surface-raised"), ("surface.sunken", "surface-sunken"), ("surface.overlay", "surface-overlay"), ("content.primary", "content"), ("content.secondary", "content-secondary"), ("content.muted", "content-muted"), ("action.primary", "action"), ("status.danger", "danger"), ("status.success", "success"), ("status.warning", "warning"), ("status.info", "info"), ("line.border", "border"), ("category.one", "category-one"), ("category.two", "category-two"), ("category.three", "category-three"), ("category.four", "category-four"), ("category.five", "category-five"), ("category.six", "category-six"), ]; /// A fully resolved intent layer: every token key → concrete `#rrggbb`. /// Includes both authored base intents and the computed derived intents. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SemanticTokens { pub meta: ThemeMeta, /// token-key → resolved hex. Stable, deterministic ordering. pub intents: BTreeMap, } impl SemanticTokens { /// Resolved hex for a token key, if present. pub fn hex(&self, key: &str) -> Option<&str> { self.intents.get(key).map(String::as_str) } /// Resolved RGB tuple for a token key (for egui / native consumers). pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> { self.intents.get(key).and_then(|h| Rgb::from_hex(h)).map(Rgb::tuple) } } /// Resolve an authored theme into the full intent token set. /// /// 1. Copy each present base intent from the authored colors. /// 2. Compute the derived interactive states from the base intents, using the /// same math the apps used to apply individually (so output is identical). /// Each derived token is emitted only when its source intents exist, mirroring /// the skip-missing behavior of the rest of the crate. pub fn resolve(theme: &ThemeColors) -> SemanticTokens { let mut intents: BTreeMap = BTreeMap::new(); // 1. Base intents (authored). Copy only values that parse as a hex color and // re-emit them in canonical `#rrggbb` form, so an authored value can never // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined // raw into a `\"\n[content]\nprimary = \"#111111\"\n", false, ) .unwrap(); let t = resolve(&theme); assert!(t.hex("surface-page").is_none(), "non-hex base intent leaked"); assert_eq!(t.hex("content").unwrap(), "#111111"); // The injected markup appears in no resolved value. assert!(!t.intents.values().any(|v| v.contains('<'))); } #[test] fn resolve_skips_derived_when_source_missing() { // No [action] => no action-derived tokens. let theme = parse_theme_str( "x", "[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n", false, ) .unwrap(); let t = resolve(&theme); assert!(t.hex("action").is_none()); assert!(t.hex("action-hover").is_none()); assert!(t.hex("selection").is_none()); assert_eq!(t.hex("border-strong").unwrap(), darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex()); } #[test] fn rgb_accessor_for_native_consumers() { let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); let t = resolve(&theme); assert_eq!(t.rgb("action"), Some((0x81, 0xa1, 0xc1))); assert_eq!(t.rgb("nonexistent"), None); } // ---- css emit ---- #[test] fn intent_css_vars_wraps_root_and_includes_tokens() { let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); let css = intent_css_vars(&resolve(&theme)); assert!(css.starts_with(":root {\n")); assert!(css.contains(" --surface-page: #2e3440;\n")); assert!(css.contains(" --danger: #bf616a;\n")); assert!(css.contains(" --action-hover: ")); assert!(css.trim_end().ends_with('}')); } // ---- loading / fs ---- #[test] fn load_and_resolve_round_trip() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let t = load_semantic(&dirs, "nord").unwrap(); assert_eq!(t.meta.name, "Nord"); assert_eq!(t.hex("action"), Some("#81a1c1")); } #[test] fn load_theme_rejects_invalid_id() { assert!(load_theme(&[], "../evil").is_err()); } #[test] fn list_themes_from_dirs_finds_toml_files() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap(); fs::write(dir.path().join("x.txt"), "ignored").unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let themes = list_themes_from_dirs(&dirs); assert_eq!(themes.len(), 1); assert_eq!(themes[0].id, "t"); } #[test] fn find_theme_path_reverse_priority() { let d1 = tempfile::tempdir().unwrap(); let d2 = tempfile::tempdir().unwrap(); fs::write(d1.path().join("s.toml"), "[meta]\n").unwrap(); fs::write(d2.path().join("s.toml"), "[meta]\n").unwrap(); let dirs = vec![(d1.path().to_path_buf(), false), (d2.path().to_path_buf(), true)]; let (path, is_custom) = find_theme_path(&dirs, "s").unwrap(); assert!(is_custom); assert_eq!(path, d2.path().join("s.toml")); } #[test] fn import_theme_valid_and_rejects_empty() { let src_dir = tempfile::tempdir().unwrap(); let custom_dir = tempfile::tempdir().unwrap(); let good = src_dir.path().join("my-theme.toml"); fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap(); let meta = import_theme(&good, custom_dir.path()).unwrap(); assert_eq!(meta.id, "my-theme"); assert!(custom_dir.path().join("my-theme.toml").exists()); let empty = src_dir.path().join("empty.toml"); fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap(); assert!(import_theme(&empty, custom_dir.path()).is_err()); } #[test] fn import_theme_rejects_invalid_toml() { let src_dir = tempfile::tempdir().unwrap(); let custom_dir = tempfile::tempdir().unwrap(); let src = src_dir.path().join("bad.toml"); fs::write(&src, "this is not [valid toml [[[").unwrap(); assert!(import_theme(&src, custom_dir.path()).is_err()); } #[test] fn delete_theme_removes_and_guards() { let custom = tempfile::tempdir().unwrap(); let path = custom.path().join("doomed.toml"); fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap(); delete_theme(custom.path(), "doomed").unwrap(); assert!(!path.exists()); assert!(delete_theme(custom.path(), "../etc/passwd").is_err()); assert!(delete_theme(custom.path(), "ghost").is_err()); } #[test] fn export_theme_copies_file() { let src_dir = tempfile::tempdir().unwrap(); let dest_dir = tempfile::tempdir().unwrap(); let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n"; fs::write(src_dir.path().join("e.toml"), content).unwrap(); let dirs = vec![(src_dir.path().to_path_buf(), false)]; let dest = dest_dir.path().join("out.toml"); export_theme(&dirs, "e", &dest).unwrap(); assert_eq!(fs::read_to_string(&dest).unwrap(), content); assert!(export_theme(&dirs, "missing", &dest).is_err()); } #[test] fn load_theme_preview_returns_role_swatches() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let p = load_theme_preview(&dirs, "nord").unwrap(); assert_eq!(p.background.as_deref(), Some("#2e3440")); // surface.page assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); // content.primary assert_eq!(p.accent.as_deref(), Some("#81a1c1")); // action.primary assert_eq!(p.border.as_deref(), Some("#4c566a")); // line.border } #[test] fn bundled_themes_dir_resolves_to_shipped_themes() { // The crate ships its themes, so this must resolve in-tree and the // Akari defaults the console falls back to must be present. let dir = bundled_themes_dir().expect("makeover ships a themes/ directory"); assert!(dir.join("akari-dawn.toml").is_file()); assert!(dir.join("akari-night.toml").is_file()); } #[test] fn every_theme_is_accounted_for_in_third_party_notices() { // Attribution is a redistribution obligation, not a nicety: adding a // theme without a notice entry silently ships someone's work // uncredited. Fail here instead. let notices = std::fs::read_to_string( Path::new(env!("CARGO_MANIFEST_DIR")).join("THIRD-PARTY-NOTICES.md"), ) .expect("THIRD-PARTY-NOTICES.md must exist"); let missing: Vec<&str> = embedded_themes() .map(|(id, _)| id) .filter(|id| !notices.contains(*id)) .collect(); assert!( missing.is_empty(), "themes missing from THIRD-PARTY-NOTICES.md: {missing:?}" ); } #[test] fn adapted_themes_carry_inline_attribution() { // Each adapted file must name its upstream in-file, so the credit // survives someone copying a single .toml out of the crate. const ORIGINALS: [&str; 4] = ["makenotwork", "goingson", "audiofiles", "high-contrast"]; for (id, source) in embedded_themes() { if ORIGINALS.contains(&id) { continue; } assert!( source.contains("adapted from"), "adapted theme `{id}` is missing its inline attribution header" ); } } #[test] fn embedded_themes_match_the_directory() { // The embedded copy and themes/ are two views of one source. If they // ever disagree, path-based and path-free consumers render different // theme sets, which is exactly the drift shipping the data was meant // to prevent. let dir = bundled_themes_dir().unwrap(); let mut on_disk: Vec = std::fs::read_dir(&dir) .unwrap() .filter_map(|e| { let path = e.ok()?.path(); if path.extension()? != "toml" { return None; } Some(path.file_stem()?.to_str()?.to_string()) }) .collect(); let mut embedded: Vec = embedded_themes().map(|(id, _)| id.to_string()).collect(); on_disk.sort(); embedded.sort(); assert_eq!(embedded, on_disk, "embedded theme set drifted from themes/"); } #[test] fn every_embedded_theme_parses() { // Guards the path-free consumers (MNW server, the Tauri build steps) // the same way every_shipped_theme_loads guards the path-based ones. let mut count = 0; for (id, source) in embedded_themes() { parse_theme_str(id, source, false) .unwrap_or_else(|e| panic!("embedded theme `{id}` failed to parse: {e}")); count += 1; } assert!(count >= 30, "expected the full theme set, got {count}"); } #[test] fn every_shipped_theme_loads() { // Guards the data, not just the loader: a malformed or truncated // .toml in themes/ is a shipping bug, and it should fail here rather // than at a user's first launch. let dir = bundled_themes_dir().unwrap(); let dirs = vec![(dir.clone(), false)]; let themes = list_themes_from_dirs(&dirs); assert!(themes.len() >= 30, "expected the full theme set, got {}", themes.len()); for meta in &themes { load_theme(&dirs, &meta.id) .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id)); } } }