Skip to main content

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)]
517#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
518mod tests {
519    use super::*;
520
521    // =========================================================================
522    // ColorPalette Tests - TESTS FIRST
523    // =========================================================================
524
525    #[test]
526    fn test_color_palette_default() {
527        let palette = ColorPalette::default();
528        assert_eq!(palette, ColorPalette::light());
529    }
530
531    #[test]
532    fn test_color_palette_light() {
533        let palette = ColorPalette::light();
534        // Primary should be a blue color
535        assert!(palette.primary.b > palette.primary.r);
536        // Surface should be white
537        assert_eq!(palette.surface, Color::WHITE);
538        // On-primary should be white (for contrast)
539        assert_eq!(palette.on_primary, Color::WHITE);
540    }
541
542    #[test]
543    fn test_color_palette_dark() {
544        let palette = ColorPalette::dark();
545        // Surface should be dark
546        assert!(palette.surface.r < 0.5);
547        // On-surface should be white
548        assert_eq!(palette.on_surface, Color::WHITE);
549        // On-primary should be black (for contrast on light primary)
550        assert_eq!(palette.on_primary, Color::BLACK);
551    }
552
553    // =========================================================================
554    // Typography Tests - TESTS FIRST
555    // =========================================================================
556
557    #[test]
558    fn test_typography_default() {
559        let typo = Typography::default();
560        assert_eq!(typo.base_size, 16.0);
561    }
562
563    #[test]
564    fn test_typography_standard() {
565        let typo = Typography::standard();
566        assert_eq!(typo.base_size, 16.0);
567        assert_eq!(typo.h1_scale, 2.5);
568        assert_eq!(typo.line_height, 1.5);
569    }
570
571    #[test]
572    fn test_typography_compact() {
573        let typo = Typography::compact();
574        assert_eq!(typo.base_size, 14.0);
575        assert!(typo.line_height < Typography::standard().line_height);
576    }
577
578    #[test]
579    fn test_typography_heading_size() {
580        let typo = Typography::standard();
581        assert_eq!(typo.heading_size(1), 40.0); // 16 * 2.5
582        assert_eq!(typo.heading_size(2), 32.0); // 16 * 2.0
583        assert_eq!(typo.heading_size(3), 28.0); // 16 * 1.75
584        assert_eq!(typo.heading_size(4), 24.0); // 16 * 1.5
585        assert_eq!(typo.heading_size(5), 20.0); // 16 * 1.25
586        assert_eq!(typo.heading_size(6), 18.0); // 16 * 1.125
587    }
588
589    #[test]
590    fn test_typography_heading_size_out_of_range() {
591        let typo = Typography::standard();
592        // Level > 6 should use h6 scale
593        assert_eq!(typo.heading_size(7), typo.heading_size(6));
594        assert_eq!(typo.heading_size(0), typo.heading_size(6));
595    }
596
597    #[test]
598    fn test_typography_body_size() {
599        let typo = Typography::standard();
600        assert_eq!(typo.body_size(), 16.0);
601    }
602
603    #[test]
604    fn test_typography_caption_size() {
605        let typo = Typography::standard();
606        assert_eq!(typo.caption_size(), 12.0); // 16 * 0.75
607    }
608
609    // =========================================================================
610    // Spacing Tests - TESTS FIRST
611    // =========================================================================
612
613    #[test]
614    fn test_spacing_default() {
615        let spacing = Spacing::default();
616        assert_eq!(spacing.unit, 8.0);
617    }
618
619    #[test]
620    fn test_spacing_standard() {
621        let spacing = Spacing::standard();
622        assert_eq!(spacing.unit, 8.0);
623    }
624
625    #[test]
626    fn test_spacing_compact() {
627        let spacing = Spacing::compact();
628        assert_eq!(spacing.unit, 4.0);
629    }
630
631    #[test]
632    fn test_spacing_get() {
633        let spacing = Spacing::standard();
634        assert_eq!(spacing.get(0.0), 0.0);
635        assert_eq!(spacing.get(1.0), 8.0);
636        assert_eq!(spacing.get(2.0), 16.0);
637        assert_eq!(spacing.get(0.5), 4.0);
638    }
639
640    #[test]
641    fn test_spacing_presets() {
642        let spacing = Spacing::standard();
643        assert_eq!(spacing.none(), 0.0);
644        assert_eq!(spacing.xs(), 4.0); // 0.5x
645        assert_eq!(spacing.sm(), 8.0); // 1x
646        assert_eq!(spacing.md(), 16.0); // 2x
647        assert_eq!(spacing.lg(), 24.0); // 3x
648        assert_eq!(spacing.xl(), 32.0); // 4x
649        assert_eq!(spacing.xl2(), 48.0); // 6x
650        assert_eq!(spacing.xl3(), 64.0); // 8x
651    }
652
653    // =========================================================================
654    // Radii Tests - TESTS FIRST
655    // =========================================================================
656
657    #[test]
658    fn test_radii_default() {
659        let radii = Radii::default();
660        assert_eq!(radii.unit, 4.0);
661    }
662
663    #[test]
664    fn test_radii_presets() {
665        let radii = Radii::standard();
666        assert_eq!(radii.none(), 0.0);
667        assert_eq!(radii.sm(), 4.0);
668        assert_eq!(radii.md(), 8.0);
669        assert_eq!(radii.lg(), 12.0);
670        assert_eq!(radii.xl(), 16.0);
671        assert_eq!(radii.full(), 9999.0);
672    }
673
674    // =========================================================================
675    // Shadows Tests - TESTS FIRST
676    // =========================================================================
677
678    #[test]
679    fn test_shadows_default() {
680        let shadows = Shadows::default();
681        assert!(shadows.color.a < 0.5); // Shadow should be semi-transparent
682    }
683
684    #[test]
685    fn test_shadows_presets() {
686        let shadows = Shadows::standard();
687        let (blur_sm, offset_sm) = shadows.sm();
688        let (blur_md, offset_md) = shadows.md();
689        let (blur_lg, offset_lg) = shadows.lg();
690        let (blur_xl, offset_xl) = shadows.xl();
691
692        // Each level should be larger than the previous
693        assert!(blur_md > blur_sm);
694        assert!(blur_lg > blur_md);
695        assert!(blur_xl > blur_lg);
696
697        assert!(offset_md > offset_sm);
698        assert!(offset_lg > offset_md);
699        assert!(offset_xl > offset_lg);
700    }
701
702    // =========================================================================
703    // Theme Tests - TESTS FIRST
704    // =========================================================================
705
706    #[test]
707    fn test_theme_default() {
708        let theme = Theme::default();
709        assert_eq!(theme.name, "Light");
710    }
711
712    #[test]
713    fn test_theme_light() {
714        let theme = Theme::light();
715        assert_eq!(theme.name, "Light");
716        assert_eq!(theme.colors, ColorPalette::light());
717    }
718
719    #[test]
720    fn test_theme_dark() {
721        let theme = Theme::dark();
722        assert_eq!(theme.name, "Dark");
723        assert_eq!(theme.colors, ColorPalette::dark());
724    }
725
726    #[test]
727    fn test_theme_with_name() {
728        let theme = Theme::light().with_name("Custom");
729        assert_eq!(theme.name, "Custom");
730    }
731
732    #[test]
733    fn test_theme_with_colors() {
734        let theme = Theme::light().with_colors(ColorPalette::dark());
735        assert_eq!(theme.colors, ColorPalette::dark());
736    }
737
738    #[test]
739    fn test_theme_with_typography() {
740        let theme = Theme::light().with_typography(Typography::compact());
741        assert_eq!(theme.typography, Typography::compact());
742    }
743
744    #[test]
745    fn test_theme_with_spacing() {
746        let theme = Theme::light().with_spacing(Spacing::compact());
747        assert_eq!(theme.spacing, Spacing::compact());
748    }
749
750    #[test]
751    fn test_theme_with_radii() {
752        let custom_radii = Radii { unit: 2.0 };
753        let theme = Theme::light().with_radii(custom_radii);
754        assert_eq!(theme.radii.unit, 2.0);
755    }
756
757    #[test]
758    fn test_theme_builder_chain() {
759        let theme = Theme::light()
760            .with_name("My Theme")
761            .with_colors(ColorPalette::dark())
762            .with_typography(Typography::compact())
763            .with_spacing(Spacing::compact());
764
765        assert_eq!(theme.name, "My Theme");
766        assert_eq!(theme.colors, ColorPalette::dark());
767        assert_eq!(theme.typography, Typography::compact());
768        assert_eq!(theme.spacing, Spacing::compact());
769    }
770
771    #[test]
772    fn test_theme_serialization() {
773        let theme = Theme::dark();
774        let json = serde_json::to_string(&theme).expect("serialize");
775        let restored: Theme = serde_json::from_str(&json).expect("deserialize");
776        assert_eq!(theme, restored);
777    }
778
779    // =========================================================================
780    // Contrast Check Tests
781    // =========================================================================
782
783    #[test]
784    fn test_light_palette_contrast_aa() {
785        let palette = ColorPalette::light();
786        let checks = palette.check_contrast();
787
788        // Should have checks for all pairs
789        assert_eq!(checks.len(), 5);
790
791        // on_primary/primary should pass (white on blue)
792        let primary_check = checks.iter().find(|c| c.name.contains("primary")).unwrap();
793        assert!(
794            primary_check.passes_aa,
795            "on_primary/primary ratio: {:.2}",
796            primary_check.ratio
797        );
798    }
799
800    #[test]
801    fn test_dark_palette_contrast_aa() {
802        let palette = ColorPalette::dark();
803        let checks = palette.check_contrast();
804
805        // on_surface/surface should pass (white on dark)
806        let surface_check = checks.iter().find(|c| c.name.contains("surface")).unwrap();
807        assert!(
808            surface_check.passes_aa,
809            "on_surface/surface ratio: {:.2}",
810            surface_check.ratio
811        );
812    }
813
814    #[test]
815    fn test_passes_wcag_aa() {
816        let light = ColorPalette::light();
817        let dark = ColorPalette::dark();
818
819        // Built-in palettes should be accessible
820        assert!(
821            light.passes_wcag_aa(),
822            "Light palette should pass AA: {:?}",
823            light.failing_aa()
824        );
825        assert!(
826            dark.passes_wcag_aa(),
827            "Dark palette should pass AA: {:?}",
828            dark.failing_aa()
829        );
830    }
831
832    #[test]
833    fn test_failing_aa() {
834        // Create an intentionally inaccessible palette
835        let bad_palette = ColorPalette {
836            primary: Color::rgb(0.5, 0.5, 0.5),
837            secondary: Color::rgb(0.5, 0.5, 0.5),
838            surface: Color::rgb(0.6, 0.6, 0.6), // Similar to on_surface
839            background: Color::rgb(0.6, 0.6, 0.6),
840            error: Color::rgb(0.5, 0.5, 0.5),
841            warning: Color::rgb(0.5, 0.5, 0.5),
842            success: Color::rgb(0.5, 0.5, 0.5),
843            on_primary: Color::rgb(0.6, 0.6, 0.6), // Low contrast
844            on_secondary: Color::rgb(0.6, 0.6, 0.6),
845            on_surface: Color::rgb(0.5, 0.5, 0.5), // Low contrast
846            on_background: Color::rgb(0.5, 0.5, 0.5),
847            on_error: Color::rgb(0.6, 0.6, 0.6),
848        };
849
850        assert!(!bad_palette.passes_wcag_aa());
851        let failures = bad_palette.failing_aa();
852        assert!(!failures.is_empty());
853    }
854
855    #[test]
856    fn test_contrast_check_ratios() {
857        let palette = ColorPalette::light();
858        let checks = palette.check_contrast();
859
860        for check in checks {
861            // All ratios should be >= 1.0 (minimum possible)
862            assert!(
863                check.ratio >= 1.0,
864                "{} has invalid ratio {}",
865                check.name,
866                check.ratio
867            );
868            // Consistency check
869            assert_eq!(check.passes_aa, check.ratio >= 4.5);
870            assert_eq!(check.passes_aaa, check.ratio >= 7.0);
871        }
872    }
873}