//! Theme palette: makeover intents resolved into ratatui `Color`s, plus //! the two Alloy-derived border tokens. //! //! Per docs/TOKENS.md, Alloy consumes makeover `.toml` files (the same //! schema every make-family app already reads) and derives two extra tokens //! locally so theme files stay minimal and cross-app compatible: //! //! - `border-subtle = mix(line.border, surface.page, 60%)` decorative divider //! - `border-strong = mix(line.border, content.primary, 65%)` focus / selection //! //! Mix is in linear sRGB, matching TOKENS.md's worked audit math. //! //! ratatui is immediate-mode with per-widget styling — there is no global //! visuals object. Widgets in this crate take a `&Theme` at construction time //! and pull colors from it. Apps build one `Theme` per theme load (via //! `makeover::load_theme` + `Theme::from_theme`) and thread it through. use ratatui::style::Color; use makeover::{Rgb, ThemeColors}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Light, Dark, HighContrast, } #[derive(Debug, Clone, Copy)] pub struct Theme { pub mode: Mode, pub surface_page: Color, pub surface_raised: Color, pub surface_sunken: Color, pub surface_overlay: Color, pub content_primary: Color, pub content_secondary: Color, pub content_muted: Color, pub action_primary: Color, pub status_danger: Color, pub status_success: Color, pub status_warning: Color, pub status_info: Color, pub line_border: Color, pub border_subtle: Color, pub border_strong: Color, pub category: [Color; 6], } #[derive(Debug, Clone)] pub enum ThemeError { MissingKey(&'static str), InvalidHex { key: &'static str, value: String }, } impl std::fmt::Display for ThemeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"), ThemeError::InvalidHex { key, value } => { write!(f, "theme key `{key}` has invalid hex value `{value}`") } } } } impl std::error::Error for ThemeError {} impl Theme { /// Resolve a loaded makeover `ThemeColors` into an Alloy `Theme`. /// Requires every intent Alloy renders — a malformed or partial theme is /// rejected explicitly rather than silently rendering with defaults. pub fn from_theme(theme: &ThemeColors) -> Result { let get = |key: &'static str| -> Result { let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?; Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex { key, value: hex.clone(), }) }; let surface_page = get("surface.page")?; let content_primary = get("content.primary")?; let line_border = get("line.border")?; let border_subtle = mix_linear_srgb(line_border, surface_page, 0.60); let border_strong = mix_linear_srgb(line_border, content_primary, 0.65); let mode = match theme.meta.variant.as_str() { "dark" => Mode::Dark, "high-contrast" => Mode::HighContrast, _ => Mode::Light, }; Ok(Self { mode, surface_page: rgb(surface_page), surface_raised: rgb(get("surface.raised")?), surface_sunken: rgb(get("surface.sunken")?), surface_overlay: rgb(get("surface.overlay")?), content_primary: rgb(content_primary), content_secondary: rgb(get("content.secondary")?), content_muted: rgb(get("content.muted")?), action_primary: rgb(get("action.primary")?), status_danger: rgb(get("status.danger")?), status_success: rgb(get("status.success")?), status_warning: rgb(get("status.warning")?), status_info: rgb(get("status.info")?), line_border: rgb(line_border), border_subtle: rgb(border_subtle), border_strong: rgb(border_strong), category: [ rgb(get("category.one")?), rgb(get("category.two")?), rgb(get("category.three")?), rgb(get("category.four")?), rgb(get("category.five")?), rgb(get("category.six")?), ], }) } } fn rgb(c: Rgb) -> Color { Color::Rgb(c.r, c.g, c.b) } // Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are // gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually // less uniform than OKLab but keeps the derived hex reproducible against the // contrast tables in TOKENS.md. fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb { let al = srgb_to_linear(a); let bl = srgb_to_linear(b); let m = ( al.0 + (bl.0 - al.0) * t, al.1 + (bl.1 - al.1) * t, al.2 + (bl.2 - al.2) * t, ); linear_to_srgb(m) } fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) { (channel_to_linear(c.r), channel_to_linear(c.g), channel_to_linear(c.b)) } fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb { Rgb { r: channel_to_srgb(c.0), g: channel_to_srgb(c.1), b: channel_to_srgb(c.2), } } fn channel_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 channel_to_srgb(c: f32) -> u8 { 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 } #[cfg(test)] mod tests { use super::*; // TOKENS.md line 61 anchors the derivation math against Akari Dawn: // line.border = #cabeae, content.primary = #1a1816, mix 65% toward primary // must produce #7f786d (the value the contrast-audit table is calibrated on). // If this test fails, the audit table in TOKENS.md is stale, not the code. #[test] fn akari_dawn_border_strong_matches_tokens_md() { let border = Rgb::from_hex("#cabeae").unwrap(); let primary = Rgb::from_hex("#1a1816").unwrap(); let got = mix_linear_srgb(border, primary, 0.65); assert_eq!( (got.r, got.g, got.b), (0x7f, 0x78, 0x6d), "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d", got.r, got.g, got.b ); } }