use crate::base::Rgba;
use crate::theme::tokens::{TokenId, TokenSet};
pub fn contrast_ratio(a: Rgba, b: Rgba) -> f32 {
let la = a.luminance();
let lb = b.luminance();
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
pub fn decisiveness(bg: Rgba) -> f32 {
(bg.luminance() - 0.5).abs()
}
pub const DECISIVENESS_MARGIN: f32 = 0.15;
#[derive(Clone, Debug, PartialEq)]
pub struct Violation {
pub theme: String,
pub rule: &'static str,
pub token: TokenId,
pub measured: f32,
pub required: f32,
}
impl core::fmt::Display for Violation {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"[{}] {} on {:?}: measured {:.2}, required {:.2}",
self.theme, self.rule, self.token, self.measured, self.required
)
}
}
pub const AUDIT_EXCEPTIONS: &[(&str, &str)] = &[
("everforest-light", "text/surface_raised"),
];
pub mod floors {
pub const TEXT: f32 = 4.5;
pub const TEXT_TARGET: f32 = 7.0; pub const TEXT_MUTED: f32 = 3.0;
pub const TEXT_FAINT: f32 = 2.5;
pub const ACCENT: f32 = 3.0;
pub const SEMANTIC: f32 = 3.0;
pub const LINK: f32 = 3.0;
pub const SELECTION_TEXT: f32 = 4.5;
pub const BORDER: f32 = 1.5;
pub const BORDER_FOCUS: f32 = 2.0;
pub const CURSOR: f32 = 3.0;
pub const SYNTAX: f32 = 4.5;
pub const SYNTAX_COMMENT: f32 = 3.0;
pub const GROUND_SEPARATION_REPORT: f32 = 1.10;
}
pub fn audit(theme_id: &str, t: &TokenSet) -> Vec<Violation> {
let mut out = Vec::new();
let mut check = |rule: &'static str, token: TokenId, fg: Rgba, bg: Rgba, floor: f32| {
let measured = contrast_ratio(fg, bg);
if measured < floor {
out.push(Violation {
theme: theme_id.to_string(),
rule,
token,
measured,
required: floor,
});
}
};
for (bg_name, bg) in [
("bg", t.bg),
("surface", t.surface),
("surface_raised", t.surface_raised),
] {
let rule: &'static str = match bg_name {
"bg" => "text/bg",
"surface" => "text/surface",
_ => "text/surface_raised",
};
check(rule, TokenId::Text, t.text, bg, floors::TEXT);
}
check(
"text_muted/bg",
TokenId::TextMuted,
t.text_muted,
t.bg,
floors::TEXT_MUTED,
);
check(
"text_faint/bg",
TokenId::TextFaint,
t.text_faint,
t.bg,
floors::TEXT_FAINT,
);
check("accent/bg", TokenId::Accent, t.accent, t.bg, floors::ACCENT);
check(
"accent_alt/bg",
TokenId::AccentAlt,
t.accent_alt,
t.bg,
floors::ACCENT,
);
check("ok/bg", TokenId::Ok, t.ok, t.bg, floors::SEMANTIC);
check("warn/bg", TokenId::Warn, t.warn, t.bg, floors::SEMANTIC);
check("error/bg", TokenId::Error, t.error, t.bg, floors::SEMANTIC);
check("info/bg", TokenId::Info, t.info, t.bg, floors::SEMANTIC);
check("link/bg", TokenId::Link, t.link, t.bg, floors::LINK);
check(
"selection_fg/selection_bg",
TokenId::SelectionFg,
t.selection_fg,
t.selection_bg,
floors::SELECTION_TEXT,
);
check("border/bg", TokenId::Border, t.border, t.bg, floors::BORDER);
check(
"border_focus/bg",
TokenId::BorderFocus,
t.border_focus,
t.bg,
floors::BORDER_FOCUS,
);
check("cursor/bg", TokenId::Cursor, t.cursor, t.bg, floors::CURSOR);
let code_ground = t.surface_raised;
let primary = crate::theme::registry::syntax_floor(floors::SYNTAX, t.text, code_ground);
let secondary =
crate::theme::registry::syntax_floor(floors::SYNTAX_COMMENT, t.text, code_ground);
for (rule, token, ink, floor) in [
(
"syntax_keyword/raised",
TokenId::SyntaxKeyword,
t.syntax_keyword,
primary,
),
(
"syntax_string/raised",
TokenId::SyntaxString,
t.syntax_string,
primary,
),
(
"syntax_number/raised",
TokenId::SyntaxNumber,
t.syntax_number,
primary,
),
(
"syntax_type/raised",
TokenId::SyntaxType,
t.syntax_type,
primary,
),
(
"syntax_func/raised",
TokenId::SyntaxFunc,
t.syntax_func,
primary,
),
(
"syntax_punct/raised",
TokenId::SyntaxPunct,
t.syntax_punct,
primary,
),
(
"syntax_comment/raised",
TokenId::SyntaxComment,
t.syntax_comment,
secondary,
),
] {
let measured = contrast_ratio(ink, code_ground);
if measured < floor {
out.push(Violation {
theme: theme_id.to_string(),
rule,
token,
measured,
required: floor,
});
}
}
for (i, c) in t.chart.iter().enumerate() {
let measured = contrast_ratio(*c, t.bg);
if measured < floors::SEMANTIC {
out.push(Violation {
theme: theme_id.to_string(),
rule: "chart/bg",
token: TokenId::chart(i as u8),
measured,
required: floors::SEMANTIC,
});
}
}
let margin = decisiveness(t.bg);
if margin < DECISIVENESS_MARGIN {
out.push(Violation {
theme: theme_id.to_string(),
rule: "decisive-ground",
token: TokenId::Bg,
measured: margin,
required: DECISIVENESS_MARGIN,
});
}
out
}
#[derive(Clone, Debug, PartialEq)]
pub struct GroundOverlap {
pub theme: String,
pub a: TokenId,
pub b: TokenId,
pub measured: f32,
}
impl core::fmt::Display for GroundOverlap {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"[{}] {} / {} measure {:.3} apart",
self.theme,
self.a.name(),
self.b.name(),
self.measured
)
}
}
pub fn ground_overlaps(theme_id: &str, t: &TokenSet, floor: f32) -> Vec<GroundOverlap> {
let g = t.grounds();
let mut out = Vec::new();
for i in 0..g.len() {
for j in (i + 1)..g.len() {
let measured = contrast_ratio(g[i].1, g[j].1);
if measured < floor {
out.push(GroundOverlap {
theme: theme_id.to_string(),
a: g[i].0,
b: g[j].0,
measured,
});
}
}
}
out
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Ink {
pub color: Rgba,
pub token: TokenId,
pub contrast: f32,
}
pub fn ink_on(t: &TokenSet, ground: Rgba) -> Ink {
let candidates = [(TokenId::Text, t.text), (TokenId::Bg, t.bg)];
let mut best = Ink {
color: candidates[0].1,
token: candidates[0].0,
contrast: contrast_ratio(candidates[0].1, ground),
};
for (token, color) in candidates.into_iter().skip(1) {
let contrast = contrast_ratio(color, ground);
if contrast > best.contrast {
best = Ink {
color,
token,
contrast,
};
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ratio_matches_known_wcag_anchors() {
assert!((contrast_ratio(Rgba::BLACK, Rgba::WHITE) - 21.0).abs() < 0.01);
let g = Rgba::rgb(0x80, 0x80, 0x80);
assert!((contrast_ratio(g, g) - 1.0).abs() < 1e-6);
let a = Rgba::rgb(0xe9, 0x45, 0x60);
let b = Rgba::rgb(0x1a, 0x1a, 0x2e);
assert_eq!(contrast_ratio(a, b), contrast_ratio(b, a));
}
#[test]
fn known_pair_measures_as_expected() {
let g = Rgba::rgb(0x76, 0x76, 0x76);
let r = contrast_ratio(g, Rgba::WHITE);
assert!(r > 4.4 && r < 4.7, "measured {r}");
}
#[test]
fn audit_flags_a_deliberately_broken_theme() {
let mut t = TokenSet::default();
t.text = t.bg; let violations = audit("broken", &t);
assert!(violations.iter().any(|v| v.rule == "text/bg"));
let msg = violations[0].to_string();
assert!(msg.contains("broken") && msg.contains("required"));
}
#[test]
fn audit_flags_indecisive_ground() {
let t = TokenSet {
bg: Rgba::rgb(0xbb, 0xbb, 0xbb), ..Default::default()
};
let violations = audit("mid", &t);
assert!(violations.iter().any(|v| v.rule == "decisive-ground"));
}
}