1use crate::color::Color;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub struct ColorPalette {
9 pub primary: Color,
11 pub secondary: Color,
13 pub surface: Color,
15 pub background: Color,
17 pub error: Color,
19 pub warning: Color,
21 pub success: Color,
23 pub on_primary: Color,
25 pub on_secondary: Color,
27 pub on_surface: Color,
29 pub on_background: Color,
31 pub on_error: Color,
33}
34
35impl Default for ColorPalette {
36 fn default() -> Self {
37 Self::light()
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct ContrastCheck {
44 pub name: String,
46 pub foreground: Color,
48 pub background: Color,
50 pub ratio: f32,
52 pub passes_aa: bool,
54 pub passes_aaa: bool,
56}
57
58impl ColorPalette {
59 #[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 #[must_use]
93 pub fn passes_wcag_aa(&self) -> bool {
94 self.check_contrast().iter().all(|c| c.passes_aa)
95 }
96
97 #[must_use]
99 pub fn passes_wcag_aaa(&self) -> bool {
100 self.check_contrast().iter().all(|c| c.passes_aaa)
101 }
102
103 #[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 #[must_use]
115 pub fn light() -> Self {
116 Self {
117 primary: Color::new(0.0, 0.35, 0.75, 1.0), secondary: Color::new(0.0, 0.40, 0.60, 1.0), surface: Color::WHITE,
120 background: Color::new(0.98, 0.98, 0.98, 1.0), error: Color::new(0.69, 0.18, 0.18, 1.0), warning: Color::new(0.70, 0.45, 0.0, 1.0), success: Color::new(0.18, 0.55, 0.34, 1.0), on_primary: Color::WHITE,
125 on_secondary: Color::WHITE,
126 on_surface: Color::new(0.13, 0.13, 0.13, 1.0), on_background: Color::new(0.13, 0.13, 0.13, 1.0),
128 on_error: Color::WHITE,
129 }
130 }
131
132 #[must_use]
134 pub fn dark() -> Self {
135 Self {
136 primary: Color::new(0.51, 0.71, 1.0, 1.0), secondary: Color::new(0.31, 0.82, 0.71, 1.0), surface: Color::new(0.14, 0.14, 0.14, 1.0), background: Color::new(0.07, 0.07, 0.07, 1.0), error: Color::new(0.94, 0.47, 0.47, 1.0), warning: Color::new(1.0, 0.78, 0.35, 1.0), success: Color::new(0.51, 0.78, 0.58, 1.0), 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct Typography {
155 pub base_size: f32,
157 pub h1_scale: f32,
159 pub h2_scale: f32,
161 pub h3_scale: f32,
163 pub h4_scale: f32,
165 pub h5_scale: f32,
167 pub h6_scale: f32,
169 pub body_scale: f32,
171 pub caption_scale: f32,
173 pub line_height: f32,
175}
176
177impl Default for Typography {
178 fn default() -> Self {
179 Self::standard()
180 }
181}
182
183impl Typography {
184 #[must_use]
186 pub const fn standard() -> Self {
187 Self {
188 base_size: 16.0,
189 h1_scale: 2.5, h2_scale: 2.0, h3_scale: 1.75, h4_scale: 1.5, h5_scale: 1.25, h6_scale: 1.125, body_scale: 1.0, caption_scale: 0.75, line_height: 1.5,
198 }
199 }
200
201 #[must_use]
203 pub const fn compact() -> Self {
204 Self {
205 base_size: 14.0,
206 h1_scale: 2.286, h2_scale: 1.857, h3_scale: 1.571, h4_scale: 1.286, h5_scale: 1.143, h6_scale: 1.0, body_scale: 1.0, caption_scale: 0.786, line_height: 1.4,
215 }
216 }
217
218 #[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 #[must_use]
234 pub fn body_size(&self) -> f32 {
235 self.base_size * self.body_scale
236 }
237
238 #[must_use]
240 pub fn caption_size(&self) -> f32 {
241 self.base_size * self.caption_scale
242 }
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
247pub struct Spacing {
248 pub unit: f32,
250}
251
252impl Default for Spacing {
253 fn default() -> Self {
254 Self::standard()
255 }
256}
257
258impl Spacing {
259 #[must_use]
261 pub const fn standard() -> Self {
262 Self { unit: 8.0 }
263 }
264
265 #[must_use]
267 pub const fn compact() -> Self {
268 Self { unit: 4.0 }
269 }
270
271 #[must_use]
273 pub fn get(&self, multiplier: f32) -> f32 {
274 self.unit * multiplier
275 }
276
277 #[must_use]
279 pub const fn none(&self) -> f32 {
280 0.0
281 }
282
283 #[must_use]
285 pub fn xs(&self) -> f32 {
286 self.unit * 0.5
287 }
288
289 #[must_use]
291 pub const fn sm(&self) -> f32 {
292 self.unit
293 }
294
295 #[must_use]
297 pub fn md(&self) -> f32 {
298 self.unit * 2.0
299 }
300
301 #[must_use]
303 pub fn lg(&self) -> f32 {
304 self.unit * 3.0
305 }
306
307 #[must_use]
309 pub fn xl(&self) -> f32 {
310 self.unit * 4.0
311 }
312
313 #[must_use]
315 pub fn xl2(&self) -> f32 {
316 self.unit * 6.0
317 }
318
319 #[must_use]
321 pub fn xl3(&self) -> f32 {
322 self.unit * 8.0
323 }
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
328pub struct Radii {
329 pub unit: f32,
331}
332
333impl Default for Radii {
334 fn default() -> Self {
335 Self::standard()
336 }
337}
338
339impl Radii {
340 #[must_use]
342 pub const fn standard() -> Self {
343 Self { unit: 4.0 }
344 }
345
346 #[must_use]
348 pub const fn none(&self) -> f32 {
349 0.0
350 }
351
352 #[must_use]
354 pub const fn sm(&self) -> f32 {
355 self.unit
356 }
357
358 #[must_use]
360 pub fn md(&self) -> f32 {
361 self.unit * 2.0
362 }
363
364 #[must_use]
366 pub fn lg(&self) -> f32 {
367 self.unit * 3.0
368 }
369
370 #[must_use]
372 pub fn xl(&self) -> f32 {
373 self.unit * 4.0
374 }
375
376 #[must_use]
378 pub const fn full(&self) -> f32 {
379 9999.0
380 }
381}
382
383#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385pub struct Shadows {
386 pub color: Color,
388}
389
390impl Default for Shadows {
391 fn default() -> Self {
392 Self::standard()
393 }
394}
395
396impl Shadows {
397 #[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 #[must_use]
407 pub const fn sm(&self) -> (f32, f32) {
408 (2.0, 1.0)
409 }
410
411 #[must_use]
413 pub const fn md(&self) -> (f32, f32) {
414 (4.0, 2.0)
415 }
416
417 #[must_use]
419 pub const fn lg(&self) -> (f32, f32) {
420 (8.0, 4.0)
421 }
422
423 #[must_use]
425 pub const fn xl(&self) -> (f32, f32) {
426 (16.0, 8.0)
427 }
428}
429
430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432pub struct Theme {
433 pub name: String,
435 pub colors: ColorPalette,
437 pub typography: Typography,
439 pub spacing: Spacing,
441 pub radii: Radii,
443 pub shadows: Shadows,
445}
446
447impl Default for Theme {
448 fn default() -> Self {
449 Self::light()
450 }
451}
452
453impl Theme {
454 #[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 #[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 #[must_use]
482 pub fn with_name(mut self, name: impl Into<String>) -> Self {
483 self.name = name.into();
484 self
485 }
486
487 #[must_use]
489 pub const fn with_colors(mut self, colors: ColorPalette) -> Self {
490 self.colors = colors;
491 self
492 }
493
494 #[must_use]
496 pub const fn with_typography(mut self, typography: Typography) -> Self {
497 self.typography = typography;
498 self
499 }
500
501 #[must_use]
503 pub const fn with_spacing(mut self, spacing: Spacing) -> Self {
504 self.spacing = spacing;
505 self
506 }
507
508 #[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 #[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 assert!(palette.primary.b > palette.primary.r);
535 assert_eq!(palette.surface, Color::WHITE);
537 assert_eq!(palette.on_primary, Color::WHITE);
539 }
540
541 #[test]
542 fn test_color_palette_dark() {
543 let palette = ColorPalette::dark();
544 assert!(palette.surface.r < 0.5);
546 assert_eq!(palette.on_surface, Color::WHITE);
548 assert_eq!(palette.on_primary, Color::BLACK);
550 }
551
552 #[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); assert_eq!(typo.heading_size(2), 32.0); assert_eq!(typo.heading_size(3), 28.0); assert_eq!(typo.heading_size(4), 24.0); assert_eq!(typo.heading_size(5), 20.0); assert_eq!(typo.heading_size(6), 18.0); }
587
588 #[test]
589 fn test_typography_heading_size_out_of_range() {
590 let typo = Typography::standard();
591 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); }
607
608 #[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); assert_eq!(spacing.sm(), 8.0); assert_eq!(spacing.md(), 16.0); assert_eq!(spacing.lg(), 24.0); assert_eq!(spacing.xl(), 32.0); assert_eq!(spacing.xl2(), 48.0); assert_eq!(spacing.xl3(), 64.0); }
651
652 #[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 #[test]
678 fn test_shadows_default() {
679 let shadows = Shadows::default();
680 assert!(shadows.color.a < 0.5); }
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 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 #[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 #[test]
783 fn test_light_palette_contrast_aa() {
784 let palette = ColorPalette::light();
785 let checks = palette.check_contrast();
786
787 assert_eq!(checks.len(), 5);
789
790 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 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 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 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), 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), on_secondary: Color::rgb(0.6, 0.6, 0.6),
844 on_surface: Color::rgb(0.5, 0.5, 0.5), 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 assert!(
862 check.ratio >= 1.0,
863 "{} has invalid ratio {}",
864 check.name,
865 check.ratio
866 );
867 assert_eq!(check.passes_aa, check.ratio >= 4.5);
869 assert_eq!(check.passes_aaa, check.ratio >= 7.0);
870 }
871 }
872}