Skip to main content

oxiui_theme/
builder.rs

1//! Fluent [`PaletteBuilder`] with WCAG contrast validation.
2//!
3//! ```rust
4//! use oxiui_core::{Color, Palette};
5//! use oxiui_theme::builder::{PaletteBuilder, WcagLevel};
6//!
7//! let result = PaletteBuilder::new()
8//!     .background(Color(0, 0, 0, 255))
9//!     .surface(Color(10, 10, 26, 255))
10//!     .text_primary(Color(255, 255, 255, 255))
11//!     .text_secondary(Color(200, 200, 200, 255))
12//!     .primary(Color(255, 255, 0, 255))
13//!     .on_primary(Color(0, 0, 0, 255))
14//!     .validate();
15//!
16//! assert!(result.is_aa_compliant);
17//! ```
18
19use crate::high_contrast::wcag_contrast;
20use crate::CooljapanTheme;
21use oxiui_core::{Color, FontSpec, Palette};
22
23/// WCAG conformance level.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum WcagLevel {
26    /// Minimum accessibility — contrast ratio ≥ 4.5:1 for normal text.
27    AA,
28    /// Enhanced accessibility — contrast ratio ≥ 7.0:1 for normal text.
29    AAA,
30}
31
32/// A contrast warning for a foreground/background colour pair.
33#[derive(Clone, Debug, PartialEq)]
34pub struct ContrastWarning {
35    /// Names of the foreground and background roles.
36    pub pair: (&'static str, &'static str),
37    /// Actual contrast ratio.
38    pub ratio: f64,
39    /// Minimum required contrast for the failing level.
40    pub required: f64,
41    /// The WCAG level that this pair fails.
42    pub level: WcagLevel,
43}
44
45/// The outcome of [`PaletteBuilder::validate`].
46#[derive(Clone, Debug)]
47pub struct ValidationResult {
48    /// All contrast warnings (pairs that fail AA or AAA).
49    pub warnings: Vec<ContrastWarning>,
50    /// `true` if every checked pair meets ≥ 4.5:1 (WCAG AA).
51    pub is_aa_compliant: bool,
52    /// `true` if every checked pair meets ≥ 7.0:1 (WCAG AAA).
53    pub is_aaa_compliant: bool,
54}
55
56/// Fluent builder for a [`Palette`] with WCAG contrast validation.
57///
58/// All fields are optional; [`build`](PaletteBuilder::build) falls back to
59/// safe defaults for any field not provided so that partial palettes compile.
60#[derive(Clone, Debug, Default)]
61pub struct PaletteBuilder {
62    background: Option<Color>,
63    surface: Option<Color>,
64    text_primary: Option<Color>,
65    text_secondary: Option<Color>,
66    primary: Option<Color>,
67    on_primary: Option<Color>,
68}
69
70impl PaletteBuilder {
71    /// Create a new builder with all fields unset.
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Set the background colour.
77    pub fn background(mut self, c: Color) -> Self {
78        self.background = Some(c);
79        self
80    }
81
82    /// Set the surface colour (cards, dialogs).
83    pub fn surface(mut self, c: Color) -> Self {
84        self.surface = Some(c);
85        self
86    }
87
88    /// Set the primary text colour.
89    pub fn text_primary(mut self, c: Color) -> Self {
90        self.text_primary = Some(c);
91        self
92    }
93
94    /// Set the secondary / muted text colour.
95    pub fn text_secondary(mut self, c: Color) -> Self {
96        self.text_secondary = Some(c);
97        self
98    }
99
100    /// Set the primary brand / accent colour.
101    pub fn primary(mut self, c: Color) -> Self {
102        self.primary = Some(c);
103        self
104    }
105
106    /// Set the "on-primary" text colour (drawn on top of `primary`).
107    pub fn on_primary(mut self, c: Color) -> Self {
108        self.on_primary = Some(c);
109        self
110    }
111
112    // ── Internal helpers ──────────────────────────────────────────────────
113
114    fn resolved_background(&self) -> Color {
115        self.background.unwrap_or(Color(255, 255, 255, 255))
116    }
117    fn resolved_surface(&self) -> Color {
118        self.surface.unwrap_or(Color(255, 255, 255, 255))
119    }
120    fn resolved_text_primary(&self) -> Color {
121        self.text_primary.unwrap_or(Color(0, 0, 0, 255))
122    }
123    fn resolved_text_secondary(&self) -> Color {
124        self.text_secondary.unwrap_or(Color(60, 60, 60, 255))
125    }
126    fn resolved_primary(&self) -> Color {
127        self.primary.unwrap_or(Color(0, 0, 200, 255))
128    }
129    fn resolved_on_primary(&self) -> Color {
130        self.on_primary.unwrap_or(Color(255, 255, 255, 255))
131    }
132
133    // ── Validation ────────────────────────────────────────────────────────
134
135    /// Validate all foreground/background pairs and return a [`ValidationResult`].
136    pub fn validate(&self) -> ValidationResult {
137        let bg = self.resolved_background();
138        let surface = self.resolved_surface();
139        let text = self.resolved_text_primary();
140        let muted = self.resolved_text_secondary();
141        let primary = self.resolved_primary();
142        let on_primary = self.resolved_on_primary();
143
144        // Pairs to check: (fg, bg, fg_name, bg_name)
145        let pairs: &[(Color, Color, &'static str, &'static str)] = &[
146            (text, bg, "text_primary", "background"),
147            (muted, bg, "text_secondary", "background"),
148            (text, surface, "text_primary", "surface"),
149            (muted, surface, "text_secondary", "surface"),
150            (on_primary, primary, "on_primary", "primary"),
151        ];
152
153        let mut warnings = Vec::new();
154        for &(fg, back, fg_name, bg_name) in pairs {
155            let ratio = wcag_contrast((fg.0, fg.1, fg.2), (back.0, back.1, back.2));
156            if ratio < 4.5 {
157                warnings.push(ContrastWarning {
158                    pair: (fg_name, bg_name),
159                    ratio,
160                    required: 4.5,
161                    level: WcagLevel::AA,
162                });
163            } else if ratio < 7.0 {
164                warnings.push(ContrastWarning {
165                    pair: (fg_name, bg_name),
166                    ratio,
167                    required: 7.0,
168                    level: WcagLevel::AAA,
169                });
170            }
171        }
172
173        let is_aa_compliant = warnings.iter().all(|w| w.level != WcagLevel::AA);
174        let is_aaa_compliant = warnings.is_empty();
175        ValidationResult {
176            warnings,
177            is_aa_compliant,
178            is_aaa_compliant,
179        }
180    }
181
182    /// Assemble a [`CooljapanTheme`] from the builder's colours.
183    ///
184    /// Returns `Err` if any foreground/background pair fails WCAG AA (< 4.5:1).
185    pub fn build(self) -> Result<CooljapanTheme, Vec<ContrastWarning>> {
186        let result = self.validate();
187        // Collect only AA failures (ratio < 4.5) as hard errors.
188        let aa_failures: Vec<ContrastWarning> = result
189            .warnings
190            .into_iter()
191            .filter(|w| w.level == WcagLevel::AA)
192            .collect();
193        if !aa_failures.is_empty() {
194            return Err(aa_failures);
195        }
196        let palette = Palette {
197            background: self.resolved_background(),
198            surface: self.resolved_surface(),
199            primary: self.resolved_primary(),
200            on_primary: self.resolved_on_primary(),
201            text: self.resolved_text_primary(),
202            muted: self.resolved_text_secondary(),
203        };
204        Ok(CooljapanTheme::new(
205            palette,
206            FontSpec::new("Inter", 14.0, 400),
207        ))
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use oxiui_core::Color;
215
216    fn high_contrast_builder() -> PaletteBuilder {
217        PaletteBuilder::new()
218            .background(Color(0, 0, 0, 255))
219            .surface(Color(10, 10, 26, 255))
220            .text_primary(Color(255, 255, 255, 255))
221            .text_secondary(Color(200, 200, 200, 255))
222            .primary(Color(255, 255, 0, 255))
223            .on_primary(Color(0, 0, 0, 255))
224    }
225
226    #[test]
227    fn builder_valid_palette_builds() {
228        let result = high_contrast_builder().build();
229        assert!(
230            result.is_ok(),
231            "high-contrast builder should succeed: {:?}",
232            result.err()
233        );
234    }
235
236    #[test]
237    fn builder_aaa_flag() {
238        let result = high_contrast_builder().validate();
239        assert!(
240            result.is_aaa_compliant,
241            "all pairs should be AAA; warnings: {:?}",
242            result.warnings
243        );
244    }
245
246    #[test]
247    fn builder_low_contrast_warns() {
248        // Light grey on white — very low contrast.
249        let result = PaletteBuilder::new()
250            .background(Color(255, 255, 255, 255))
251            .text_primary(Color(200, 200, 200, 255)) // near-white text
252            .validate();
253        assert!(
254            !result.warnings.is_empty(),
255            "should warn about low contrast"
256        );
257    }
258
259    #[test]
260    fn builder_aa_failure_returns_err() {
261        // Near-identical colours: near-white text on white background.
262        let result = PaletteBuilder::new()
263            .background(Color(255, 255, 255, 255))
264            .text_primary(Color(240, 240, 240, 255))
265            .build();
266        assert!(result.is_err(), "near-white on white should fail AA build");
267    }
268
269    #[test]
270    fn builder_default_is_accessible() {
271        // Default colours (black text on white bg) must pass AA.
272        let result = PaletteBuilder::new().validate();
273        assert!(result.is_aa_compliant);
274    }
275}