presentar_core/
theme.rs

1//! Theme system for consistent styling.
2
3use crate::color::Color;
4use serde::{Deserialize, Serialize};
5
6/// A color palette for theming.
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub struct ColorPalette {
9    /// Primary brand color
10    pub primary: Color,
11    /// Secondary brand color
12    pub secondary: Color,
13    /// Surface/background color
14    pub surface: Color,
15    /// Background color
16    pub background: Color,
17    /// Error/danger color
18    pub error: Color,
19    /// Warning color
20    pub warning: Color,
21    /// Success color
22    pub success: Color,
23    /// Text on primary
24    pub on_primary: Color,
25    /// Text on secondary
26    pub on_secondary: Color,
27    /// Text on surface
28    pub on_surface: Color,
29    /// Text on background
30    pub on_background: Color,
31    /// Text on error
32    pub on_error: Color,
33}
34
35impl Default for ColorPalette {
36    fn default() -> Self {
37        Self::light()
38    }
39}
40
41/// Result of a WCAG contrast check.
42#[derive(Debug, Clone)]
43pub struct ContrastCheck {
44    /// Name of the color pair
45    pub name: String,
46    /// Foreground color
47    pub foreground: Color,
48    /// Background color
49    pub background: Color,
50    /// Calculated contrast ratio
51    pub ratio: f32,
52    /// Passes WCAG AA for normal text (4.5:1)
53    pub passes_aa: bool,
54    /// Passes WCAG AAA for normal text (7:1)
55    pub passes_aaa: bool,
56}
57
58impl ColorPalette {
59    /// Check all foreground/background combinations for WCAG compliance.
60    /// Returns a list of contrast checks for each semantic color pair.
61    #[must_use]
62    pub fn check_contrast(&self) -> Vec<ContrastCheck> {
63        let checks = [
64            ("on_primary/primary", self.on_primary, self.primary),
65            ("on_secondary/secondary", self.on_secondary, self.secondary),
66            ("on_surface/surface", self.on_surface, self.surface),
67            (
68                "on_background/background",
69                self.on_background,
70                self.background,
71            ),
72            ("on_error/error", self.on_error, self.error),
73        ];
74
75        checks
76            .into_iter()
77            .map(|(name, fg, bg)| {
78                let ratio = fg.contrast_ratio(&bg);
79                ContrastCheck {
80                    name: name.to_string(),
81                    foreground: fg,
82                    background: bg,
83                    ratio,
84                    passes_aa: ratio >= 4.5,
85                    passes_aaa: ratio >= 7.0,
86                }
87            })
88            .collect()
89    }
90
91    /// Check if all color pairs pass WCAG AA.
92    #[must_use]
93    pub fn passes_wcag_aa(&self) -> bool {
94        self.check_contrast().iter().all(|c| c.passes_aa)
95    }
96
97    /// Check if all color pairs pass WCAG AAA.
98    #[must_use]
99    pub fn passes_wcag_aaa(&self) -> bool {
100        self.check_contrast().iter().all(|c| c.passes_aaa)
101    }
102
103    /// Get any failing contrast pairs for WCAG AA.
104    #[must_use]
105    pub fn failing_aa(&self) -> Vec<ContrastCheck> {
106        self.check_contrast()
107            .into_iter()
108            .filter(|c| !c.passes_aa)
109            .collect()
110    }
111
112    /// Create a light color palette.
113    /// All color combinations pass WCAG AA (4.5:1 contrast ratio).
114    #[must_use]
115    pub fn light() -> Self {
116        Self {
117            primary: Color::new(0.0, 0.35, 0.75, 1.0), // Darker blue for AA compliance
118            secondary: Color::new(0.0, 0.40, 0.60, 1.0), // Darker teal for AA compliance
119            surface: Color::WHITE,
120            background: Color::new(0.98, 0.98, 0.98, 1.0), // Light gray
121            error: Color::new(0.69, 0.18, 0.18, 1.0),      // Red (passes with white)
122            warning: Color::new(0.70, 0.45, 0.0, 1.0),     // Darker orange for AA
123            success: Color::new(0.18, 0.55, 0.34, 1.0),    // Green
124            on_primary: Color::WHITE,
125            on_secondary: Color::WHITE,
126            on_surface: Color::new(0.13, 0.13, 0.13, 1.0), // Dark gray
127            on_background: Color::new(0.13, 0.13, 0.13, 1.0),
128            on_error: Color::WHITE,
129        }
130    }
131
132    /// Create a dark color palette.
133    #[must_use]
134    pub fn dark() -> Self {
135        Self {
136            primary: Color::new(0.51, 0.71, 1.0, 1.0),     // Light blue
137            secondary: Color::new(0.31, 0.82, 0.71, 1.0),  // Teal
138            surface: Color::new(0.14, 0.14, 0.14, 1.0),    // Dark gray
139            background: Color::new(0.07, 0.07, 0.07, 1.0), // Near black
140            error: Color::new(0.94, 0.47, 0.47, 1.0),      // Light red
141            warning: Color::new(1.0, 0.78, 0.35, 1.0),     // Light orange
142            success: Color::new(0.51, 0.78, 0.58, 1.0),    // Light green
143            on_primary: Color::BLACK,
144            on_secondary: Color::BLACK,
145            on_surface: Color::WHITE,
146            on_background: Color::WHITE,
147            on_error: Color::BLACK,
148        }
149    }
150}
151
152/// Typography scale.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct Typography {
155    /// Base font size
156    pub base_size: f32,
157    /// H1 scale (relative to base)
158    pub h1_scale: f32,
159    /// H2 scale
160    pub h2_scale: f32,
161    /// H3 scale
162    pub h3_scale: f32,
163    /// H4 scale
164    pub h4_scale: f32,
165    /// H5 scale
166    pub h5_scale: f32,
167    /// H6 scale
168    pub h6_scale: f32,
169    /// Body scale
170    pub body_scale: f32,
171    /// Caption scale
172    pub caption_scale: f32,
173    /// Line height
174    pub line_height: f32,
175}
176
177impl Default for Typography {
178    fn default() -> Self {
179        Self::standard()
180    }
181}
182
183impl Typography {
184    /// Standard typography scale (based on 16px base).
185    #[must_use]
186    pub const fn standard() -> Self {
187        Self {
188            base_size: 16.0,
189            h1_scale: 2.5,       // 40px
190            h2_scale: 2.0,       // 32px
191            h3_scale: 1.75,      // 28px
192            h4_scale: 1.5,       // 24px
193            h5_scale: 1.25,      // 20px
194            h6_scale: 1.125,     // 18px
195            body_scale: 1.0,     // 16px
196            caption_scale: 0.75, // 12px
197            line_height: 1.5,
198        }
199    }
200
201    /// Compact typography scale (based on 14px base).
202    #[must_use]
203    pub const fn compact() -> Self {
204        Self {
205            base_size: 14.0,
206            h1_scale: 2.286,      // 32px
207            h2_scale: 1.857,      // 26px
208            h3_scale: 1.571,      // 22px
209            h4_scale: 1.286,      // 18px
210            h5_scale: 1.143,      // 16px
211            h6_scale: 1.0,        // 14px
212            body_scale: 1.0,      // 14px
213            caption_scale: 0.786, // 11px
214            line_height: 1.4,
215        }
216    }
217
218    /// Get size for a heading level (1-6).
219    #[must_use]
220    pub fn heading_size(&self, level: u8) -> f32 {
221        let scale = match level {
222            1 => self.h1_scale,
223            2 => self.h2_scale,
224            3 => self.h3_scale,
225            4 => self.h4_scale,
226            5 => self.h5_scale,
227            _ => self.h6_scale,
228        };
229        self.base_size * scale
230    }
231
232    /// Get body text size.
233    #[must_use]
234    pub fn body_size(&self) -> f32 {
235        self.base_size * self.body_scale
236    }
237
238    /// Get caption text size.
239    #[must_use]
240    pub fn caption_size(&self) -> f32 {
241        self.base_size * self.caption_scale
242    }
243}
244
245/// Spacing scale.
246#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
247pub struct Spacing {
248    /// Base spacing unit
249    pub unit: f32,
250}
251
252impl Default for Spacing {
253    fn default() -> Self {
254        Self::standard()
255    }
256}
257
258impl Spacing {
259    /// Standard spacing (8px base unit).
260    #[must_use]
261    pub const fn standard() -> Self {
262        Self { unit: 8.0 }
263    }
264
265    /// Compact spacing (4px base unit).
266    #[must_use]
267    pub const fn compact() -> Self {
268        Self { unit: 4.0 }
269    }
270
271    /// Get spacing for a given multiplier.
272    #[must_use]
273    pub fn get(&self, multiplier: f32) -> f32 {
274        self.unit * multiplier
275    }
276
277    /// None/zero spacing.
278    #[must_use]
279    pub const fn none(&self) -> f32 {
280        0.0
281    }
282
283    /// Extra small spacing (0.5x).
284    #[must_use]
285    pub fn xs(&self) -> f32 {
286        self.unit * 0.5
287    }
288
289    /// Small spacing (1x).
290    #[must_use]
291    pub const fn sm(&self) -> f32 {
292        self.unit
293    }
294
295    /// Medium spacing (2x).
296    #[must_use]
297    pub fn md(&self) -> f32 {
298        self.unit * 2.0
299    }
300
301    /// Large spacing (3x).
302    #[must_use]
303    pub fn lg(&self) -> f32 {
304        self.unit * 3.0
305    }
306
307    /// Extra large spacing (4x).
308    #[must_use]
309    pub fn xl(&self) -> f32 {
310        self.unit * 4.0
311    }
312
313    /// 2XL spacing (6x).
314    #[must_use]
315    pub fn xl2(&self) -> f32 {
316        self.unit * 6.0
317    }
318
319    /// 3XL spacing (8x).
320    #[must_use]
321    pub fn xl3(&self) -> f32 {
322        self.unit * 8.0
323    }
324}
325
326/// Border radius presets.
327#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
328pub struct Radii {
329    /// Base radius unit
330    pub unit: f32,
331}
332
333impl Default for Radii {
334    fn default() -> Self {
335        Self::standard()
336    }
337}
338
339impl Radii {
340    /// Standard radii (4px base).
341    #[must_use]
342    pub const fn standard() -> Self {
343        Self { unit: 4.0 }
344    }
345
346    /// No radius.
347    #[must_use]
348    pub const fn none(&self) -> f32 {
349        0.0
350    }
351
352    /// Small radius (1x).
353    #[must_use]
354    pub const fn sm(&self) -> f32 {
355        self.unit
356    }
357
358    /// Medium radius (2x).
359    #[must_use]
360    pub fn md(&self) -> f32 {
361        self.unit * 2.0
362    }
363
364    /// Large radius (3x).
365    #[must_use]
366    pub fn lg(&self) -> f32 {
367        self.unit * 3.0
368    }
369
370    /// Extra large radius (4x).
371    #[must_use]
372    pub fn xl(&self) -> f32 {
373        self.unit * 4.0
374    }
375
376    /// Full/pill radius.
377    #[must_use]
378    pub const fn full(&self) -> f32 {
379        9999.0
380    }
381}
382
383/// Shadow presets.
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385pub struct Shadows {
386    /// Shadow color
387    pub color: Color,
388}
389
390impl Default for Shadows {
391    fn default() -> Self {
392        Self::standard()
393    }
394}
395
396impl Shadows {
397    /// Standard shadows.
398    #[must_use]
399    pub fn standard() -> Self {
400        Self {
401            color: Color::new(0.0, 0.0, 0.0, 0.1),
402        }
403    }
404
405    /// Small shadow parameters (blur, y offset).
406    #[must_use]
407    pub const fn sm(&self) -> (f32, f32) {
408        (2.0, 1.0)
409    }
410
411    /// Medium shadow parameters.
412    #[must_use]
413    pub const fn md(&self) -> (f32, f32) {
414        (4.0, 2.0)
415    }
416
417    /// Large shadow parameters.
418    #[must_use]
419    pub const fn lg(&self) -> (f32, f32) {
420        (8.0, 4.0)
421    }
422
423    /// XL shadow parameters.
424    #[must_use]
425    pub const fn xl(&self) -> (f32, f32) {
426        (16.0, 8.0)
427    }
428}
429
430/// Complete theme definition.
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432pub struct Theme {
433    /// Theme name
434    pub name: String,
435    /// Color palette
436    pub colors: ColorPalette,
437    /// Typography
438    pub typography: Typography,
439    /// Spacing
440    pub spacing: Spacing,
441    /// Border radii
442    pub radii: Radii,
443    /// Shadows
444    pub shadows: Shadows,
445}
446
447impl Default for Theme {
448    fn default() -> Self {
449        Self::light()
450    }
451}
452
453impl Theme {
454    /// Create a light theme.
455    #[must_use]
456    pub fn light() -> Self {
457        Self {
458            name: "Light".to_string(),
459            colors: ColorPalette::light(),
460            typography: Typography::standard(),
461            spacing: Spacing::standard(),
462            radii: Radii::standard(),
463            shadows: Shadows::standard(),
464        }
465    }
466
467    /// Create a dark theme.
468    #[must_use]
469    pub fn dark() -> Self {
470        Self {
471            name: "Dark".to_string(),
472            colors: ColorPalette::dark(),
473            typography: Typography::standard(),
474            spacing: Spacing::standard(),
475            radii: Radii::standard(),
476            shadows: Shadows::standard(),
477        }
478    }
479
480    /// Create a theme with a custom name.
481    #[must_use]
482    pub fn with_name(mut self, name: impl Into<String>) -> Self {
483        self.name = name.into();
484        self
485    }
486
487    /// Create a theme with custom colors.
488    #[must_use]
489    pub const fn with_colors(mut self, colors: ColorPalette) -> Self {
490        self.colors = colors;
491        self
492    }
493
494    /// Create a theme with custom typography.
495    #[must_use]
496    pub const fn with_typography(mut self, typography: Typography) -> Self {
497        self.typography = typography;
498        self
499    }
500
501    /// Create a theme with custom spacing.
502    #[must_use]
503    pub const fn with_spacing(mut self, spacing: Spacing) -> Self {
504        self.spacing = spacing;
505        self
506    }
507
508    /// Create a theme with custom radii.
509    #[must_use]
510    pub const fn with_radii(mut self, radii: Radii) -> Self {
511        self.radii = radii;
512        self
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    // =========================================================================
521    // ColorPalette Tests - TESTS FIRST
522    // =========================================================================
523
524    #[test]
525    fn test_color_palette_default() {
526        let palette = ColorPalette::default();
527        assert_eq!(palette, ColorPalette::light());
528    }
529
530    #[test]
531    fn test_color_palette_light() {
532        let palette = ColorPalette::light();
533        // Primary should be a blue color
534        assert!(palette.primary.b > palette.primary.r);
535        // Surface should be white
536        assert_eq!(palette.surface, Color::WHITE);
537        // On-primary should be white (for contrast)
538        assert_eq!(palette.on_primary, Color::WHITE);
539    }
540
541    #[test]
542    fn test_color_palette_dark() {
543        let palette = ColorPalette::dark();
544        // Surface should be dark
545        assert!(palette.surface.r < 0.5);
546        // On-surface should be white
547        assert_eq!(palette.on_surface, Color::WHITE);
548        // On-primary should be black (for contrast on light primary)
549        assert_eq!(palette.on_primary, Color::BLACK);
550    }
551
552    // =========================================================================
553    // Typography Tests - TESTS FIRST
554    // =========================================================================
555
556    #[test]
557    fn test_typography_default() {
558        let typo = Typography::default();
559        assert_eq!(typo.base_size, 16.0);
560    }
561
562    #[test]
563    fn test_typography_standard() {
564        let typo = Typography::standard();
565        assert_eq!(typo.base_size, 16.0);
566        assert_eq!(typo.h1_scale, 2.5);
567        assert_eq!(typo.line_height, 1.5);
568    }
569
570    #[test]
571    fn test_typography_compact() {
572        let typo = Typography::compact();
573        assert_eq!(typo.base_size, 14.0);
574        assert!(typo.line_height < Typography::standard().line_height);
575    }
576
577    #[test]
578    fn test_typography_heading_size() {
579        let typo = Typography::standard();
580        assert_eq!(typo.heading_size(1), 40.0); // 16 * 2.5
581        assert_eq!(typo.heading_size(2), 32.0); // 16 * 2.0
582        assert_eq!(typo.heading_size(3), 28.0); // 16 * 1.75
583        assert_eq!(typo.heading_size(4), 24.0); // 16 * 1.5
584        assert_eq!(typo.heading_size(5), 20.0); // 16 * 1.25
585        assert_eq!(typo.heading_size(6), 18.0); // 16 * 1.125
586    }
587
588    #[test]
589    fn test_typography_heading_size_out_of_range() {
590        let typo = Typography::standard();
591        // Level > 6 should use h6 scale
592        assert_eq!(typo.heading_size(7), typo.heading_size(6));
593        assert_eq!(typo.heading_size(0), typo.heading_size(6));
594    }
595
596    #[test]
597    fn test_typography_body_size() {
598        let typo = Typography::standard();
599        assert_eq!(typo.body_size(), 16.0);
600    }
601
602    #[test]
603    fn test_typography_caption_size() {
604        let typo = Typography::standard();
605        assert_eq!(typo.caption_size(), 12.0); // 16 * 0.75
606    }
607
608    // =========================================================================
609    // Spacing Tests - TESTS FIRST
610    // =========================================================================
611
612    #[test]
613    fn test_spacing_default() {
614        let spacing = Spacing::default();
615        assert_eq!(spacing.unit, 8.0);
616    }
617
618    #[test]
619    fn test_spacing_standard() {
620        let spacing = Spacing::standard();
621        assert_eq!(spacing.unit, 8.0);
622    }
623
624    #[test]
625    fn test_spacing_compact() {
626        let spacing = Spacing::compact();
627        assert_eq!(spacing.unit, 4.0);
628    }
629
630    #[test]
631    fn test_spacing_get() {
632        let spacing = Spacing::standard();
633        assert_eq!(spacing.get(0.0), 0.0);
634        assert_eq!(spacing.get(1.0), 8.0);
635        assert_eq!(spacing.get(2.0), 16.0);
636        assert_eq!(spacing.get(0.5), 4.0);
637    }
638
639    #[test]
640    fn test_spacing_presets() {
641        let spacing = Spacing::standard();
642        assert_eq!(spacing.none(), 0.0);
643        assert_eq!(spacing.xs(), 4.0); // 0.5x
644        assert_eq!(spacing.sm(), 8.0); // 1x
645        assert_eq!(spacing.md(), 16.0); // 2x
646        assert_eq!(spacing.lg(), 24.0); // 3x
647        assert_eq!(spacing.xl(), 32.0); // 4x
648        assert_eq!(spacing.xl2(), 48.0); // 6x
649        assert_eq!(spacing.xl3(), 64.0); // 8x
650    }
651
652    // =========================================================================
653    // Radii Tests - TESTS FIRST
654    // =========================================================================
655
656    #[test]
657    fn test_radii_default() {
658        let radii = Radii::default();
659        assert_eq!(radii.unit, 4.0);
660    }
661
662    #[test]
663    fn test_radii_presets() {
664        let radii = Radii::standard();
665        assert_eq!(radii.none(), 0.0);
666        assert_eq!(radii.sm(), 4.0);
667        assert_eq!(radii.md(), 8.0);
668        assert_eq!(radii.lg(), 12.0);
669        assert_eq!(radii.xl(), 16.0);
670        assert_eq!(radii.full(), 9999.0);
671    }
672
673    // =========================================================================
674    // Shadows Tests - TESTS FIRST
675    // =========================================================================
676
677    #[test]
678    fn test_shadows_default() {
679        let shadows = Shadows::default();
680        assert!(shadows.color.a < 0.5); // Shadow should be semi-transparent
681    }
682
683    #[test]
684    fn test_shadows_presets() {
685        let shadows = Shadows::standard();
686        let (blur_sm, offset_sm) = shadows.sm();
687        let (blur_md, offset_md) = shadows.md();
688        let (blur_lg, offset_lg) = shadows.lg();
689        let (blur_xl, offset_xl) = shadows.xl();
690
691        // Each level should be larger than the previous
692        assert!(blur_md > blur_sm);
693        assert!(blur_lg > blur_md);
694        assert!(blur_xl > blur_lg);
695
696        assert!(offset_md > offset_sm);
697        assert!(offset_lg > offset_md);
698        assert!(offset_xl > offset_lg);
699    }
700
701    // =========================================================================
702    // Theme Tests - TESTS FIRST
703    // =========================================================================
704
705    #[test]
706    fn test_theme_default() {
707        let theme = Theme::default();
708        assert_eq!(theme.name, "Light");
709    }
710
711    #[test]
712    fn test_theme_light() {
713        let theme = Theme::light();
714        assert_eq!(theme.name, "Light");
715        assert_eq!(theme.colors, ColorPalette::light());
716    }
717
718    #[test]
719    fn test_theme_dark() {
720        let theme = Theme::dark();
721        assert_eq!(theme.name, "Dark");
722        assert_eq!(theme.colors, ColorPalette::dark());
723    }
724
725    #[test]
726    fn test_theme_with_name() {
727        let theme = Theme::light().with_name("Custom");
728        assert_eq!(theme.name, "Custom");
729    }
730
731    #[test]
732    fn test_theme_with_colors() {
733        let theme = Theme::light().with_colors(ColorPalette::dark());
734        assert_eq!(theme.colors, ColorPalette::dark());
735    }
736
737    #[test]
738    fn test_theme_with_typography() {
739        let theme = Theme::light().with_typography(Typography::compact());
740        assert_eq!(theme.typography, Typography::compact());
741    }
742
743    #[test]
744    fn test_theme_with_spacing() {
745        let theme = Theme::light().with_spacing(Spacing::compact());
746        assert_eq!(theme.spacing, Spacing::compact());
747    }
748
749    #[test]
750    fn test_theme_with_radii() {
751        let custom_radii = Radii { unit: 2.0 };
752        let theme = Theme::light().with_radii(custom_radii);
753        assert_eq!(theme.radii.unit, 2.0);
754    }
755
756    #[test]
757    fn test_theme_builder_chain() {
758        let theme = Theme::light()
759            .with_name("My Theme")
760            .with_colors(ColorPalette::dark())
761            .with_typography(Typography::compact())
762            .with_spacing(Spacing::compact());
763
764        assert_eq!(theme.name, "My Theme");
765        assert_eq!(theme.colors, ColorPalette::dark());
766        assert_eq!(theme.typography, Typography::compact());
767        assert_eq!(theme.spacing, Spacing::compact());
768    }
769
770    #[test]
771    fn test_theme_serialization() {
772        let theme = Theme::dark();
773        let json = serde_json::to_string(&theme).expect("serialize");
774        let restored: Theme = serde_json::from_str(&json).expect("deserialize");
775        assert_eq!(theme, restored);
776    }
777
778    // =========================================================================
779    // Contrast Check Tests
780    // =========================================================================
781
782    #[test]
783    fn test_light_palette_contrast_aa() {
784        let palette = ColorPalette::light();
785        let checks = palette.check_contrast();
786
787        // Should have checks for all pairs
788        assert_eq!(checks.len(), 5);
789
790        // on_primary/primary should pass (white on blue)
791        let primary_check = checks.iter().find(|c| c.name.contains("primary")).unwrap();
792        assert!(
793            primary_check.passes_aa,
794            "on_primary/primary ratio: {:.2}",
795            primary_check.ratio
796        );
797    }
798
799    #[test]
800    fn test_dark_palette_contrast_aa() {
801        let palette = ColorPalette::dark();
802        let checks = palette.check_contrast();
803
804        // on_surface/surface should pass (white on dark)
805        let surface_check = checks.iter().find(|c| c.name.contains("surface")).unwrap();
806        assert!(
807            surface_check.passes_aa,
808            "on_surface/surface ratio: {:.2}",
809            surface_check.ratio
810        );
811    }
812
813    #[test]
814    fn test_passes_wcag_aa() {
815        let light = ColorPalette::light();
816        let dark = ColorPalette::dark();
817
818        // Built-in palettes should be accessible
819        assert!(
820            light.passes_wcag_aa(),
821            "Light palette should pass AA: {:?}",
822            light.failing_aa()
823        );
824        assert!(
825            dark.passes_wcag_aa(),
826            "Dark palette should pass AA: {:?}",
827            dark.failing_aa()
828        );
829    }
830
831    #[test]
832    fn test_failing_aa() {
833        // Create an intentionally inaccessible palette
834        let bad_palette = ColorPalette {
835            primary: Color::rgb(0.5, 0.5, 0.5),
836            secondary: Color::rgb(0.5, 0.5, 0.5),
837            surface: Color::rgb(0.6, 0.6, 0.6), // Similar to on_surface
838            background: Color::rgb(0.6, 0.6, 0.6),
839            error: Color::rgb(0.5, 0.5, 0.5),
840            warning: Color::rgb(0.5, 0.5, 0.5),
841            success: Color::rgb(0.5, 0.5, 0.5),
842            on_primary: Color::rgb(0.6, 0.6, 0.6), // Low contrast
843            on_secondary: Color::rgb(0.6, 0.6, 0.6),
844            on_surface: Color::rgb(0.5, 0.5, 0.5), // Low contrast
845            on_background: Color::rgb(0.5, 0.5, 0.5),
846            on_error: Color::rgb(0.6, 0.6, 0.6),
847        };
848
849        assert!(!bad_palette.passes_wcag_aa());
850        let failures = bad_palette.failing_aa();
851        assert!(!failures.is_empty());
852    }
853
854    #[test]
855    fn test_contrast_check_ratios() {
856        let palette = ColorPalette::light();
857        let checks = palette.check_contrast();
858
859        for check in checks {
860            // All ratios should be >= 1.0 (minimum possible)
861            assert!(
862                check.ratio >= 1.0,
863                "{} has invalid ratio {}",
864                check.name,
865                check.ratio
866            );
867            // Consistency check
868            assert_eq!(check.passes_aa, check.ratio >= 4.5);
869            assert_eq!(check.passes_aaa, check.ratio >= 7.0);
870        }
871    }
872}