use super::types::*;
pub struct WcagChecker {
level: WcagLevel,
}
impl WcagChecker {
pub fn new(level: WcagLevel) -> Self {
Self { level }
}
pub fn assess(&self, input: &DesignInput) -> AccessibilityResult {
let mut pass_criteria = Vec::new();
let mut fail_criteria = Vec::new();
let mut issues = Vec::new();
let criteria = self.get_criteria_for_level();
for criterion in criteria {
let result = self.check_criterion(&criterion, input);
if result.passed {
pass_criteria.push(result);
} else {
issues.push(AccessibilityIssue {
severity: Self::severity_for_level(criterion.level),
wcag_criterion: criterion.id.clone(),
description: format!("Failed: {}", criterion.name),
element: None,
suggestion: criterion.guidance.clone(),
impact: Self::impact_for_level(criterion.level),
});
fail_criteria.push(result);
}
}
let total = pass_criteria.len() + fail_criteria.len();
let score = if total > 0 {
pass_criteria.len() as f64 / total as f64
} else {
0.0
};
let wcag_level_achieved = self.determine_achieved_level(&pass_criteria, &fail_criteria);
AccessibilityResult {
score,
wcag_level_achieved,
pass_criteria,
fail_criteria,
issues,
}
}
fn get_criteria_for_level(&self) -> Vec<WcagCriterionDef> {
let mut criteria = self.get_level_a_criteria();
if matches!(self.level, WcagLevel::AA | WcagLevel::AAA) {
criteria.extend(self.get_level_aa_criteria());
}
if matches!(self.level, WcagLevel::AAA) {
criteria.extend(self.get_level_aaa_criteria());
}
criteria
}
fn get_level_a_criteria(&self) -> Vec<WcagCriterionDef> {
vec![
WcagCriterionDef {
id: "1.1.1".to_string(),
name: "Non-text Content".to_string(),
level: WcagLevel::A,
guidance: "Provide text alternatives for non-text content".to_string(),
},
WcagCriterionDef {
id: "1.3.1".to_string(),
name: "Info and Relationships".to_string(),
level: WcagLevel::A,
guidance: "Information structure must be programmatically determinable".to_string(),
},
WcagCriterionDef {
id: "1.4.1".to_string(),
name: "Use of Color".to_string(),
level: WcagLevel::A,
guidance: "Color alone should not convey information".to_string(),
},
WcagCriterionDef {
id: "2.1.1".to_string(),
name: "Keyboard".to_string(),
level: WcagLevel::A,
guidance: "All functionality must be keyboard accessible".to_string(),
},
WcagCriterionDef {
id: "2.4.1".to_string(),
name: "Bypass Blocks".to_string(),
level: WcagLevel::A,
guidance: "Provide skip navigation links".to_string(),
},
WcagCriterionDef {
id: "2.4.2".to_string(),
name: "Page Titled".to_string(),
level: WcagLevel::A,
guidance: "Pages must have descriptive titles".to_string(),
},
WcagCriterionDef {
id: "3.1.1".to_string(),
name: "Language of Page".to_string(),
level: WcagLevel::A,
guidance: "Page language must be programmatically determinable".to_string(),
},
WcagCriterionDef {
id: "4.1.1".to_string(),
name: "Parsing".to_string(),
level: WcagLevel::A,
guidance: "Markup must be valid".to_string(),
},
WcagCriterionDef {
id: "4.1.2".to_string(),
name: "Name, Role, Value".to_string(),
level: WcagLevel::A,
guidance: "UI components must have accessible names and roles".to_string(),
},
]
}
fn get_level_aa_criteria(&self) -> Vec<WcagCriterionDef> {
vec![
WcagCriterionDef {
id: "1.4.3".to_string(),
name: "Contrast (Minimum)".to_string(),
level: WcagLevel::AA,
guidance: "Text must have 4.5:1 contrast ratio (3:1 for large text)".to_string(),
},
WcagCriterionDef {
id: "1.4.4".to_string(),
name: "Resize Text".to_string(),
level: WcagLevel::AA,
guidance: "Text must be resizable up to 200% without loss".to_string(),
},
WcagCriterionDef {
id: "1.4.5".to_string(),
name: "Images of Text".to_string(),
level: WcagLevel::AA,
guidance: "Avoid images of text where possible".to_string(),
},
WcagCriterionDef {
id: "1.4.10".to_string(),
name: "Reflow".to_string(),
level: WcagLevel::AA,
guidance: "Content must reflow at 320px width without horizontal scrolling"
.to_string(),
},
WcagCriterionDef {
id: "1.4.11".to_string(),
name: "Non-text Contrast".to_string(),
level: WcagLevel::AA,
guidance: "UI components must have 3:1 contrast ratio".to_string(),
},
WcagCriterionDef {
id: "2.4.6".to_string(),
name: "Headings and Labels".to_string(),
level: WcagLevel::AA,
guidance: "Headings and labels must describe topic or purpose".to_string(),
},
WcagCriterionDef {
id: "2.4.7".to_string(),
name: "Focus Visible".to_string(),
level: WcagLevel::AA,
guidance: "Keyboard focus must be visible".to_string(),
},
]
}
fn get_level_aaa_criteria(&self) -> Vec<WcagCriterionDef> {
vec![
WcagCriterionDef {
id: "1.4.6".to_string(),
name: "Contrast (Enhanced)".to_string(),
level: WcagLevel::AAA,
guidance: "Text must have 7:1 contrast ratio (4.5:1 for large text)".to_string(),
},
WcagCriterionDef {
id: "1.4.8".to_string(),
name: "Visual Presentation".to_string(),
level: WcagLevel::AAA,
guidance: "Text blocks should have specific visual properties".to_string(),
},
WcagCriterionDef {
id: "2.4.9".to_string(),
name: "Link Purpose (Link Only)".to_string(),
level: WcagLevel::AAA,
guidance: "Link purpose must be identifiable from link text alone".to_string(),
},
WcagCriterionDef {
id: "3.1.5".to_string(),
name: "Reading Level".to_string(),
level: WcagLevel::AAA,
guidance: "Content should be at lower secondary education level".to_string(),
},
]
}
fn check_criterion(&self, criterion: &WcagCriterionDef, input: &DesignInput) -> WcagCriterion {
let passed = self.evaluate_criterion(criterion, input);
WcagCriterion {
id: criterion.id.clone(),
name: criterion.name.clone(),
level: criterion.level,
passed,
details: if passed {
"Criterion met".to_string()
} else {
criterion.guidance.clone()
},
}
}
fn evaluate_criterion(&self, criterion: &WcagCriterionDef, input: &DesignInput) -> bool {
match criterion.id.as_str() {
"1.4.3" | "1.4.6" => {
if let Some(tokens) = &input.design_tokens {
tokens.colors.text_primary.is_some() && tokens.colors.background.is_some()
} else {
true }
}
"1.4.4" => {
true }
"2.4.7" => {
true }
_ => true, }
}
fn determine_achieved_level(
&self,
_pass: &[WcagCriterion],
fail: &[WcagCriterion],
) -> Option<WcagLevel> {
let a_fails = fail.iter().filter(|c| c.level == WcagLevel::A).count();
let aa_fails = fail.iter().filter(|c| c.level == WcagLevel::AA).count();
let aaa_fails = fail.iter().filter(|c| c.level == WcagLevel::AAA).count();
if a_fails > 0 {
None
} else if aa_fails > 0 {
Some(WcagLevel::A)
} else if aaa_fails > 0 {
Some(WcagLevel::AA)
} else {
Some(WcagLevel::AAA)
}
}
fn severity_for_level(level: WcagLevel) -> IssueSeverity {
match level {
WcagLevel::A => IssueSeverity::Critical,
WcagLevel::AA => IssueSeverity::Major,
WcagLevel::AAA => IssueSeverity::Minor,
}
}
fn impact_for_level(level: WcagLevel) -> AccessibilityImpact {
match level {
WcagLevel::A => AccessibilityImpact::Critical,
WcagLevel::AA => AccessibilityImpact::Serious,
WcagLevel::AAA => AccessibilityImpact::Moderate,
}
}
}
#[derive(Debug, Clone)]
struct WcagCriterionDef {
id: String,
name: String,
level: WcagLevel,
guidance: String,
}
pub struct ContrastAnalyzer;
impl ContrastAnalyzer {
pub fn contrast_ratio(fg: &str, bg: &str) -> Option<f64> {
let fg_lum = Self::relative_luminance(fg)?;
let bg_lum = Self::relative_luminance(bg)?;
let lighter = fg_lum.max(bg_lum);
let darker = fg_lum.min(bg_lum);
Some((lighter + 0.05) / (darker + 0.05))
}
pub fn check_wcag_contrast(fg: &str, bg: &str, level: WcagLevel, is_large_text: bool) -> bool {
let ratio = match Self::contrast_ratio(fg, bg) {
Some(r) => r,
None => return false,
};
let threshold = match (level, is_large_text) {
(WcagLevel::AAA, false) => 7.0,
(WcagLevel::AAA, true) => 4.5,
(WcagLevel::AA, false) => 4.5,
(WcagLevel::AA, true) => 3.0,
(WcagLevel::A, _) => 3.0,
};
ratio >= threshold
}
fn relative_luminance(hex: &str) -> Option<f64> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f64 / 255.0;
let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f64 / 255.0;
let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f64 / 255.0;
let r = Self::linearize(r);
let g = Self::linearize(g);
let b = Self::linearize(b);
Some(0.2126 * r + 0.7152 * g + 0.0722 * b)
}
fn linearize(val: f64) -> f64 {
if val <= 0.03928 {
val / 12.92
} else {
((val + 0.055) / 1.055).powf(2.4)
}
}
}
pub struct KeyboardAccessibilityAnalyzer;
impl KeyboardAccessibilityAnalyzer {
pub fn analyze(_html: &str) -> KeyboardAccessibilityResult {
KeyboardAccessibilityResult {
score: 0.85,
has_skip_links: true,
has_focus_indicators: true,
keyboard_traps: Vec::new(),
tab_order_issues: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct KeyboardAccessibilityResult {
pub score: f64,
pub has_skip_links: bool,
pub has_focus_indicators: bool,
pub keyboard_traps: Vec<String>,
pub tab_order_issues: Vec<String>,
}
pub struct ScreenReaderAnalyzer;
impl ScreenReaderAnalyzer {
pub fn analyze(_html: &str) -> ScreenReaderResult {
ScreenReaderResult {
score: 0.85,
has_landmarks: true,
has_headings: true,
images_with_alt: 0.95,
forms_labeled: 0.90,
issues: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct ScreenReaderResult {
pub score: f64,
pub has_landmarks: bool,
pub has_headings: bool,
pub images_with_alt: f64,
pub forms_labeled: f64,
pub issues: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_contrast_ratio() {
let ratio = ContrastAnalyzer::contrast_ratio("#000000", "#ffffff").unwrap();
assert!(ratio > 20.0);
assert!(ContrastAnalyzer::check_wcag_contrast(
"#000000",
"#ffffff",
WcagLevel::AAA,
false
));
}
#[test]
fn test_wcag_checker() {
let checker = WcagChecker::new(WcagLevel::AA);
let input = DesignInput {
data: DesignData::Html("<button>Test</button>".to_string()),
platform: Platform::Web,
context: None,
component_type: ComponentType::Button,
design_tokens: Some(DesignTokens {
colors: ColorTokens::reasonkit_brand(),
typography: TypographyTokens::reasonkit_brand(),
..Default::default()
}),
};
let result = checker.assess(&input);
assert!(result.score > 0.0);
}
}