use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Color {
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 255 }
}
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
pub fn from_hex(hex: &str) -> Option<Self> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(hex.get(0..2)?, 16).ok()?;
let g = u8::from_str_radix(hex.get(2..4)?, 16).ok()?;
let b = u8::from_str_radix(hex.get(4..6)?, 16).ok()?;
Some(Self::rgb(r, g, b))
}
#[allow(clippy::wrong_self_convention)]
pub fn to_hex(&self) -> String {
format!("{:02X}{:02X}{:02X}", self.r, self.g, self.b)
}
#[allow(clippy::wrong_self_convention)]
pub fn to_css_hex(&self) -> String {
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
}
#[allow(clippy::wrong_self_convention)]
pub fn to_css_rgba(&self) -> String {
if self.a == 255 {
format!("rgb({}, {}, {})", self.r, self.g, self.b)
} else {
format!("rgba({}, {}, {}, {:.2})", self.r, self.g, self.b, self.a as f32 / 255.0)
}
}
pub fn with_opacity(&self, opacity: f32) -> Self {
Self { r: self.r, g: self.g, b: self.b, a: (opacity.clamp(0.0, 1.0) * 255.0) as u8 }
}
pub fn lighten(&self, amount: f32) -> Self {
let amount = amount.clamp(0.0, 1.0);
Self {
r: (self.r as f32 + (255.0 - self.r as f32) * amount) as u8,
g: (self.g as f32 + (255.0 - self.g as f32) * amount) as u8,
b: (self.b as f32 + (255.0 - self.b as f32) * amount) as u8,
a: self.a,
}
}
pub fn darken(&self, amount: f32) -> Self {
let amount = amount.clamp(0.0, 1.0);
Self {
r: (self.r as f32 * (1.0 - amount)) as u8,
g: (self.g as f32 * (1.0 - amount)) as u8,
b: (self.b as f32 * (1.0 - amount)) as u8,
a: self.a,
}
}
}
impl fmt::Display for Color {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_css_hex())
}
}
impl Default for Color {
fn default() -> Self {
Self::rgb(0, 0, 0)
}
}
#[derive(Debug, Clone)]
pub struct MaterialPalette {
pub primary: Color,
pub on_primary: Color,
pub primary_container: Color,
pub on_primary_container: Color,
pub secondary: Color,
pub on_secondary: Color,
pub tertiary: Color,
pub on_tertiary: Color,
pub error: Color,
pub on_error: Color,
pub surface: Color,
pub on_surface: Color,
pub surface_variant: Color,
pub on_surface_variant: Color,
pub outline: Color,
pub outline_variant: Color,
pub background: Color,
pub on_background: Color,
}
impl MaterialPalette {
pub fn light() -> Self {
Self {
primary: Color::rgb(103, 80, 164),
on_primary: Color::rgb(255, 255, 255),
primary_container: Color::rgb(234, 221, 255),
on_primary_container: Color::rgb(33, 0, 93),
secondary: Color::rgb(98, 91, 113),
on_secondary: Color::rgb(255, 255, 255),
tertiary: Color::rgb(125, 82, 96),
on_tertiary: Color::rgb(255, 255, 255),
error: Color::rgb(179, 38, 30),
on_error: Color::rgb(255, 255, 255),
surface: Color::rgb(255, 251, 254),
on_surface: Color::rgb(28, 27, 31),
surface_variant: Color::rgb(231, 224, 236),
on_surface_variant: Color::rgb(73, 69, 79),
outline: Color::rgb(121, 116, 126),
outline_variant: Color::rgb(202, 196, 208),
background: Color::rgb(255, 251, 254),
on_background: Color::rgb(28, 27, 31),
}
}
pub fn dark() -> Self {
Self {
primary: Color::rgb(208, 188, 255),
on_primary: Color::rgb(56, 30, 114),
primary_container: Color::rgb(79, 55, 139),
on_primary_container: Color::rgb(234, 221, 255),
secondary: Color::rgb(204, 194, 220),
on_secondary: Color::rgb(51, 45, 65),
tertiary: Color::rgb(239, 184, 200),
on_tertiary: Color::rgb(73, 37, 50),
error: Color::rgb(242, 184, 181),
on_error: Color::rgb(96, 20, 16),
surface: Color::rgb(28, 27, 31),
on_surface: Color::rgb(230, 225, 229),
surface_variant: Color::rgb(73, 69, 79),
on_surface_variant: Color::rgb(202, 196, 208),
outline: Color::rgb(147, 143, 153),
outline_variant: Color::rgb(73, 69, 79),
background: Color::rgb(28, 27, 31),
on_background: Color::rgb(230, 225, 229),
}
}
pub fn with_primary(primary: Color) -> Self {
let mut palette = Self::light();
palette.primary = primary;
palette
}
pub fn is_valid_color(&self, color: &Color) -> bool {
color == &self.primary
|| color == &self.on_primary
|| color == &self.primary_container
|| color == &self.on_primary_container
|| color == &self.secondary
|| color == &self.on_secondary
|| color == &self.tertiary
|| color == &self.on_tertiary
|| color == &self.error
|| color == &self.on_error
|| color == &self.surface
|| color == &self.on_surface
|| color == &self.surface_variant
|| color == &self.on_surface_variant
|| color == &self.outline
|| color == &self.outline_variant
|| color == &self.background
|| color == &self.on_background
}
pub fn all_colors(&self) -> Vec<Color> {
vec![
self.primary,
self.on_primary,
self.primary_container,
self.on_primary_container,
self.secondary,
self.on_secondary,
self.tertiary,
self.on_tertiary,
self.error,
self.on_error,
self.surface,
self.on_surface,
self.surface_variant,
self.on_surface_variant,
self.outline,
self.outline_variant,
self.background,
self.on_background,
]
}
}
impl Default for MaterialPalette {
fn default() -> Self {
Self::light()
}
}
#[derive(Debug, Clone)]
pub struct SovereignPalette {
pub material: MaterialPalette,
pub trueno: Color,
pub aprender: Color,
pub realizar: Color,
pub batuta: Color,
pub success: Color,
pub warning: Color,
pub info: Color,
}
impl SovereignPalette {
pub fn light() -> Self {
Self {
material: MaterialPalette::light(),
trueno: Color::rgb(255, 109, 0), aprender: Color::rgb(41, 98, 255), realizar: Color::rgb(0, 200, 83), batuta: Color::rgb(103, 80, 164), success: Color::rgb(0, 200, 83), warning: Color::rgb(255, 214, 0), info: Color::rgb(0, 176, 255), }
}
pub fn dark() -> Self {
Self {
material: MaterialPalette::dark(),
trueno: Color::rgb(255, 171, 64), aprender: Color::rgb(130, 177, 255), realizar: Color::rgb(105, 240, 174), batuta: Color::rgb(208, 188, 255), success: Color::rgb(105, 240, 174), warning: Color::rgb(255, 229, 127), info: Color::rgb(128, 216, 255), }
}
pub fn component_color(&self, component: &str) -> Color {
match component.to_lowercase().as_str() {
"trueno" => self.trueno,
"aprender" => self.aprender,
"realizar" => self.realizar,
"batuta" => self.batuta,
_ => self.material.outline,
}
}
}
impl Default for SovereignPalette {
fn default() -> Self {
Self::light()
}
}
#[derive(Debug, Clone)]
pub struct VideoPalette {
pub canvas: Color,
pub surface: Color,
pub badge_grey: Color,
pub badge_blue: Color,
pub badge_green: Color,
pub badge_gold: Color,
pub heading: Color,
pub heading_secondary: Color,
pub body: Color,
pub accent_blue: Color,
pub accent_green: Color,
pub accent_gold: Color,
pub accent_red: Color,
pub outline: Color,
}
impl VideoPalette {
pub fn dark() -> Self {
Self {
canvas: Color::from_hex("#0f172a").expect("valid hex"),
surface: Color::from_hex("#1e293b").expect("valid hex"),
badge_grey: Color::from_hex("#374151").expect("valid hex"),
badge_blue: Color::from_hex("#1e3a5f").expect("valid hex"),
badge_green: Color::from_hex("#14532d").expect("valid hex"),
badge_gold: Color::from_hex("#713f12").expect("valid hex"),
heading: Color::from_hex("#f1f5f9").expect("valid hex"),
heading_secondary: Color::from_hex("#d1d5db").expect("valid hex"),
body: Color::from_hex("#94a3b8").expect("valid hex"),
accent_blue: Color::from_hex("#60a5fa").expect("valid hex"),
accent_green: Color::from_hex("#4ade80").expect("valid hex"),
accent_gold: Color::from_hex("#fde047").expect("valid hex"),
accent_red: Color::from_hex("#ef4444").expect("valid hex"),
outline: Color::from_hex("#475569").expect("valid hex"),
}
}
pub fn light() -> Self {
Self {
canvas: Color::from_hex("#f8fafc").expect("valid hex"),
surface: Color::from_hex("#ffffff").expect("valid hex"),
badge_grey: Color::from_hex("#e5e7eb").expect("valid hex"),
badge_blue: Color::from_hex("#dbeafe").expect("valid hex"),
badge_green: Color::from_hex("#dcfce7").expect("valid hex"),
badge_gold: Color::from_hex("#fef9c3").expect("valid hex"),
heading: Color::from_hex("#0f172a").expect("valid hex"),
heading_secondary: Color::from_hex("#374151").expect("valid hex"),
body: Color::from_hex("#475569").expect("valid hex"),
accent_blue: Color::from_hex("#2563eb").expect("valid hex"),
accent_green: Color::from_hex("#16a34a").expect("valid hex"),
accent_gold: Color::from_hex("#ca8a04").expect("valid hex"),
accent_red: Color::from_hex("#dc2626").expect("valid hex"),
outline: Color::from_hex("#94a3b8").expect("valid hex"),
}
}
pub fn verify_contrast(text: &Color, bg: &Color) -> bool {
contrast_ratio(text, bg) >= 4.5
}
}
impl Default for VideoPalette {
fn default() -> Self {
Self::dark()
}
}
pub const FORBIDDEN_PAIRINGS: &[(&str, &str)] = &[
("#64748b", "#0f172a"), ("#6b7280", "#1e293b"), ("#3b82f6", "#1e293b"), ("#475569", "#0f172a"), ];
fn channel_luminance(c: u8) -> f64 {
let c = c as f64 / 255.0;
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn relative_luminance(color: &Color) -> f64 {
0.2126 * channel_luminance(color.r)
+ 0.7152 * channel_luminance(color.g)
+ 0.0722 * channel_luminance(color.b)
}
pub fn contrast_ratio(c1: &Color, c2: &Color) -> f64 {
let l1 = relative_luminance(c1);
let l2 = relative_luminance(c2);
let lighter = l1.max(l2);
let darker = l1.min(l2);
(lighter + 0.05) / (darker + 0.05)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_color_from_hex() {
let color = Color::rgb(103, 80, 164);
assert_eq!(color.r, 103);
assert_eq!(color.g, 80);
assert_eq!(color.b, 164);
}
#[test]
fn test_color_from_hex_no_hash() {
let color = Color::from_hex("6750A4").expect("unexpected failure");
assert_eq!(color.r, 103);
assert_eq!(color.g, 80);
assert_eq!(color.b, 164);
}
#[test]
fn test_color_to_hex() {
let color = Color::rgb(103, 80, 164);
assert_eq!(color.to_hex(), "6750A4");
assert_eq!(color.to_css_hex(), "#6750A4");
}
#[test]
fn test_color_to_css_rgba() {
let color = Color::rgb(103, 80, 164);
assert_eq!(color.to_css_rgba(), "rgb(103, 80, 164)");
let color_alpha = Color::rgba(103, 80, 164, 128);
assert!(color_alpha.to_css_rgba().starts_with("rgba(103, 80, 164,"));
}
#[test]
fn test_color_with_opacity() {
let color = Color::rgb(255, 255, 255);
let semi = color.with_opacity(0.5);
assert_eq!(semi.a, 127);
}
#[test]
fn test_color_lighten() {
let color = Color::rgb(100, 100, 100);
let lighter = color.lighten(0.5);
assert!(lighter.r > color.r);
assert!(lighter.g > color.g);
assert!(lighter.b > color.b);
}
#[test]
fn test_color_darken() {
let color = Color::rgb(100, 100, 100);
let darker = color.darken(0.5);
assert!(darker.r < color.r);
assert!(darker.g < color.g);
assert!(darker.b < color.b);
}
#[test]
fn test_material_palette_light() {
let palette = MaterialPalette::light();
assert_eq!(palette.primary.to_css_hex(), "#6750A4");
assert_eq!(palette.surface.to_css_hex(), "#FFFBFE");
assert_eq!(palette.outline.to_css_hex(), "#79747E");
}
#[test]
fn test_material_palette_dark() {
let palette = MaterialPalette::dark();
assert_eq!(palette.primary.to_css_hex(), "#D0BCFF");
assert_eq!(palette.surface.to_css_hex(), "#1C1B1F");
}
#[test]
fn test_material_palette_validation() {
let palette = MaterialPalette::light();
assert!(palette.is_valid_color(&palette.primary));
assert!(palette.is_valid_color(&palette.surface));
assert!(!palette.is_valid_color(&Color::rgb(1, 2, 3)));
}
#[test]
fn test_sovereign_palette() {
let palette = SovereignPalette::light();
assert_eq!(palette.trueno.to_css_hex(), "#FF6D00");
assert_eq!(palette.aprender.to_css_hex(), "#2962FF");
assert_eq!(palette.realizar.to_css_hex(), "#00C853");
}
#[test]
fn test_sovereign_component_color() {
let palette = SovereignPalette::light();
assert_eq!(palette.component_color("trueno"), palette.trueno);
assert_eq!(palette.component_color("APRENDER"), palette.aprender);
assert_eq!(palette.component_color("unknown"), palette.material.outline);
}
#[test]
fn test_color_display() {
let color = Color::rgb(103, 80, 164);
assert_eq!(format!("{}", color), "#6750A4");
}
#[test]
fn test_color_default() {
let color = Color::default();
assert_eq!(color.r, 0);
assert_eq!(color.g, 0);
assert_eq!(color.b, 0);
assert_eq!(color.a, 255);
}
#[test]
fn test_material_palette_with_primary() {
let custom_primary = Color::rgb(255, 0, 0);
let palette = MaterialPalette::with_primary(custom_primary);
assert_eq!(palette.primary, custom_primary);
}
#[test]
fn test_material_palette_all_colors() {
let palette = MaterialPalette::light();
let colors = palette.all_colors();
assert_eq!(colors.len(), 18);
assert!(colors.contains(&palette.primary));
assert!(colors.contains(&palette.surface));
assert!(colors.contains(&palette.error));
}
#[test]
fn test_material_palette_default() {
let palette = MaterialPalette::default();
let light = MaterialPalette::light();
assert_eq!(palette.primary, light.primary);
}
#[test]
fn test_sovereign_palette_dark() {
let palette = SovereignPalette::dark();
assert_eq!(palette.trueno.to_css_hex(), "#FFAB40");
assert_eq!(palette.aprender.to_css_hex(), "#82B1FF");
}
#[test]
fn test_sovereign_palette_default() {
let palette = SovereignPalette::default();
let light = SovereignPalette::light();
assert_eq!(palette.trueno, light.trueno);
}
#[test]
fn test_color_from_hex_invalid_length() {
assert!(Color::from_hex("#12").is_none());
assert!(Color::from_hex("#1234567").is_none());
}
#[test]
fn test_color_from_hex_invalid_chars() {
assert!(Color::from_hex("#GGHHII").is_none());
}
#[test]
fn test_color_equality() {
let c1 = Color::rgb(100, 200, 50);
let c2 = Color::rgb(100, 200, 50);
let c3 = Color::rgb(100, 200, 51);
assert_eq!(c1, c2);
assert_ne!(c1, c3);
}
#[test]
fn test_color_lighten_clamp() {
let white = Color::rgb(255, 255, 255);
let lightened = white.lighten(0.5);
assert_eq!(lightened.r, 255);
assert_eq!(lightened.g, 255);
assert_eq!(lightened.b, 255);
}
#[test]
fn test_color_darken_clamp() {
let black = Color::rgb(0, 0, 0);
let darkened = black.darken(0.5);
assert_eq!(darkened.r, 0);
assert_eq!(darkened.g, 0);
assert_eq!(darkened.b, 0);
}
#[test]
fn test_color_with_opacity_clamp() {
let color = Color::rgb(100, 100, 100);
let over = color.with_opacity(1.5);
assert_eq!(over.a, 255);
let under = color.with_opacity(-0.5);
assert_eq!(under.a, 0);
}
#[test]
fn test_video_palette_dark() {
let vp = VideoPalette::dark();
assert_eq!(vp.canvas.to_css_hex(), "#0F172A");
assert_eq!(vp.surface.to_css_hex(), "#1E293B");
assert_eq!(vp.heading.to_css_hex(), "#F1F5F9");
}
#[test]
fn test_video_palette_light() {
let vp = VideoPalette::light();
assert_eq!(vp.canvas.to_css_hex(), "#F8FAFC");
assert_eq!(vp.surface.to_css_hex(), "#FFFFFF");
assert_eq!(vp.heading.to_css_hex(), "#0F172A");
}
#[test]
fn test_video_palette_default() {
let vp = VideoPalette::default();
assert_eq!(vp.canvas, VideoPalette::dark().canvas);
}
#[test]
fn test_video_palette_verify_contrast_passes() {
let dark = VideoPalette::dark();
assert!(VideoPalette::verify_contrast(&dark.heading, &dark.canvas));
assert!(VideoPalette::verify_contrast(&dark.heading, &dark.surface));
assert!(VideoPalette::verify_contrast(&dark.accent_gold, &dark.canvas));
}
#[test]
fn test_video_palette_verify_contrast_fails_for_forbidden() {
for (text_hex, bg_hex) in FORBIDDEN_PAIRINGS {
let text = Color::from_hex(text_hex).expect("unexpected failure");
let bg = Color::from_hex(bg_hex).expect("unexpected failure");
assert!(
!VideoPalette::verify_contrast(&text, &bg),
"Expected forbidden pairing {} on {} to fail contrast check, ratio: {:.2}",
text_hex,
bg_hex,
contrast_ratio(&text, &bg)
);
}
}
#[test]
fn test_contrast_ratio_black_on_white() {
let ratio = contrast_ratio(&Color::rgb(0, 0, 0), &Color::rgb(255, 255, 255));
assert!(ratio > 20.0 && ratio < 22.0, "Expected ~21:1, got {:.2}", ratio);
}
#[test]
fn test_contrast_ratio_same_color() {
let c = Color::rgb(128, 128, 128);
let ratio = contrast_ratio(&c, &c);
assert!((ratio - 1.0).abs() < 0.01);
}
#[test]
fn test_forbidden_pairings_count() {
assert_eq!(FORBIDDEN_PAIRINGS.len(), 4);
}
#[test]
fn test_video_palette_light_contrast() {
let light = VideoPalette::light();
assert!(VideoPalette::verify_contrast(&light.heading, &light.canvas));
assert!(VideoPalette::verify_contrast(&light.body, &light.surface));
}
}