/// Semantic color tokens and the Color type. /// /// Prefer semantic tokens (Primary, Surface, etc.) over explicit values. /// The theme maps tokens to actual colors — swap the theme, everything updates. /// Use Hex/Rgba only as an escape hatch when you need a one-off value. /// A color value — either a semantic token or an explicit value. #[derive(Debug, Clone, PartialEq)] pub enum Color { // --- Semantic tokens (preferred) --- /// Brand primary color. Use for key actions and highlights. Primary, /// Content drawn on top of Primary. OnPrimary, /// A less prominent container for primary-branded content. PrimaryContainer, /// Secondary accent color. Secondary, /// Content drawn on top of Secondary. OnSecondary, /// The page/screen background. Background, /// Content drawn on top of Background. OnBackground, /// Surface color for cards, sheets, dialogs. Surface, /// Content drawn on top of Surface. OnSurface, /// A surface variant, slightly different from Surface. SurfaceVariant, /// Error state color. Error, /// Content drawn on top of Error. OnError, /// Outline/border color. Outline, /// Muted outline (dividers, subtle borders). OutlineVariant, // --- Escape hatches --- /// An explicit hex color string (e.g. "#3b82f6" or "#3b82f6ff"). Hex(String), /// An explicit RGBA color (channels 0–255, alpha 0.0–1.0). Rgba(u8, u8, u8, f32), /// Apply opacity to any color. Opacity(Box, f32), } impl Color { /// Create an Opacity variant. pub fn with_opacity(self, opacity: f32) -> Self { Color::Opacity(Box::new(self), opacity.clamp(0.0, 1.0)) } /// Convenience: fully transparent. pub fn transparent() -> Self { Color::Rgba(0, 0, 0, 0.0) } /// Convenience: pure white. pub fn white() -> Self { Color::Rgba(255, 255, 255, 1.0) } /// Convenience: pure black. pub fn black() -> Self { Color::Rgba(0, 0, 0, 1.0) } /// Resolve a hex string like "#rrggbb" or "#rrggbbaa" to Rgba. /// Returns None if the string is not a valid hex color. pub fn parse_hex(s: &str) -> Option { let s = s.trim_start_matches('#'); match s.len() { 6 => { let r = u8::from_str_radix(&s[0..2], 16).ok()?; let g = u8::from_str_radix(&s[2..4], 16).ok()?; let b = u8::from_str_radix(&s[4..6], 16).ok()?; Some(Color::Rgba(r, g, b, 1.0)) } 8 => { let r = u8::from_str_radix(&s[0..2], 16).ok()?; let g = u8::from_str_radix(&s[2..4], 16).ok()?; let b = u8::from_str_radix(&s[4..6], 16).ok()?; let a = u8::from_str_radix(&s[6..8], 16).ok()?; Some(Color::Rgba(r, g, b, a as f32 / 255.0)) } _ => None, } } } /// Maps semantic color tokens to actual RGBA values. /// One ColorScheme per theme mode (light/dark). #[derive(Debug, Clone)] pub struct ColorScheme { pub primary: (u8, u8, u8, f32), pub on_primary: (u8, u8, u8, f32), pub primary_container: (u8, u8, u8, f32), pub secondary: (u8, u8, u8, f32), pub on_secondary: (u8, u8, u8, f32), pub background: (u8, u8, u8, f32), pub on_background: (u8, u8, u8, f32), pub surface: (u8, u8, u8, f32), pub on_surface: (u8, u8, u8, f32), pub surface_variant: (u8, u8, u8, f32), pub error: (u8, u8, u8, f32), pub on_error: (u8, u8, u8, f32), pub outline: (u8, u8, u8, f32), pub outline_variant: (u8, u8, u8, f32), } impl ColorScheme { /// Default light color scheme. pub fn light() -> Self { Self { primary: (59, 130, 246, 1.0), // blue-500 on_primary: (255, 255, 255, 1.0), primary_container: (219, 234, 254, 1.0), // blue-100 secondary: (100, 116, 139, 1.0), // slate-500 on_secondary: (255, 255, 255, 1.0), background: (255, 255, 255, 1.0), on_background: (15, 23, 42, 1.0), // slate-900 surface: (248, 250, 252, 1.0), // slate-50 on_surface: (15, 23, 42, 1.0), surface_variant: (241, 245, 249, 1.0), // slate-100 error: (239, 68, 68, 1.0), // red-500 on_error: (255, 255, 255, 1.0), outline: (203, 213, 225, 1.0), // slate-300 outline_variant: (226, 232, 240, 1.0), // slate-200 } } /// Default dark color scheme. pub fn dark() -> Self { Self { primary: (96, 165, 250, 1.0), // blue-400 on_primary: (15, 23, 42, 1.0), primary_container: (30, 58, 138, 1.0), // blue-900 secondary: (148, 163, 184, 1.0), // slate-400 on_secondary: (15, 23, 42, 1.0), background: (15, 23, 42, 1.0), // slate-900 on_background: (248, 250, 252, 1.0), surface: (30, 41, 59, 1.0), // slate-800 on_surface: (248, 250, 252, 1.0), surface_variant: (51, 65, 85, 1.0), // slate-700 error: (248, 113, 113, 1.0), // red-400 on_error: (15, 23, 42, 1.0), outline: (71, 85, 105, 1.0), // slate-600 outline_variant: (51, 65, 85, 1.0), // slate-700 } } /// Resolve a semantic Color token to its RGBA tuple. pub fn resolve(&self, color: &Color) -> (u8, u8, u8, f32) { match color { Color::Primary => self.primary, Color::OnPrimary => self.on_primary, Color::PrimaryContainer => self.primary_container, Color::Secondary => self.secondary, Color::OnSecondary => self.on_secondary, Color::Background => self.background, Color::OnBackground => self.on_background, Color::Surface => self.surface, Color::OnSurface => self.on_surface, Color::SurfaceVariant => self.surface_variant, Color::Error => self.error, Color::OnError => self.on_error, Color::Outline => self.outline, Color::OutlineVariant => self.outline_variant, Color::Hex(s) => Color::parse_hex(s) .map(|c| self.resolve(&c)) .unwrap_or((0, 0, 0, 1.0)), Color::Rgba(r, g, b, a) => (*r, *g, *b, *a), Color::Opacity(inner, opacity) => { let (r, g, b, a) = self.resolve(inner); (r, g, b, a * opacity) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_hex_6_chars() { let c = Color::parse_hex("#3b82f6").unwrap(); assert_eq!(c, Color::Rgba(0x3b, 0x82, 0xf6, 1.0)); } #[test] fn parse_hex_8_chars() { let c = Color::parse_hex("#3b82f680").unwrap(); if let Color::Rgba(r, g, b, a) = c { assert_eq!(r, 0x3b); assert_eq!(g, 0x82); assert_eq!(b, 0xf6); assert!((a - 0x80 as f32 / 255.0).abs() < 0.01); } else { panic!("expected Rgba"); } } #[test] fn parse_hex_invalid() { assert!(Color::parse_hex("not-a-color").is_none()); assert!(Color::parse_hex("#gg0000").is_none()); } #[test] fn with_opacity() { let c = Color::Primary.with_opacity(0.5); assert!(matches!(c, Color::Opacity(_, _))); } #[test] fn color_scheme_light_resolves_primary() { let scheme = ColorScheme::light(); let (r, g, b, a) = scheme.resolve(&Color::Primary); assert_eq!((r, g, b), (59, 130, 246)); assert!((a - 1.0).abs() < 0.001); } #[test] fn color_scheme_dark_resolves_background() { let scheme = ColorScheme::dark(); let (r, g, b, _) = scheme.resolve(&Color::Background); assert_eq!((r, g, b), (15, 23, 42)); } #[test] fn opacity_modifies_alpha() { let scheme = ColorScheme::light(); let color = Color::Primary.with_opacity(0.5); let (_, _, _, a) = scheme.resolve(&color); assert!((a - 0.5).abs() < 0.001); } #[test] fn rgba_passthrough() { let scheme = ColorScheme::light(); let color = Color::Rgba(10, 20, 30, 0.8); let (r, g, b, a) = scheme.resolve(&color); assert_eq!((r, g, b), (10, 20, 30)); assert!((a - 0.8).abs() < 0.001); } }