use crate::color::Color;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ColorPalette {
pub primary: Color,
pub secondary: Color,
pub surface: Color,
pub background: Color,
pub error: Color,
pub warning: Color,
pub success: Color,
pub on_primary: Color,
pub on_secondary: Color,
pub on_surface: Color,
pub on_background: Color,
pub on_error: Color,
}
impl Default for ColorPalette {
fn default() -> Self {
Self::light()
}
}
#[derive(Debug, Clone)]
pub struct ContrastCheck {
pub name: String,
pub foreground: Color,
pub background: Color,
pub ratio: f32,
pub passes_aa: bool,
pub passes_aaa: bool,
}
impl ColorPalette {
#[must_use]
pub fn check_contrast(&self) -> Vec<ContrastCheck> {
let checks = [
("on_primary/primary", self.on_primary, self.primary),
("on_secondary/secondary", self.on_secondary, self.secondary),
("on_surface/surface", self.on_surface, self.surface),
(
"on_background/background",
self.on_background,
self.background,
),
("on_error/error", self.on_error, self.error),
];
checks
.into_iter()
.map(|(name, fg, bg)| {
let ratio = fg.contrast_ratio(&bg);
ContrastCheck {
name: name.to_string(),
foreground: fg,
background: bg,
ratio,
passes_aa: ratio >= 4.5,
passes_aaa: ratio >= 7.0,
}
})
.collect()
}
#[must_use]
pub fn passes_wcag_aa(&self) -> bool {
self.check_contrast().iter().all(|c| c.passes_aa)
}
#[must_use]
pub fn passes_wcag_aaa(&self) -> bool {
self.check_contrast().iter().all(|c| c.passes_aaa)
}
#[must_use]
pub fn failing_aa(&self) -> Vec<ContrastCheck> {
self.check_contrast()
.into_iter()
.filter(|c| !c.passes_aa)
.collect()
}
#[must_use]
pub fn light() -> Self {
Self {
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,
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,
on_secondary: Color::WHITE,
on_surface: Color::new(0.13, 0.13, 0.13, 1.0), on_background: Color::new(0.13, 0.13, 0.13, 1.0),
on_error: Color::WHITE,
}
}
#[must_use]
pub fn dark() -> Self {
Self {
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,
on_secondary: Color::BLACK,
on_surface: Color::WHITE,
on_background: Color::WHITE,
on_error: Color::BLACK,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Typography {
pub base_size: f32,
pub h1_scale: f32,
pub h2_scale: f32,
pub h3_scale: f32,
pub h4_scale: f32,
pub h5_scale: f32,
pub h6_scale: f32,
pub body_scale: f32,
pub caption_scale: f32,
pub line_height: f32,
}
impl Default for Typography {
fn default() -> Self {
Self::standard()
}
}
impl Typography {
#[must_use]
pub const fn standard() -> Self {
Self {
base_size: 16.0,
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,
}
}
#[must_use]
pub const fn compact() -> Self {
Self {
base_size: 14.0,
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,
}
}
#[must_use]
pub fn heading_size(&self, level: u8) -> f32 {
let scale = match level {
1 => self.h1_scale,
2 => self.h2_scale,
3 => self.h3_scale,
4 => self.h4_scale,
5 => self.h5_scale,
_ => self.h6_scale,
};
self.base_size * scale
}
#[must_use]
pub fn body_size(&self) -> f32 {
self.base_size * self.body_scale
}
#[must_use]
pub fn caption_size(&self) -> f32 {
self.base_size * self.caption_scale
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Spacing {
pub unit: f32,
}
impl Default for Spacing {
fn default() -> Self {
Self::standard()
}
}
impl Spacing {
#[must_use]
pub const fn standard() -> Self {
Self { unit: 8.0 }
}
#[must_use]
pub const fn compact() -> Self {
Self { unit: 4.0 }
}
#[must_use]
pub fn get(&self, multiplier: f32) -> f32 {
self.unit * multiplier
}
#[must_use]
pub const fn none(&self) -> f32 {
0.0
}
#[must_use]
pub fn xs(&self) -> f32 {
self.unit * 0.5
}
#[must_use]
pub const fn sm(&self) -> f32 {
self.unit
}
#[must_use]
pub fn md(&self) -> f32 {
self.unit * 2.0
}
#[must_use]
pub fn lg(&self) -> f32 {
self.unit * 3.0
}
#[must_use]
pub fn xl(&self) -> f32 {
self.unit * 4.0
}
#[must_use]
pub fn xl2(&self) -> f32 {
self.unit * 6.0
}
#[must_use]
pub fn xl3(&self) -> f32 {
self.unit * 8.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Radii {
pub unit: f32,
}
impl Default for Radii {
fn default() -> Self {
Self::standard()
}
}
impl Radii {
#[must_use]
pub const fn standard() -> Self {
Self { unit: 4.0 }
}
#[must_use]
pub const fn none(&self) -> f32 {
0.0
}
#[must_use]
pub const fn sm(&self) -> f32 {
self.unit
}
#[must_use]
pub fn md(&self) -> f32 {
self.unit * 2.0
}
#[must_use]
pub fn lg(&self) -> f32 {
self.unit * 3.0
}
#[must_use]
pub fn xl(&self) -> f32 {
self.unit * 4.0
}
#[must_use]
pub const fn full(&self) -> f32 {
9999.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Shadows {
pub color: Color,
}
impl Default for Shadows {
fn default() -> Self {
Self::standard()
}
}
impl Shadows {
#[must_use]
pub fn standard() -> Self {
Self {
color: Color::new(0.0, 0.0, 0.0, 0.1),
}
}
#[must_use]
pub const fn sm(&self) -> (f32, f32) {
(2.0, 1.0)
}
#[must_use]
pub const fn md(&self) -> (f32, f32) {
(4.0, 2.0)
}
#[must_use]
pub const fn lg(&self) -> (f32, f32) {
(8.0, 4.0)
}
#[must_use]
pub const fn xl(&self) -> (f32, f32) {
(16.0, 8.0)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Theme {
pub name: String,
pub colors: ColorPalette,
pub typography: Typography,
pub spacing: Spacing,
pub radii: Radii,
pub shadows: Shadows,
}
impl Default for Theme {
fn default() -> Self {
Self::light()
}
}
impl Theme {
#[must_use]
pub fn light() -> Self {
Self {
name: "Light".to_string(),
colors: ColorPalette::light(),
typography: Typography::standard(),
spacing: Spacing::standard(),
radii: Radii::standard(),
shadows: Shadows::standard(),
}
}
#[must_use]
pub fn dark() -> Self {
Self {
name: "Dark".to_string(),
colors: ColorPalette::dark(),
typography: Typography::standard(),
spacing: Spacing::standard(),
radii: Radii::standard(),
shadows: Shadows::standard(),
}
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
#[must_use]
pub const fn with_colors(mut self, colors: ColorPalette) -> Self {
self.colors = colors;
self
}
#[must_use]
pub const fn with_typography(mut self, typography: Typography) -> Self {
self.typography = typography;
self
}
#[must_use]
pub const fn with_spacing(mut self, spacing: Spacing) -> Self {
self.spacing = spacing;
self
}
#[must_use]
pub const fn with_radii(mut self, radii: Radii) -> Self {
self.radii = radii;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_color_palette_default() {
let palette = ColorPalette::default();
assert_eq!(palette, ColorPalette::light());
}
#[test]
fn test_color_palette_light() {
let palette = ColorPalette::light();
assert!(palette.primary.b > palette.primary.r);
assert_eq!(palette.surface, Color::WHITE);
assert_eq!(palette.on_primary, Color::WHITE);
}
#[test]
fn test_color_palette_dark() {
let palette = ColorPalette::dark();
assert!(palette.surface.r < 0.5);
assert_eq!(palette.on_surface, Color::WHITE);
assert_eq!(palette.on_primary, Color::BLACK);
}
#[test]
fn test_typography_default() {
let typo = Typography::default();
assert_eq!(typo.base_size, 16.0);
}
#[test]
fn test_typography_standard() {
let typo = Typography::standard();
assert_eq!(typo.base_size, 16.0);
assert_eq!(typo.h1_scale, 2.5);
assert_eq!(typo.line_height, 1.5);
}
#[test]
fn test_typography_compact() {
let typo = Typography::compact();
assert_eq!(typo.base_size, 14.0);
assert!(typo.line_height < Typography::standard().line_height);
}
#[test]
fn test_typography_heading_size() {
let typo = Typography::standard();
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); }
#[test]
fn test_typography_heading_size_out_of_range() {
let typo = Typography::standard();
assert_eq!(typo.heading_size(7), typo.heading_size(6));
assert_eq!(typo.heading_size(0), typo.heading_size(6));
}
#[test]
fn test_typography_body_size() {
let typo = Typography::standard();
assert_eq!(typo.body_size(), 16.0);
}
#[test]
fn test_typography_caption_size() {
let typo = Typography::standard();
assert_eq!(typo.caption_size(), 12.0); }
#[test]
fn test_spacing_default() {
let spacing = Spacing::default();
assert_eq!(spacing.unit, 8.0);
}
#[test]
fn test_spacing_standard() {
let spacing = Spacing::standard();
assert_eq!(spacing.unit, 8.0);
}
#[test]
fn test_spacing_compact() {
let spacing = Spacing::compact();
assert_eq!(spacing.unit, 4.0);
}
#[test]
fn test_spacing_get() {
let spacing = Spacing::standard();
assert_eq!(spacing.get(0.0), 0.0);
assert_eq!(spacing.get(1.0), 8.0);
assert_eq!(spacing.get(2.0), 16.0);
assert_eq!(spacing.get(0.5), 4.0);
}
#[test]
fn test_spacing_presets() {
let spacing = Spacing::standard();
assert_eq!(spacing.none(), 0.0);
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); }
#[test]
fn test_radii_default() {
let radii = Radii::default();
assert_eq!(radii.unit, 4.0);
}
#[test]
fn test_radii_presets() {
let radii = Radii::standard();
assert_eq!(radii.none(), 0.0);
assert_eq!(radii.sm(), 4.0);
assert_eq!(radii.md(), 8.0);
assert_eq!(radii.lg(), 12.0);
assert_eq!(radii.xl(), 16.0);
assert_eq!(radii.full(), 9999.0);
}
#[test]
fn test_shadows_default() {
let shadows = Shadows::default();
assert!(shadows.color.a < 0.5); }
#[test]
fn test_shadows_presets() {
let shadows = Shadows::standard();
let (blur_sm, offset_sm) = shadows.sm();
let (blur_md, offset_md) = shadows.md();
let (blur_lg, offset_lg) = shadows.lg();
let (blur_xl, offset_xl) = shadows.xl();
assert!(blur_md > blur_sm);
assert!(blur_lg > blur_md);
assert!(blur_xl > blur_lg);
assert!(offset_md > offset_sm);
assert!(offset_lg > offset_md);
assert!(offset_xl > offset_lg);
}
#[test]
fn test_theme_default() {
let theme = Theme::default();
assert_eq!(theme.name, "Light");
}
#[test]
fn test_theme_light() {
let theme = Theme::light();
assert_eq!(theme.name, "Light");
assert_eq!(theme.colors, ColorPalette::light());
}
#[test]
fn test_theme_dark() {
let theme = Theme::dark();
assert_eq!(theme.name, "Dark");
assert_eq!(theme.colors, ColorPalette::dark());
}
#[test]
fn test_theme_with_name() {
let theme = Theme::light().with_name("Custom");
assert_eq!(theme.name, "Custom");
}
#[test]
fn test_theme_with_colors() {
let theme = Theme::light().with_colors(ColorPalette::dark());
assert_eq!(theme.colors, ColorPalette::dark());
}
#[test]
fn test_theme_with_typography() {
let theme = Theme::light().with_typography(Typography::compact());
assert_eq!(theme.typography, Typography::compact());
}
#[test]
fn test_theme_with_spacing() {
let theme = Theme::light().with_spacing(Spacing::compact());
assert_eq!(theme.spacing, Spacing::compact());
}
#[test]
fn test_theme_with_radii() {
let custom_radii = Radii { unit: 2.0 };
let theme = Theme::light().with_radii(custom_radii);
assert_eq!(theme.radii.unit, 2.0);
}
#[test]
fn test_theme_builder_chain() {
let theme = Theme::light()
.with_name("My Theme")
.with_colors(ColorPalette::dark())
.with_typography(Typography::compact())
.with_spacing(Spacing::compact());
assert_eq!(theme.name, "My Theme");
assert_eq!(theme.colors, ColorPalette::dark());
assert_eq!(theme.typography, Typography::compact());
assert_eq!(theme.spacing, Spacing::compact());
}
#[test]
fn test_theme_serialization() {
let theme = Theme::dark();
let json = serde_json::to_string(&theme).expect("serialize");
let restored: Theme = serde_json::from_str(&json).expect("deserialize");
assert_eq!(theme, restored);
}
#[test]
fn test_light_palette_contrast_aa() {
let palette = ColorPalette::light();
let checks = palette.check_contrast();
assert_eq!(checks.len(), 5);
let primary_check = checks.iter().find(|c| c.name.contains("primary")).unwrap();
assert!(
primary_check.passes_aa,
"on_primary/primary ratio: {:.2}",
primary_check.ratio
);
}
#[test]
fn test_dark_palette_contrast_aa() {
let palette = ColorPalette::dark();
let checks = palette.check_contrast();
let surface_check = checks.iter().find(|c| c.name.contains("surface")).unwrap();
assert!(
surface_check.passes_aa,
"on_surface/surface ratio: {:.2}",
surface_check.ratio
);
}
#[test]
fn test_passes_wcag_aa() {
let light = ColorPalette::light();
let dark = ColorPalette::dark();
assert!(
light.passes_wcag_aa(),
"Light palette should pass AA: {:?}",
light.failing_aa()
);
assert!(
dark.passes_wcag_aa(),
"Dark palette should pass AA: {:?}",
dark.failing_aa()
);
}
#[test]
fn test_failing_aa() {
let bad_palette = ColorPalette {
primary: Color::rgb(0.5, 0.5, 0.5),
secondary: Color::rgb(0.5, 0.5, 0.5),
surface: Color::rgb(0.6, 0.6, 0.6), background: Color::rgb(0.6, 0.6, 0.6),
error: Color::rgb(0.5, 0.5, 0.5),
warning: Color::rgb(0.5, 0.5, 0.5),
success: Color::rgb(0.5, 0.5, 0.5),
on_primary: Color::rgb(0.6, 0.6, 0.6), on_secondary: Color::rgb(0.6, 0.6, 0.6),
on_surface: Color::rgb(0.5, 0.5, 0.5), on_background: Color::rgb(0.5, 0.5, 0.5),
on_error: Color::rgb(0.6, 0.6, 0.6),
};
assert!(!bad_palette.passes_wcag_aa());
let failures = bad_palette.failing_aa();
assert!(!failures.is_empty());
}
#[test]
fn test_contrast_check_ratios() {
let palette = ColorPalette::light();
let checks = palette.check_contrast();
for check in checks {
assert!(
check.ratio >= 1.0,
"{} has invalid ratio {}",
check.name,
check.ratio
);
assert_eq!(check.passes_aa, check.ratio >= 4.5);
assert_eq!(check.passes_aaa, check.ratio >= 7.0);
}
}
}