1use std::fmt;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct Color {
10 pub r: u8,
11 pub g: u8,
12 pub b: u8,
13 pub a: u8,
14}
15
16impl Color {
17 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
19 Self { r, g, b, a: 255 }
20 }
21
22 pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
24 Self { r, g, b, a }
25 }
26
27 pub fn from_hex(hex: &str) -> Option<Self> {
29 let hex = hex.trim_start_matches('#');
30 if hex.len() != 6 {
31 return None;
32 }
33
34 let r = u8::from_str_radix(hex.get(0..2)?, 16).ok()?;
35 let g = u8::from_str_radix(hex.get(2..4)?, 16).ok()?;
36 let b = u8::from_str_radix(hex.get(4..6)?, 16).ok()?;
37
38 Some(Self::rgb(r, g, b))
39 }
40
41 #[allow(clippy::wrong_self_convention)]
43 pub fn to_hex(&self) -> String {
44 format!("{:02X}{:02X}{:02X}", self.r, self.g, self.b)
45 }
46
47 #[allow(clippy::wrong_self_convention)]
49 pub fn to_css_hex(&self) -> String {
50 format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
51 }
52
53 #[allow(clippy::wrong_self_convention)]
55 pub fn to_css_rgba(&self) -> String {
56 if self.a == 255 {
57 format!("rgb({}, {}, {})", self.r, self.g, self.b)
58 } else {
59 format!("rgba({}, {}, {}, {:.2})", self.r, self.g, self.b, self.a as f32 / 255.0)
60 }
61 }
62
63 pub fn with_opacity(&self, opacity: f32) -> Self {
65 Self { r: self.r, g: self.g, b: self.b, a: (opacity.clamp(0.0, 1.0) * 255.0) as u8 }
66 }
67
68 pub fn lighten(&self, amount: f32) -> Self {
70 let amount = amount.clamp(0.0, 1.0);
71 Self {
72 r: (self.r as f32 + (255.0 - self.r as f32) * amount) as u8,
73 g: (self.g as f32 + (255.0 - self.g as f32) * amount) as u8,
74 b: (self.b as f32 + (255.0 - self.b as f32) * amount) as u8,
75 a: self.a,
76 }
77 }
78
79 pub fn darken(&self, amount: f32) -> Self {
81 let amount = amount.clamp(0.0, 1.0);
82 Self {
83 r: (self.r as f32 * (1.0 - amount)) as u8,
84 g: (self.g as f32 * (1.0 - amount)) as u8,
85 b: (self.b as f32 * (1.0 - amount)) as u8,
86 a: self.a,
87 }
88 }
89}
90
91impl fmt::Display for Color {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 write!(f, "{}", self.to_css_hex())
94 }
95}
96
97impl Default for Color {
98 fn default() -> Self {
99 Self::rgb(0, 0, 0)
100 }
101}
102
103#[derive(Debug, Clone)]
105pub struct MaterialPalette {
106 pub primary: Color,
108 pub on_primary: Color,
110 pub primary_container: Color,
112 pub on_primary_container: Color,
114
115 pub secondary: Color,
117 pub on_secondary: Color,
119
120 pub tertiary: Color,
122 pub on_tertiary: Color,
124
125 pub error: Color,
127 pub on_error: Color,
129
130 pub surface: Color,
132 pub on_surface: Color,
134 pub surface_variant: Color,
136 pub on_surface_variant: Color,
138
139 pub outline: Color,
141 pub outline_variant: Color,
143
144 pub background: Color,
146 pub on_background: Color,
148}
149
150impl MaterialPalette {
151 pub fn light() -> Self {
153 Self {
154 primary: Color::rgb(103, 80, 164),
156 on_primary: Color::rgb(255, 255, 255),
157 primary_container: Color::rgb(234, 221, 255),
158 on_primary_container: Color::rgb(33, 0, 93),
159
160 secondary: Color::rgb(98, 91, 113),
162 on_secondary: Color::rgb(255, 255, 255),
163
164 tertiary: Color::rgb(125, 82, 96),
166 on_tertiary: Color::rgb(255, 255, 255),
167
168 error: Color::rgb(179, 38, 30),
170 on_error: Color::rgb(255, 255, 255),
171
172 surface: Color::rgb(255, 251, 254),
174 on_surface: Color::rgb(28, 27, 31),
175 surface_variant: Color::rgb(231, 224, 236),
176 on_surface_variant: Color::rgb(73, 69, 79),
177
178 outline: Color::rgb(121, 116, 126),
180 outline_variant: Color::rgb(202, 196, 208),
181
182 background: Color::rgb(255, 251, 254),
184 on_background: Color::rgb(28, 27, 31),
185 }
186 }
187
188 pub fn dark() -> Self {
190 Self {
191 primary: Color::rgb(208, 188, 255),
193 on_primary: Color::rgb(56, 30, 114),
194 primary_container: Color::rgb(79, 55, 139),
195 on_primary_container: Color::rgb(234, 221, 255),
196
197 secondary: Color::rgb(204, 194, 220),
199 on_secondary: Color::rgb(51, 45, 65),
200
201 tertiary: Color::rgb(239, 184, 200),
203 on_tertiary: Color::rgb(73, 37, 50),
204
205 error: Color::rgb(242, 184, 181),
207 on_error: Color::rgb(96, 20, 16),
208
209 surface: Color::rgb(28, 27, 31),
211 on_surface: Color::rgb(230, 225, 229),
212 surface_variant: Color::rgb(73, 69, 79),
213 on_surface_variant: Color::rgb(202, 196, 208),
214
215 outline: Color::rgb(147, 143, 153),
217 outline_variant: Color::rgb(73, 69, 79),
218
219 background: Color::rgb(28, 27, 31),
221 on_background: Color::rgb(230, 225, 229),
222 }
223 }
224
225 pub fn with_primary(primary: Color) -> Self {
227 let mut palette = Self::light();
228 palette.primary = primary;
229 palette
230 }
231
232 pub fn is_valid_color(&self, color: &Color) -> bool {
234 color == &self.primary
235 || color == &self.on_primary
236 || color == &self.primary_container
237 || color == &self.on_primary_container
238 || color == &self.secondary
239 || color == &self.on_secondary
240 || color == &self.tertiary
241 || color == &self.on_tertiary
242 || color == &self.error
243 || color == &self.on_error
244 || color == &self.surface
245 || color == &self.on_surface
246 || color == &self.surface_variant
247 || color == &self.on_surface_variant
248 || color == &self.outline
249 || color == &self.outline_variant
250 || color == &self.background
251 || color == &self.on_background
252 }
253
254 pub fn all_colors(&self) -> Vec<Color> {
256 vec![
257 self.primary,
258 self.on_primary,
259 self.primary_container,
260 self.on_primary_container,
261 self.secondary,
262 self.on_secondary,
263 self.tertiary,
264 self.on_tertiary,
265 self.error,
266 self.on_error,
267 self.surface,
268 self.on_surface,
269 self.surface_variant,
270 self.on_surface_variant,
271 self.outline,
272 self.outline_variant,
273 self.background,
274 self.on_background,
275 ]
276 }
277}
278
279impl Default for MaterialPalette {
280 fn default() -> Self {
281 Self::light()
282 }
283}
284
285#[derive(Debug, Clone)]
287pub struct SovereignPalette {
288 pub material: MaterialPalette,
290
291 pub trueno: Color,
293 pub aprender: Color,
295 pub realizar: Color,
297 pub batuta: Color,
299
300 pub success: Color,
302 pub warning: Color,
304 pub info: Color,
306}
307
308impl SovereignPalette {
309 pub fn light() -> Self {
311 Self {
312 material: MaterialPalette::light(),
313 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), }
321 }
322
323 pub fn dark() -> Self {
325 Self {
326 material: MaterialPalette::dark(),
327 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), }
335 }
336
337 pub fn component_color(&self, component: &str) -> Color {
339 match component.to_lowercase().as_str() {
340 "trueno" => self.trueno,
341 "aprender" => self.aprender,
342 "realizar" => self.realizar,
343 "batuta" => self.batuta,
344 _ => self.material.outline,
345 }
346 }
347}
348
349impl Default for SovereignPalette {
350 fn default() -> Self {
351 Self::light()
352 }
353}
354
355#[derive(Debug, Clone)]
360pub struct VideoPalette {
361 pub canvas: Color,
364 pub surface: Color,
366 pub badge_grey: Color,
368 pub badge_blue: Color,
370 pub badge_green: Color,
372 pub badge_gold: Color,
374
375 pub heading: Color,
378 pub heading_secondary: Color,
380 pub body: Color,
382 pub accent_blue: Color,
384 pub accent_green: Color,
386 pub accent_gold: Color,
388 pub accent_red: Color,
390
391 pub outline: Color,
394}
395
396impl VideoPalette {
397 pub fn dark() -> Self {
399 Self {
400 canvas: Color::from_hex("#0f172a").expect("valid hex"),
401 surface: Color::from_hex("#1e293b").expect("valid hex"),
402 badge_grey: Color::from_hex("#374151").expect("valid hex"),
403 badge_blue: Color::from_hex("#1e3a5f").expect("valid hex"),
404 badge_green: Color::from_hex("#14532d").expect("valid hex"),
405 badge_gold: Color::from_hex("#713f12").expect("valid hex"),
406 heading: Color::from_hex("#f1f5f9").expect("valid hex"),
407 heading_secondary: Color::from_hex("#d1d5db").expect("valid hex"),
408 body: Color::from_hex("#94a3b8").expect("valid hex"),
409 accent_blue: Color::from_hex("#60a5fa").expect("valid hex"),
410 accent_green: Color::from_hex("#4ade80").expect("valid hex"),
411 accent_gold: Color::from_hex("#fde047").expect("valid hex"),
412 accent_red: Color::from_hex("#ef4444").expect("valid hex"),
413 outline: Color::from_hex("#475569").expect("valid hex"),
414 }
415 }
416
417 pub fn light() -> Self {
419 Self {
420 canvas: Color::from_hex("#f8fafc").expect("valid hex"),
421 surface: Color::from_hex("#ffffff").expect("valid hex"),
422 badge_grey: Color::from_hex("#e5e7eb").expect("valid hex"),
423 badge_blue: Color::from_hex("#dbeafe").expect("valid hex"),
424 badge_green: Color::from_hex("#dcfce7").expect("valid hex"),
425 badge_gold: Color::from_hex("#fef9c3").expect("valid hex"),
426 heading: Color::from_hex("#0f172a").expect("valid hex"),
427 heading_secondary: Color::from_hex("#374151").expect("valid hex"),
428 body: Color::from_hex("#475569").expect("valid hex"),
429 accent_blue: Color::from_hex("#2563eb").expect("valid hex"),
430 accent_green: Color::from_hex("#16a34a").expect("valid hex"),
431 accent_gold: Color::from_hex("#ca8a04").expect("valid hex"),
432 accent_red: Color::from_hex("#dc2626").expect("valid hex"),
433 outline: Color::from_hex("#94a3b8").expect("valid hex"),
434 }
435 }
436
437 pub fn verify_contrast(text: &Color, bg: &Color) -> bool {
439 contrast_ratio(text, bg) >= 4.5
440 }
441}
442
443impl Default for VideoPalette {
444 fn default() -> Self {
445 Self::dark()
446 }
447}
448
449pub const FORBIDDEN_PAIRINGS: &[(&str, &str)] = &[
451 ("#64748b", "#0f172a"), ("#6b7280", "#1e293b"), ("#3b82f6", "#1e293b"), ("#475569", "#0f172a"), ];
456
457fn channel_luminance(c: u8) -> f64 {
459 let c = c as f64 / 255.0;
460 if c <= 0.03928 {
461 c / 12.92
462 } else {
463 ((c + 0.055) / 1.055).powf(2.4)
464 }
465}
466
467fn relative_luminance(color: &Color) -> f64 {
469 0.2126 * channel_luminance(color.r)
470 + 0.7152 * channel_luminance(color.g)
471 + 0.0722 * channel_luminance(color.b)
472}
473
474pub fn contrast_ratio(c1: &Color, c2: &Color) -> f64 {
476 let l1 = relative_luminance(c1);
477 let l2 = relative_luminance(c2);
478 let lighter = l1.max(l2);
479 let darker = l1.min(l2);
480 (lighter + 0.05) / (darker + 0.05)
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn test_color_from_hex() {
489 let color = Color::rgb(103, 80, 164);
490 assert_eq!(color.r, 103);
491 assert_eq!(color.g, 80);
492 assert_eq!(color.b, 164);
493 }
494
495 #[test]
496 fn test_color_from_hex_no_hash() {
497 let color = Color::from_hex("6750A4").expect("unexpected failure");
498 assert_eq!(color.r, 103);
499 assert_eq!(color.g, 80);
500 assert_eq!(color.b, 164);
501 }
502
503 #[test]
504 fn test_color_to_hex() {
505 let color = Color::rgb(103, 80, 164);
506 assert_eq!(color.to_hex(), "6750A4");
507 assert_eq!(color.to_css_hex(), "#6750A4");
508 }
509
510 #[test]
511 fn test_color_to_css_rgba() {
512 let color = Color::rgb(103, 80, 164);
513 assert_eq!(color.to_css_rgba(), "rgb(103, 80, 164)");
514
515 let color_alpha = Color::rgba(103, 80, 164, 128);
516 assert!(color_alpha.to_css_rgba().starts_with("rgba(103, 80, 164,"));
517 }
518
519 #[test]
520 fn test_color_with_opacity() {
521 let color = Color::rgb(255, 255, 255);
522 let semi = color.with_opacity(0.5);
523 assert_eq!(semi.a, 127);
524 }
525
526 #[test]
527 fn test_color_lighten() {
528 let color = Color::rgb(100, 100, 100);
529 let lighter = color.lighten(0.5);
530 assert!(lighter.r > color.r);
531 assert!(lighter.g > color.g);
532 assert!(lighter.b > color.b);
533 }
534
535 #[test]
536 fn test_color_darken() {
537 let color = Color::rgb(100, 100, 100);
538 let darker = color.darken(0.5);
539 assert!(darker.r < color.r);
540 assert!(darker.g < color.g);
541 assert!(darker.b < color.b);
542 }
543
544 #[test]
545 fn test_material_palette_light() {
546 let palette = MaterialPalette::light();
547 assert_eq!(palette.primary.to_css_hex(), "#6750A4");
548 assert_eq!(palette.surface.to_css_hex(), "#FFFBFE");
549 assert_eq!(palette.outline.to_css_hex(), "#79747E");
550 }
551
552 #[test]
553 fn test_material_palette_dark() {
554 let palette = MaterialPalette::dark();
555 assert_eq!(palette.primary.to_css_hex(), "#D0BCFF");
556 assert_eq!(palette.surface.to_css_hex(), "#1C1B1F");
557 }
558
559 #[test]
560 fn test_material_palette_validation() {
561 let palette = MaterialPalette::light();
562 assert!(palette.is_valid_color(&palette.primary));
563 assert!(palette.is_valid_color(&palette.surface));
564 assert!(!palette.is_valid_color(&Color::rgb(1, 2, 3)));
565 }
566
567 #[test]
568 fn test_sovereign_palette() {
569 let palette = SovereignPalette::light();
570 assert_eq!(palette.trueno.to_css_hex(), "#FF6D00");
571 assert_eq!(palette.aprender.to_css_hex(), "#2962FF");
572 assert_eq!(palette.realizar.to_css_hex(), "#00C853");
573 }
574
575 #[test]
576 fn test_sovereign_component_color() {
577 let palette = SovereignPalette::light();
578 assert_eq!(palette.component_color("trueno"), palette.trueno);
579 assert_eq!(palette.component_color("APRENDER"), palette.aprender);
580 assert_eq!(palette.component_color("unknown"), palette.material.outline);
581 }
582
583 #[test]
584 fn test_color_display() {
585 let color = Color::rgb(103, 80, 164);
586 assert_eq!(format!("{}", color), "#6750A4");
587 }
588
589 #[test]
590 fn test_color_default() {
591 let color = Color::default();
592 assert_eq!(color.r, 0);
593 assert_eq!(color.g, 0);
594 assert_eq!(color.b, 0);
595 assert_eq!(color.a, 255);
596 }
597
598 #[test]
599 fn test_material_palette_with_primary() {
600 let custom_primary = Color::rgb(255, 0, 0);
601 let palette = MaterialPalette::with_primary(custom_primary);
602 assert_eq!(palette.primary, custom_primary);
603 }
604
605 #[test]
606 fn test_material_palette_all_colors() {
607 let palette = MaterialPalette::light();
608 let colors = palette.all_colors();
609 assert_eq!(colors.len(), 18);
610 assert!(colors.contains(&palette.primary));
611 assert!(colors.contains(&palette.surface));
612 assert!(colors.contains(&palette.error));
613 }
614
615 #[test]
616 fn test_material_palette_default() {
617 let palette = MaterialPalette::default();
618 let light = MaterialPalette::light();
619 assert_eq!(palette.primary, light.primary);
620 }
621
622 #[test]
623 fn test_sovereign_palette_dark() {
624 let palette = SovereignPalette::dark();
625 assert_eq!(palette.trueno.to_css_hex(), "#FFAB40");
626 assert_eq!(palette.aprender.to_css_hex(), "#82B1FF");
627 }
628
629 #[test]
630 fn test_sovereign_palette_default() {
631 let palette = SovereignPalette::default();
632 let light = SovereignPalette::light();
633 assert_eq!(palette.trueno, light.trueno);
634 }
635
636 #[test]
637 fn test_color_from_hex_invalid_length() {
638 assert!(Color::from_hex("#12").is_none());
639 assert!(Color::from_hex("#1234567").is_none());
640 }
641
642 #[test]
643 fn test_color_from_hex_invalid_chars() {
644 assert!(Color::from_hex("#GGHHII").is_none());
645 }
646
647 #[test]
648 fn test_color_equality() {
649 let c1 = Color::rgb(100, 200, 50);
650 let c2 = Color::rgb(100, 200, 50);
651 let c3 = Color::rgb(100, 200, 51);
652 assert_eq!(c1, c2);
653 assert_ne!(c1, c3);
654 }
655
656 #[test]
657 fn test_color_lighten_clamp() {
658 let white = Color::rgb(255, 255, 255);
659 let lightened = white.lighten(0.5);
660 assert_eq!(lightened.r, 255);
661 assert_eq!(lightened.g, 255);
662 assert_eq!(lightened.b, 255);
663 }
664
665 #[test]
666 fn test_color_darken_clamp() {
667 let black = Color::rgb(0, 0, 0);
668 let darkened = black.darken(0.5);
669 assert_eq!(darkened.r, 0);
670 assert_eq!(darkened.g, 0);
671 assert_eq!(darkened.b, 0);
672 }
673
674 #[test]
675 fn test_color_with_opacity_clamp() {
676 let color = Color::rgb(100, 100, 100);
677 let over = color.with_opacity(1.5);
678 assert_eq!(over.a, 255);
679 let under = color.with_opacity(-0.5);
680 assert_eq!(under.a, 0);
681 }
682
683 #[test]
686 fn test_video_palette_dark() {
687 let vp = VideoPalette::dark();
688 assert_eq!(vp.canvas.to_css_hex(), "#0F172A");
689 assert_eq!(vp.surface.to_css_hex(), "#1E293B");
690 assert_eq!(vp.heading.to_css_hex(), "#F1F5F9");
691 }
692
693 #[test]
694 fn test_video_palette_light() {
695 let vp = VideoPalette::light();
696 assert_eq!(vp.canvas.to_css_hex(), "#F8FAFC");
697 assert_eq!(vp.surface.to_css_hex(), "#FFFFFF");
698 assert_eq!(vp.heading.to_css_hex(), "#0F172A");
699 }
700
701 #[test]
702 fn test_video_palette_default() {
703 let vp = VideoPalette::default();
704 assert_eq!(vp.canvas, VideoPalette::dark().canvas);
706 }
707
708 #[test]
709 fn test_video_palette_verify_contrast_passes() {
710 let dark = VideoPalette::dark();
711 assert!(VideoPalette::verify_contrast(&dark.heading, &dark.canvas));
713 assert!(VideoPalette::verify_contrast(&dark.heading, &dark.surface));
715 assert!(VideoPalette::verify_contrast(&dark.accent_gold, &dark.canvas));
717 }
718
719 #[test]
720 fn test_video_palette_verify_contrast_fails_for_forbidden() {
721 for (text_hex, bg_hex) in FORBIDDEN_PAIRINGS {
722 let text = Color::from_hex(text_hex).expect("unexpected failure");
723 let bg = Color::from_hex(bg_hex).expect("unexpected failure");
724 assert!(
725 !VideoPalette::verify_contrast(&text, &bg),
726 "Expected forbidden pairing {} on {} to fail contrast check, ratio: {:.2}",
727 text_hex,
728 bg_hex,
729 contrast_ratio(&text, &bg)
730 );
731 }
732 }
733
734 #[test]
735 fn test_contrast_ratio_black_on_white() {
736 let ratio = contrast_ratio(&Color::rgb(0, 0, 0), &Color::rgb(255, 255, 255));
737 assert!(ratio > 20.0 && ratio < 22.0, "Expected ~21:1, got {:.2}", ratio);
738 }
739
740 #[test]
741 fn test_contrast_ratio_same_color() {
742 let c = Color::rgb(128, 128, 128);
743 let ratio = contrast_ratio(&c, &c);
744 assert!((ratio - 1.0).abs() < 0.01);
745 }
746
747 #[test]
748 fn test_forbidden_pairings_count() {
749 assert_eq!(FORBIDDEN_PAIRINGS.len(), 4);
750 }
751
752 #[test]
753 fn test_video_palette_light_contrast() {
754 let light = VideoPalette::light();
755 assert!(VideoPalette::verify_contrast(&light.heading, &light.canvas));
757 assert!(VideoPalette::verify_contrast(&light.body, &light.surface));
759 }
760}