use chromiumoxide::Page;
use tracing::{debug, warn};
use crate::accessibility::{extract_text_styles, AXTree};
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation};
pub const CONTRAST_RULE: RuleMetadata = RuleMetadata {
id: "1.4.3",
name: "Contrast (Minimum)",
level: WcagLevel::AA,
severity: Severity::High,
description: "Text must have sufficient color contrast with background",
help_url: "https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html",
axe_id: "color-contrast",
tags: &["wcag2aa", "wcag143", "cat.color"],
};
pub struct ContrastRule;
impl ContrastRule {
pub async fn check_with_page(page: &Page, _tree: &AXTree, level: WcagLevel) -> Vec<Violation> {
debug!("Running contrast check with CDP integration...");
let mut violations = Vec::new();
let styles_result = extract_text_styles(page).await;
let styles = match styles_result {
Ok(s) => s,
Err(e) => {
warn!("Failed to extract text styles: {}", e);
return violations;
}
};
debug!("Checking contrast for {} elements", styles.len());
for style in &styles {
if let Some(visibility) = style.get("visibility") {
if visibility == "hidden" {
continue;
}
}
if let Some(display) = style.get("display") {
if display == "none" {
continue;
}
}
let fg_color_str = match style.color() {
Some(c) => c,
None => continue, };
let bg_color_str = style.background_color().unwrap_or("rgb(255, 255, 255)");
let fg_color = match Color::from_css(fg_color_str) {
Some(c) => c,
None => {
debug!("Failed to parse foreground color: {}", fg_color_str);
continue;
}
};
let bg_color = match Color::from_css(bg_color_str) {
Some(c) => c,
None => {
debug!("Failed to parse background color: {}", bg_color_str);
continue;
}
};
let ratio = Self::calculate_contrast_ratio(&fg_color, &bg_color);
let is_large = style.is_large_text();
if !Self::meets_requirement(ratio, is_large, level) {
let selector = style.selector.as_deref().unwrap_or("unknown");
let message = format!(
"Insufficient color contrast ratio: {:.2}:1 ({}text, requires {}:1)",
ratio,
if is_large { "large " } else { "" },
if is_large {
if level == WcagLevel::AAA {
"4.5"
} else {
"3.0"
}
} else if level == WcagLevel::AAA {
"7.0"
} else {
"4.5"
}
);
let fix = format!(
"Adjust colors to improve contrast. Current: foreground={}, background={}",
fg_color_str, bg_color_str
);
let mut violation = Violation::new(
CONTRAST_RULE.id,
CONTRAST_RULE.name,
CONTRAST_RULE.level,
Severity::High,
&message,
format!("{}#{}", selector, style.node_id),
)
.with_selector(selector)
.with_fix(&fix)
.with_help_url(CONTRAST_RULE.help_url);
if let Some(snippet) = &style.html_snippet {
violation = violation.with_html_snippet(snippet);
}
violations.push(violation);
}
}
debug!("Found {} contrast violations", violations.len());
violations
}
pub fn calculate_contrast_ratio(color1: &Color, color2: &Color) -> f64 {
let lum1 = color1.relative_luminance();
let lum2 = color2.relative_luminance();
let lighter = lum1.max(lum2);
let darker = lum1.min(lum2);
(lighter + 0.05) / (darker + 0.05)
}
pub fn meets_requirement(contrast_ratio: f64, is_large_text: bool, level: WcagLevel) -> bool {
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, _) => return true, };
contrast_ratio >= threshold
}
}
#[derive(Debug, Clone, Copy)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Color {
pub fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
pub fn is_transparent(css: &str) -> bool {
let css = css.trim();
if css == "transparent" {
return true;
}
if css.starts_with("rgba") {
if let Some(start) = css.find('(') {
if let Some(end) = css.find(')') {
let parts: Vec<&str> =
css[start + 1..end].split(',').map(|s| s.trim()).collect();
if parts.len() == 4 {
if let Ok(alpha) = parts[3].parse::<f64>() {
return alpha == 0.0;
}
}
}
}
}
false
}
pub fn from_css(css: &str) -> Option<Self> {
let css = css.trim();
if css.starts_with("rgb") {
return Self::parse_rgb(css);
}
if css.starts_with('#') {
return Self::parse_hex(css);
}
None
}
fn parse_rgb(css: &str) -> Option<Self> {
let start = css.find('(')?;
let end = css.find(')')?;
let values = &css[start + 1..end];
let parts: Vec<&str> = values.split(',').map(|s| s.trim()).collect();
if parts.len() < 3 {
return None;
}
let r = parts[0].parse::<u8>().ok()?;
let g = parts[1].parse::<u8>().ok()?;
let b = parts[2].parse::<u8>().ok()?;
Some(Self::new(r, g, b))
}
fn parse_hex(css: &str) -> Option<Self> {
let hex = css.trim_start_matches('#');
match hex.len() {
3 => {
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
Some(Self::new(r, g, b))
}
6 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Self::new(r, g, b))
}
_ => None,
}
}
pub fn relative_luminance(&self) -> f64 {
let r = Self::srgb_to_linear(self.r);
let g = Self::srgb_to_linear(self.g);
let b = Self::srgb_to_linear(self.b);
0.2126 * r + 0.7152 * g + 0.0722 * b
}
fn srgb_to_linear(value: u8) -> f64 {
let v = value as f64 / 255.0;
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_color_parsing_rgb() {
let color = Color::from_css("rgb(255, 0, 0)").unwrap();
assert_eq!(color.r, 255);
assert_eq!(color.g, 0);
assert_eq!(color.b, 0);
}
#[test]
fn test_color_parsing_rgba() {
let color = Color::from_css("rgba(0, 128, 255, 0.5)").unwrap();
assert_eq!(color.r, 0);
assert_eq!(color.g, 128);
assert_eq!(color.b, 255);
}
#[test]
fn test_color_parsing_hex6() {
let color = Color::from_css("#FF0000").unwrap();
assert_eq!(color.r, 255);
assert_eq!(color.g, 0);
assert_eq!(color.b, 0);
}
#[test]
fn test_color_parsing_hex3() {
let color = Color::from_css("#F00").unwrap();
assert_eq!(color.r, 255);
assert_eq!(color.g, 0);
assert_eq!(color.b, 0);
}
#[test]
fn test_relative_luminance_white() {
let white = Color::new(255, 255, 255);
let lum = white.relative_luminance();
assert!((lum - 1.0).abs() < 0.01); }
#[test]
fn test_relative_luminance_black() {
let black = Color::new(0, 0, 0);
let lum = black.relative_luminance();
assert!(lum < 0.01); }
#[test]
fn test_contrast_ratio_black_white() {
let black = Color::new(0, 0, 0);
let white = Color::new(255, 255, 255);
let ratio = ContrastRule::calculate_contrast_ratio(&black, &white);
assert!((ratio - 21.0).abs() < 0.1); }
#[test]
fn test_contrast_ratio_same_color() {
let red = Color::new(255, 0, 0);
let ratio = ContrastRule::calculate_contrast_ratio(&red, &red);
assert!((ratio - 1.0).abs() < 0.01); }
#[test]
fn test_meets_requirement_aa_normal() {
assert!(ContrastRule::meets_requirement(4.5, false, WcagLevel::AA));
assert!(ContrastRule::meets_requirement(5.0, false, WcagLevel::AA));
assert!(!ContrastRule::meets_requirement(4.0, false, WcagLevel::AA));
}
#[test]
fn test_meets_requirement_aa_large() {
assert!(ContrastRule::meets_requirement(3.0, true, WcagLevel::AA));
assert!(ContrastRule::meets_requirement(4.0, true, WcagLevel::AA));
assert!(!ContrastRule::meets_requirement(2.5, true, WcagLevel::AA));
}
#[test]
fn test_meets_requirement_aaa_normal() {
assert!(ContrastRule::meets_requirement(7.0, false, WcagLevel::AAA));
assert!(ContrastRule::meets_requirement(8.0, false, WcagLevel::AAA));
assert!(!ContrastRule::meets_requirement(6.5, false, WcagLevel::AAA));
}
#[test]
fn test_meets_requirement_level_a() {
assert!(ContrastRule::meets_requirement(1.0, false, WcagLevel::A));
assert!(ContrastRule::meets_requirement(2.0, true, WcagLevel::A));
}
#[test]
fn test_is_transparent() {
assert!(Color::is_transparent("transparent"));
assert!(Color::is_transparent("rgba(0, 0, 0, 0)"));
assert!(Color::is_transparent("rgba(255, 255, 255, 0)"));
assert!(Color::is_transparent("rgba(0, 0, 0, 0.0)"));
assert!(!Color::is_transparent("rgba(0, 0, 0, 0.5)"));
assert!(!Color::is_transparent("rgba(0, 0, 0, 1)"));
assert!(!Color::is_transparent("rgb(255, 255, 255)"));
assert!(!Color::is_transparent("#FFFFFF"));
}
}