1use crate::high_contrast::wcag_contrast;
20use crate::CooljapanTheme;
21use oxiui_core::{Color, FontSpec, Palette};
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum WcagLevel {
26 AA,
28 AAA,
30}
31
32#[derive(Clone, Debug, PartialEq)]
34pub struct ContrastWarning {
35 pub pair: (&'static str, &'static str),
37 pub ratio: f64,
39 pub required: f64,
41 pub level: WcagLevel,
43}
44
45#[derive(Clone, Debug)]
47pub struct ValidationResult {
48 pub warnings: Vec<ContrastWarning>,
50 pub is_aa_compliant: bool,
52 pub is_aaa_compliant: bool,
54}
55
56#[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 pub fn new() -> Self {
73 Self::default()
74 }
75
76 pub fn background(mut self, c: Color) -> Self {
78 self.background = Some(c);
79 self
80 }
81
82 pub fn surface(mut self, c: Color) -> Self {
84 self.surface = Some(c);
85 self
86 }
87
88 pub fn text_primary(mut self, c: Color) -> Self {
90 self.text_primary = Some(c);
91 self
92 }
93
94 pub fn text_secondary(mut self, c: Color) -> Self {
96 self.text_secondary = Some(c);
97 self
98 }
99
100 pub fn primary(mut self, c: Color) -> Self {
102 self.primary = Some(c);
103 self
104 }
105
106 pub fn on_primary(mut self, c: Color) -> Self {
108 self.on_primary = Some(c);
109 self
110 }
111
112 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 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 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 pub fn build(self) -> Result<CooljapanTheme, Vec<ContrastWarning>> {
186 let result = self.validate();
187 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 let result = PaletteBuilder::new()
250 .background(Color(255, 255, 255, 255))
251 .text_primary(Color(200, 200, 200, 255)) .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 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 let result = PaletteBuilder::new().validate();
273 assert!(result.is_aa_compliant);
274 }
275}