use chromiumoxide::Page;
use tracing::{debug, warn};
use crate::accessibility::{extract_text_styles, AXTree};
use crate::audit::ViewportScreenshot;
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"],
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContrastVerdict {
Pass,
Violation,
NeedsReview,
}
pub struct ContrastRule;
impl ContrastRule {
pub async fn check_with_page(
page: &Page,
_tree: &AXTree,
level: WcagLevel,
screenshot: Option<&ViewportScreenshot>,
) -> 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());
let mut sample_tasks = Vec::new();
if screenshot.is_some() {
for style in &styles {
let fg_color_str = match style.color() {
Some(c) => c,
None => continue,
};
let background_uncertain = style
.get("background-uncertain")
.map(|value| value == "true")
.unwrap_or(false);
if background_uncertain {
let fg_color = match Color::from_css(fg_color_str) {
Some(c) => c,
None => continue,
};
let bg_color_str = style.background_color().unwrap_or("rgb(255, 255, 255)");
let bg_color = match Color::from_css(bg_color_str) {
Some(c) => c,
None => continue,
};
let white_color = Color::new(255, 255, 255);
let bg_effective = bg_color.composite_over(&white_color);
let fg_effective = fg_color.composite_over(&bg_effective);
let ratio = Self::calculate_contrast_ratio(&fg_effective, &bg_effective);
let is_large = style.is_large_text();
if !Self::meets_requirement(ratio, is_large, level) {
if let Some(ref sel) = style.selector {
let threshold = if is_large {
if level == WcagLevel::AAA {
4.5
} else {
3.0
}
} else if level == WcagLevel::AAA {
7.0
} else {
4.5
};
sample_tasks.push(serde_json::json!({
"selector": sel,
"fgColor": fg_color_str,
"threshold": threshold,
}));
}
}
}
}
}
let mut sampled_verdicts = std::collections::HashMap::new();
if !sample_tasks.is_empty() {
if let Some(shot) = screenshot {
let base64_data = to_base64(&shot.bytes);
let tasks_json = serde_json::to_string(&sample_tasks).unwrap();
let js_script = format!(
r#"(async () => {{
const tasks = {};
const screenshotBase64 = "{}";
const img = new Image();
const loaded = new Promise((resolve, reject) => {{
img.onload = () => resolve(true);
img.onerror = (e) => reject(new Error("Failed to load screenshot image"));
}});
img.src = "data:image/png;base64," + screenshotBase64;
try {{
await loaded;
}} catch (err) {{
return {{ error: err.message, results: [] }};
}}
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {{
return {{ error: "Failed to get 2d canvas context", results: [] }};
}}
ctx.drawImage(img, 0, 0);
const dpr = {};
const scrollX = {};
const scrollY = {};
function parseColor(cStr) {{
const m = cStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m) return {{ r: 0, g: 0, b: 0 }};
return {{
r: parseInt(m[1], 10),
g: parseInt(m[2], 10),
b: parseInt(m[3], 10)
}};
}}
function relativeLuminance(c) {{
const srgb = [c.r / 255, c.g / 255, c.b / 255];
const linear = srgb.map(v => {{
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}});
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}}
function contrastRatio(l1, l2) {{
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}}
const results = [];
for (const task of tasks) {{
try {{
const el = document.querySelector(task.selector);
if (!el) {{
results.push({{ selector: task.selector, verdict: "NeedsReview", reason: "Element not found" }});
continue;
}}
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {{
results.push({{ selector: task.selector, verdict: "NeedsReview", reason: "Zero area rect" }});
continue;
}}
const left = Math.round(rect.left * dpr);
const top = Math.round(rect.top * dpr);
const width = Math.round(rect.width * dpr);
const height = Math.round(rect.height * dpr);
if (left < 0 || top < 0 || left + width > img.width || top + height > img.height) {{
results.push({{ selector: task.selector, verdict: "NeedsReview", reason: "Element outside screenshot bounds" }});
continue;
}}
const imgData = ctx.getImageData(left, top, width, height);
const pixels = imgData.data;
const fg = parseColor(task.fgColor);
const fgLum = relativeLuminance(fg);
const ratios = [];
for (let i = 0; i < pixels.length; i += 4) {{
const a = pixels[i + 3] / 255;
const r = Math.round(pixels[i] * a + 255 * (1 - a));
const g = Math.round(pixels[i + 1] * a + 255 * (1 - a));
const b = Math.round(pixels[i + 2] * a + 255 * (1 - a));
const bgLum = relativeLuminance({{ r, g, b }});
ratios.push(contrastRatio(fgLum, bgLum));
}}
if (ratios.length === 0) {{
results.push({{ selector: task.selector, verdict: "NeedsReview", reason: "No pixels sampled" }});
continue;
}}
ratios.sort((x, y) => x - y);
const worstIdx = Math.floor(ratios.length * 0.40);
const worstCaseRatio = ratios[worstIdx];
const medianIdx = Math.floor(ratios.length * 0.50);
const medianRatio = ratios[medianIdx];
let verdict = "NeedsReview";
if (medianRatio >= task.threshold && worstCaseRatio >= task.threshold) {{
verdict = "Pass";
}} else if (medianRatio < task.threshold) {{
verdict = "Violation";
}}
results.push({{
selector: task.selector,
verdict,
medianRatio,
worstCaseRatio,
threshold: task.threshold
}});
}} catch (e) {{
results.push({{ selector: task.selector, verdict: "NeedsReview", reason: "Error: " + e.message }});
}}
}}
return {{ results }};
}})();"#,
tasks_json, base64_data, shot.device_scale_factor, shot.scroll_x, shot.scroll_y
);
match page.evaluate(js_script.as_str()).await {
Ok(res) => {
if let Some(val) = res.value() {
if let Some(arr) = val.get("results").and_then(|v| v.as_array()) {
for item in arr {
if let Some(sel) = item.get("selector").and_then(|v| v.as_str())
{
if let Some(verdict) =
item.get("verdict").and_then(|v| v.as_str())
{
let median_ratio =
item.get("medianRatio").and_then(|v| v.as_f64());
let worst_case_ratio =
item.get("worstCaseRatio").and_then(|v| v.as_f64());
sampled_verdicts.insert(
sel.to_string(),
(
verdict.to_string(),
median_ratio,
worst_case_ratio,
),
);
}
}
}
}
}
}
Err(e) => {
warn!("Pixel sampling script execution failed: {}", e);
}
}
}
}
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 background_uncertain = style
.get("background-uncertain")
.map(|value| value == "true")
.unwrap_or(false);
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 white_color = Color::new(255, 255, 255);
let bg_effective = bg_color.composite_over(&white_color);
let fg_effective = fg_color.composite_over(&bg_effective);
let ratio = Self::calculate_contrast_ratio(&fg_effective, &bg_effective);
let is_large = style.is_large_text();
let mut verdict = Self::verdict(ratio, is_large, level, background_uncertain);
let mut final_ratio = ratio;
let mut is_warning = background_uncertain;
if background_uncertain && verdict == ContrastVerdict::NeedsReview {
if let Some(ref sel) = style.selector {
if let Some((sampled_verdict, median, _worst)) = sampled_verdicts.get(sel) {
if sampled_verdict == "Pass" {
verdict = ContrastVerdict::Pass;
} else if sampled_verdict == "Violation" {
verdict = ContrastVerdict::Violation;
is_warning = false;
if let Some(m) = median {
final_ratio = *m;
}
} else {
verdict = ContrastVerdict::NeedsReview;
is_warning = true;
if let Some(m) = median {
final_ratio = *m;
}
}
}
}
}
if verdict != ContrastVerdict::Pass {
let selector = style.selector.as_deref().unwrap_or("unknown");
let threshold = if is_large {
if level == WcagLevel::AAA {
"4.5"
} else {
"3.0"
}
} else if level == WcagLevel::AAA {
"7.0"
} else {
"4.5"
};
let message = if is_warning {
format!(
"Potential insufficient color contrast ratio: {:.2}:1 ({}text, requires {}:1). Background includes an image or gradient and needs manual review.",
final_ratio,
if is_large { "large " } else { "" },
threshold
)
} else {
format!(
"Insufficient color contrast ratio: {:.2}:1 ({}text, requires {}:1)",
final_ratio,
if is_large { "large " } else { "" },
threshold
)
};
let fix = if is_warning {
format!(
"Verify contrast against the rendered image/gradient background. Estimated from CSS colors: foreground={}, background={}",
fg_color_str, bg_color_str
)
} else {
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);
}
if is_warning {
violation = violation.as_warning();
}
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 verdict(
contrast_ratio: f64,
is_large_text: bool,
level: WcagLevel,
background_uncertain: bool,
) -> ContrastVerdict {
if Self::meets_requirement(contrast_ratio, is_large_text, level) {
ContrastVerdict::Pass
} else if background_uncertain {
ContrastVerdict::NeedsReview
} else {
ContrastVerdict::Violation
}
}
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, PartialEq)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: f64,
}
impl Color {
pub fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 1.0 }
}
pub fn composite_over(&self, background: &Color) -> Self {
let a_fg = self.a;
let a_bg = background.a;
let a_out = a_fg + a_bg * (1.0 - a_fg);
if a_out == 0.0 {
return Self {
r: 0,
g: 0,
b: 0,
a: 0.0,
};
}
let r_out = ((self.r as f64 * a_fg + background.r as f64 * a_bg * (1.0 - a_fg)) / a_out)
.round() as u8;
let g_out = ((self.g as f64 * a_fg + background.g as f64 * a_bg * (1.0 - a_fg)) / a_out)
.round() as u8;
let b_out = ((self.b as f64 * a_fg + background.b as f64 * a_bg * (1.0 - a_fg)) / a_out)
.round() as u8;
Self {
r: r_out,
g: g_out,
b: b_out,
a: a_out,
}
}
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.001; }
}
}
}
}
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()?;
let a = if parts.len() >= 4 {
parts[3].parse::<f64>().unwrap_or(1.0)
} else {
1.0
};
Some(Self { r, g, b, a })
}
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 { r, g, b, a: 1.0 })
}
4 => {
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()?;
let a_val = u8::from_str_radix(&hex[3..4].repeat(2), 16).ok()?;
let a = a_val as f64 / 255.0;
Some(Self { r, g, b, a })
}
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 { r, g, b, a: 1.0 })
}
8 => {
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()?;
let a_val = u8::from_str_radix(&hex[6..8], 16).ok()?;
let a = a_val as f64 / 255.0;
Some(Self { r, g, b, a })
}
_ => 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)
}
}
}
fn to_base64(bytes: &[u8]) -> String {
const CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
match chunk.len() {
3 => {
let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | (chunk[2] as u32);
result.push(CHARS[((n >> 18) & 63) as usize] as char);
result.push(CHARS[((n >> 12) & 63) as usize] as char);
result.push(CHARS[((n >> 6) & 63) as usize] as char);
result.push(CHARS[(n & 63) as usize] as char);
}
2 => {
let n = ((chunk[0] as u32) << 8) | (chunk[1] as u32);
result.push(CHARS[((n >> 10) & 63) as usize] as char);
result.push(CHARS[((n >> 4) & 63) as usize] as char);
result.push(CHARS[((n << 2) & 63) as usize] as char);
result.push('=');
}
1 => {
let n = chunk[0] as u32;
result.push(CHARS[((n >> 2) & 63) as usize] as char);
result.push(CHARS[((n << 4) & 63) as usize] as char);
result.push('=');
result.push('=');
}
_ => unreachable!(),
}
}
result
}
#[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);
assert!((color.a - 0.5).abs() < 0.001);
}
#[test]
fn test_color_parsing_hex8() {
let color = Color::from_css("#0080FF7F").unwrap();
assert_eq!(color.r, 0);
assert_eq!(color.g, 128);
assert_eq!(color.b, 255);
assert!((color.a - 127.0 / 255.0).abs() < 0.001);
}
#[test]
fn test_color_parsing_hex4() {
let color = Color::from_css("#08F7").unwrap();
assert_eq!(color.r, 0);
assert_eq!(color.g, 136);
assert_eq!(color.b, 255);
assert!((color.a - 119.0 / 255.0).abs() < 0.001);
}
#[test]
fn test_alpha_compositing_and_blending() {
let fg = Color::from_css("rgba(0, 0, 0, 0.1)").unwrap(); let bg = Color::new(255, 255, 255); let effective = fg.composite_over(&bg);
assert_eq!(effective.r, 230); assert_eq!(effective.g, 230);
assert_eq!(effective.b, 230);
assert_eq!(effective.a, 1.0);
}
#[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"));
}
#[test]
fn verdict_passes_when_ratio_meets_threshold() {
assert_eq!(
ContrastRule::verdict(5.0, false, WcagLevel::AA, false),
ContrastVerdict::Pass
);
assert_eq!(
ContrastRule::verdict(5.0, false, WcagLevel::AA, true),
ContrastVerdict::Pass
);
}
#[test]
fn verdict_confirms_violation_on_solid_background() {
assert_eq!(
ContrastRule::verdict(3.0, false, WcagLevel::AA, false),
ContrastVerdict::Violation
);
}
#[test]
fn verdict_demotes_image_background_to_review() {
assert_eq!(
ContrastRule::verdict(3.0, false, WcagLevel::AA, true),
ContrastVerdict::NeedsReview
);
assert_eq!(
ContrastRule::verdict(3.0, true, WcagLevel::AA, true),
ContrastVerdict::Pass
);
}
}