Skip to main content

batuta/oracle/svg/
palette.rs

1//! Material Design 3 Color Palette
2//!
3//! Defines color schemes based on Material Design 3 specification.
4
5use std::fmt;
6
7/// An RGBA color
8#[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    /// Create a new color with full opacity
18    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
19        Self { r, g, b, a: 255 }
20    }
21
22    /// Create a new color with custom opacity
23    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
24        Self { r, g, b, a }
25    }
26
27    /// Create from hex string (e.g., "#6750A4" or "6750A4")
28    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    /// Convert to hex string (without #)
42    #[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    /// Convert to CSS hex string (with #)
48    #[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    /// Convert to CSS rgba string
54    #[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    /// Apply opacity (0.0 - 1.0) to this color
64    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    /// Lighten the color by a percentage (0.0 - 1.0)
69    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    /// Darken the color by a percentage (0.0 - 1.0)
80    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/// Material Design 3 color palette
104#[derive(Debug, Clone)]
105pub struct MaterialPalette {
106    /// Primary color - #6750A4
107    pub primary: Color,
108    /// On-primary text color
109    pub on_primary: Color,
110    /// Primary container
111    pub primary_container: Color,
112    /// On-primary container text
113    pub on_primary_container: Color,
114
115    /// Secondary color
116    pub secondary: Color,
117    /// On-secondary text color
118    pub on_secondary: Color,
119
120    /// Tertiary color
121    pub tertiary: Color,
122    /// On-tertiary text color
123    pub on_tertiary: Color,
124
125    /// Error color
126    pub error: Color,
127    /// On-error text color
128    pub on_error: Color,
129
130    /// Surface color - #FFFBFE
131    pub surface: Color,
132    /// On-surface text color
133    pub on_surface: Color,
134    /// Surface variant
135    pub surface_variant: Color,
136    /// On-surface variant text
137    pub on_surface_variant: Color,
138
139    /// Outline color - #79747E
140    pub outline: Color,
141    /// Outline variant (lighter)
142    pub outline_variant: Color,
143
144    /// Background color
145    pub background: Color,
146    /// On-background text color
147    pub on_background: Color,
148}
149
150impl MaterialPalette {
151    /// Create the default Material Design 3 light palette
152    pub fn light() -> Self {
153        Self {
154            // Primary (Purple)
155            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 (Pink-purple)
161            secondary: Color::rgb(98, 91, 113),
162            on_secondary: Color::rgb(255, 255, 255),
163
164            // Tertiary (Teal)
165            tertiary: Color::rgb(125, 82, 96),
166            on_tertiary: Color::rgb(255, 255, 255),
167
168            // Error (Red)
169            error: Color::rgb(179, 38, 30),
170            on_error: Color::rgb(255, 255, 255),
171
172            // Surface
173            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
179            outline: Color::rgb(121, 116, 126),
180            outline_variant: Color::rgb(202, 196, 208),
181
182            // Background
183            background: Color::rgb(255, 251, 254),
184            on_background: Color::rgb(28, 27, 31),
185        }
186    }
187
188    /// Create the Material Design 3 dark palette
189    pub fn dark() -> Self {
190        Self {
191            // Primary (Purple)
192            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
198            secondary: Color::rgb(204, 194, 220),
199            on_secondary: Color::rgb(51, 45, 65),
200
201            // Tertiary
202            tertiary: Color::rgb(239, 184, 200),
203            on_tertiary: Color::rgb(73, 37, 50),
204
205            // Error
206            error: Color::rgb(242, 184, 181),
207            on_error: Color::rgb(96, 20, 16),
208
209            // Surface
210            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
216            outline: Color::rgb(147, 143, 153),
217            outline_variant: Color::rgb(73, 69, 79),
218
219            // Background
220            background: Color::rgb(28, 27, 31),
221            on_background: Color::rgb(230, 225, 229),
222        }
223    }
224
225    /// Create a custom palette with a primary color
226    pub fn with_primary(primary: Color) -> Self {
227        let mut palette = Self::light();
228        palette.primary = primary;
229        palette
230    }
231
232    /// Validate that a color is in this palette
233    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    /// Get all colors in the palette
255    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/// Sovereign AI Stack color scheme (extends Material Design 3)
286#[derive(Debug, Clone)]
287pub struct SovereignPalette {
288    /// Base Material palette
289    pub material: MaterialPalette,
290
291    /// Trueno component color (orange)
292    pub trueno: Color,
293    /// Aprender component color (blue)
294    pub aprender: Color,
295    /// Realizar component color (green)
296    pub realizar: Color,
297    /// Batuta component color (purple)
298    pub batuta: Color,
299
300    /// Success color
301    pub success: Color,
302    /// Warning color
303    pub warning: Color,
304    /// Info color
305    pub info: Color,
306}
307
308impl SovereignPalette {
309    /// Create the light sovereign palette
310    pub fn light() -> Self {
311        Self {
312            material: MaterialPalette::light(),
313            trueno: Color::rgb(255, 109, 0),   // Deep Orange A400
314            aprender: Color::rgb(41, 98, 255), // Blue A700
315            realizar: Color::rgb(0, 200, 83),  // Green A700
316            batuta: Color::rgb(103, 80, 164),  // Primary Purple
317            success: Color::rgb(0, 200, 83),   // Green A700
318            warning: Color::rgb(255, 214, 0),  // Yellow A700
319            info: Color::rgb(0, 176, 255),     // Light Blue A400
320        }
321    }
322
323    /// Create the dark sovereign palette
324    pub fn dark() -> Self {
325        Self {
326            material: MaterialPalette::dark(),
327            trueno: Color::rgb(255, 171, 64),    // Orange A200
328            aprender: Color::rgb(130, 177, 255), // Blue A100
329            realizar: Color::rgb(105, 240, 174), // Green A200
330            batuta: Color::rgb(208, 188, 255),   // Primary Purple
331            success: Color::rgb(105, 240, 174),  // Green A200
332            warning: Color::rgb(255, 229, 127),  // Amber A200
333            info: Color::rgb(128, 216, 255),     // Light Blue A100
334        }
335    }
336
337    /// Get color for a stack component
338    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/// Pre-verified video palette for 1080p presentation SVGs.
356///
357/// All text/background pairings meet WCAG AA 4.5:1 contrast ratio.
358/// Forbidden pairings are documented and checked by the linter.
359#[derive(Debug, Clone)]
360pub struct VideoPalette {
361    // Backgrounds
362    /// Canvas background
363    pub canvas: Color,
364    /// Surface (card/box) background
365    pub surface: Color,
366    /// Grey badge background
367    pub badge_grey: Color,
368    /// Blue badge background
369    pub badge_blue: Color,
370    /// Green badge background
371    pub badge_green: Color,
372    /// Gold badge background
373    pub badge_gold: Color,
374
375    // Text
376    /// Primary heading text
377    pub heading: Color,
378    /// Secondary heading text
379    pub heading_secondary: Color,
380    /// Body text
381    pub body: Color,
382    /// Blue accent text
383    pub accent_blue: Color,
384    /// Green accent text
385    pub accent_green: Color,
386    /// Gold accent text
387    pub accent_gold: Color,
388    /// Red accent text
389    pub accent_red: Color,
390
391    // Strokes
392    /// Outline/stroke color
393    pub outline: Color,
394}
395
396impl VideoPalette {
397    /// Dark palette — light text on dark backgrounds.
398    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    /// Light palette — dark text on light backgrounds.
418    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    /// Check if a text/background pairing meets WCAG AA 4.5:1 contrast.
438    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
449/// Known-bad color pairings that fail WCAG AA 4.5:1 contrast.
450pub const FORBIDDEN_PAIRINGS: &[(&str, &str)] = &[
451    ("#64748b", "#0f172a"), // slate-500 on navy: ~3.75:1
452    ("#6b7280", "#1e293b"), // grey-500 on slate: ~3.03:1
453    ("#3b82f6", "#1e293b"), // blue-500 on slate: ~3.98:1
454    ("#475569", "#0f172a"), // slate-600 on navy: ~2.58:1
455];
456
457/// Calculate WCAG relative luminance for a color channel (sRGB).
458fn 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
467/// Calculate WCAG relative luminance for a color.
468fn 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
474/// Calculate WCAG contrast ratio between two colors.
475pub 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    // ── VideoPalette tests ──────────────────────────────────────────────
684
685    #[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        // Default is dark
705        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        // heading (#f1f5f9) on canvas (#0f172a) should pass
712        assert!(VideoPalette::verify_contrast(&dark.heading, &dark.canvas));
713        // heading (#f1f5f9) on surface (#1e293b) should pass
714        assert!(VideoPalette::verify_contrast(&dark.heading, &dark.surface));
715        // accent_gold (#fde047) on canvas (#0f172a) should pass
716        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        // heading (#0f172a) on canvas (#f8fafc) should pass
756        assert!(VideoPalette::verify_contrast(&light.heading, &light.canvas));
757        // body (#475569) on surface (#ffffff) should pass
758        assert!(VideoPalette::verify_contrast(&light.body, &light.surface));
759    }
760}