WCAG Contrast
The WCAG 2.1 success criteria require specific contrast ratios
between text and its background. ftui-style ships the math and the
threshold constants so every theme, preset, and user-chosen color
combination can be audited against the standard.
The four thresholds
pub const WCAG_AA_NORMAL_TEXT: f64 = 4.5;
pub const WCAG_AA_LARGE_TEXT: f64 = 3.0;
pub const WCAG_AAA_NORMAL_TEXT: f64 = 7.0;
pub const WCAG_AAA_LARGE_TEXT: f64 = 4.5;| Level | Normal text | Large text |
|---|---|---|
| AA | ≥ 4.5 : 1 | ≥ 3.0 : 1 |
| AAA | ≥ 7.0 : 1 | ≥ 4.5 : 1 |
“Large text” in WCAG terms is 18 pt regular or 14 pt bold. In a terminal the boundary is less crisp; err on the side of “normal” unless you’re rendering a banner in a deliberately larger cell font.
Relative luminance — linearized sRGB
Every sRGB channel value has to be linearized before contrast math, because sRGB uses a non-linear transfer curve:
Then the relative luminance (per ITU-R BT.709 weights) is:
where , , are the linearized channels in [0, 1]. Green
dominates because human vision is most sensitive to it.
pub fn relative_luminance(rgb: Rgb) -> f64;
pub fn relative_luminance_packed(color: PackedRgba) -> f64;Contrast ratio
Given two colors, the contrast ratio is:
The + 0.05 flare term is defined by WCAG to model a small amount of
ambient light in the viewing environment. The ratio ranges from 1.0
(identical colors, no contrast) to 21.0 (black on white).
pub fn contrast_ratio(fg: Rgb, bg: Rgb) -> f64;
pub fn contrast_ratio_packed(fg: PackedRgba, bg: PackedRgba) -> f64;The level helpers
pub fn meets_wcag_aa(fg: Rgb, bg: Rgb) -> bool; // ≥ 4.5
pub fn meets_wcag_aa_packed(fg: PackedRgba, bg: PackedRgba) -> bool;
pub fn meets_wcag_aa_large_text(fg: Rgb, bg: Rgb) -> bool; // ≥ 3.0
pub fn meets_wcag_aaa(fg: Rgb, bg: Rgb) -> bool; // ≥ 7.0Each is a one-liner over contrast_ratio. The _packed variants skip
the Rgb::from conversion.
best_text_color — pick from candidates
pub fn best_text_color(bg: Rgb, candidates: &[Rgb]) -> Rgb;Given a background and a palette of candidates (e.g. your theme’s foregrounds), returns the one with the highest contrast. Useful for “paint text on an arbitrary color cell” workflows.
use ftui_style::{best_text_color, Rgb};
let bg = Rgb::new(240, 240, 240);
let candidates = [
Rgb::new(20, 20, 20), // near-black
Rgb::new(80, 80, 80), // dark grey
Rgb::new(200, 50, 50), // red
];
let fg = best_text_color(bg, &candidates);
assert_eq!(fg, Rgb::new(20, 20, 20));Worked example — reject an illegible pair
use ftui_style::{contrast_ratio, meets_wcag_aa, Rgb};
fn audit(pair_name: &str, fg: Rgb, bg: Rgb) {
let cr = contrast_ratio(fg, bg);
let ok = meets_wcag_aa(fg, bg);
println!("{pair_name:>20}: ratio {cr:5.2} AA: {ok}");
}
audit("black on white", Rgb::new(0, 0, 0), Rgb::new(255, 255, 255));
audit("grey on white", Rgb::new(180, 180, 180), Rgb::new(255, 255, 255));
audit("dim yellow on...",Rgb::new(220, 200, 100), Rgb::new(240, 240, 240));
// stdout:
// black on white: ratio 21.00 AA: true
// grey on white : ratio 1.86 AA: false
// dim yellow on.: ratio 1.49 AA: falseWhy the 0.05 flare term matters
Without the flare term, contrast between two very dark colors would go to infinity ( in the denominator). The 0.05 adds a constant ambient component that makes the metric agree with how humans actually perceive contrast in an imperfect room. Don’t rewrite it away.
Integration with themes
FrankenTUI themes compose adaptive colors — light and dark variants
for every semantic slot. Whenever a new theme is built, a CI check
runs meets_wcag_aa against each fg/bg pair. A theme that fails AA
on any slot is rejected at merge time. See the
a11y / contrast-checking page for the
production check harness.
Pitfalls
Contrast depends on both foreground and background. Changing the theme’s background invalidates every foreground’s audit. Always check pairs, never single colors.
Dimmed text lowers effective contrast. Style::dim() maps to
SGR 2, which many terminals render as a mild fade. The luminance
shift is terminal-specific; the WCAG helpers do not account for it.
Treat dim text as a failure mode for borderline pairs.
Don’t use naive RGB mean for luminance. (r + g + b) / 3 is a
common shortcut that gives the wrong answer. Use
relative_luminance — it’s already written and it’s cheap.