Contrast checking (WCAG 2.1)
FrankenTUI computes WCAG 2.1 contrast ratios directly from the theme’s packed RGBA color values. The ratios drive:
- the live WCAG checker on the
accessibility_paneldemo screen, - the high-contrast theme toggle that flips palettes when ratios drop below AA,
- and the automated contrast tests that gate regressions during theme work.
The math is the standard WCAG 2.1 §1.4.3 formula: linearize sRGB,
compute relative luminance, then take (lighter + 0.05) / (darker + 0.05).
The implementation lives at
crates/ftui-demo-showcase/src/screens/accessibility_panel.rs:87-106,
with the usage elsewhere in the same screen.
The formula
Given two colors in sRGB, each stored as PackedRgba (the
render cell color type):
Step 1 — normalize each channel to [0, 1]
let r = (rgba.r() as f32) / 255.0;
let g = (rgba.g() as f32) / 255.0;
let b = (rgba.b() as f32) / 255.0;Step 2 — linearize gamma
sRGB is gamma-encoded. WCAG requires computing luminance against linear light:
fn linearize(v: f32) -> f32 {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}The two-piece function matches the sRGB transfer curve exactly.
Step 3 — relative luminance
Weighted by the CIE Y coefficients:
fn luminance(c: PackedRgba) -> f32 {
let r = linearize(c.r() as f32 / 255.0);
let g = linearize(c.g() as f32 / 255.0);
let b = linearize(c.b() as f32 / 255.0);
0.2126 * r + 0.7152 * g + 0.0722 * b
}Step 4 — the ratio
let l1 = luminance(fg);
let l2 = luminance(bg);
let (hi, lo) = if l1 >= l2 { (l1, l2) } else { (l2, l1) };
let ratio = (hi + 0.05) / (lo + 0.05);(x + 0.05) pushes pure black off zero so the ratio is bounded. The
result is a unitless float in [1.0, 21.0] — 1.0 is no contrast, 21.0
is pure black on pure white.
The WCAG rating thresholds
fn wcag_rating(ratio: f32) -> (&'static str, Style) {
if ratio >= 7.0 {
("AAA", Style::new().fg(theme::accent::SUCCESS))
} else if ratio >= 4.5 {
("AA", Style::new().fg(theme::accent::INFO))
} else if ratio >= 3.0 {
("AA Large", Style::new().fg(theme::accent::WARNING))
} else {
("Fail", Style::new().fg(theme::accent::ERROR))
}
}| Ratio | Rating | Meaning |
|---|---|---|
| ≥ 7.0 | AAA | Normal text, enhanced level. |
| ≥ 4.5 | AA | Normal text, minimum level. |
| ≥ 3.0 | AA Large | Text ≥ 18 pt / 14 pt bold. |
| < 3.0 | Fail | Does not meet WCAG contrast. |
The thresholds are the ones the WCAG 2.1 specification defines. The palette
mapping (SUCCESS, INFO, WARNING, ERROR) is the same semantic color
scheme used across the rest of the demo and can be re-skinned without
touching the math.
The accessibility panel screen
The accessibility_panel screen wires the math up to a live
demonstration. The layout is two columns:
- Left — toggles, driven by single-key shortcuts:
h— High Contrast theme toggle.m— Reduced Motion (disables animations).l— Large Text scaling.Shift+A— open a compact a11y overlay.
- Right — WCAG checker, listing every foreground-background pair in the current theme, their computed ratio, and the rating badge.
Toggling high-contrast flips the palette and the checker instantly
recomputes. A failing pair shown as Fail is a visible bug — the theme
has to be fixed.
Telemetry: A11yTelemetryEvent
Each toggle pushes an A11yTelemetryEvent onto a per-screen queue
(MAX_EVENTS = 6) so the demo can display the last few events inline:
struct A11yTelemetryEvent {
kind: A11yEventKind,
tick: u64,
high_contrast: bool,
reduced_motion: bool,
large_text: bool,
}Nothing about the events is WCAG-specific — they are just the screen’s way of showing that toggle changes propagate through the runtime’s state and back out to the accessibility tree.
Where to compute contrast in your own code
The linearization and luminance math is not a public ftui-a11y API yet;
it lives in the demo screen because that is the only in-tree consumer. If
you are writing a theme and want to gate it on contrast:
- Borrow the three short functions (
linearize,luminance,contrast_ratio) into your own module, or - Add them to
ftui-styleas part of theme validation.
A proper home on ftui-style is the right long-term answer — see the
discussion on Style — color. Until then, the demo screen
is the canonical reference.
Example — manual check of a theme pair
use ftui_render::cell::PackedRgba;
fn contrast(fg: PackedRgba, bg: PackedRgba) -> f32 {
// … the four steps from above …
}
let text_on_bg = contrast(
PackedRgba::from_u32(0xFFE6E1E5), // on-surface
PackedRgba::from_u32(0xFF1C1B1F), // surface
);
assert!(text_on_bg >= 4.5, "theme violates AA for normal text");If you want this kind of check in CI, add it as a unit test next to the theme definition — that way a future palette edit that regresses contrast fails the test instead of shipping a failing theme.
Pitfalls
- Do not use the raw sRGB channels for luminance. The gamma decode step is load-bearing. Skipping it yields a value that is plausible but systematically wrong — dark colors read as too-contrasting, light colors as too-flat.
- Do not average the two colors instead of taking the ratio. WCAG is
a ratio, not a delta.
|L1 − L2|and(L1 + 0.05) / (L2 + 0.05)do not correlate well near the middle of the range. - Do not round the ratio before thresholding.
3.0 - epsilonis failing; rounding to3.0pretends it is passing. Compare the float against the threshold directly. - Do not confuse “AAA” with accessibility. AAA contrast is necessary, not sufficient: a screen can pass AAA while being unreadable for a thousand other reasons (no headings, no focus ring, assertive live region spam). Contrast is one axis in a wider accessibility tree story.
See also
- Accessibility tree — the broader a11y model
- Focus graph — focus indication depends on contrast against the active theme
- Bidi / RTL support — RTL scripts still need contrast; the math is the same
- Style — color — where a future first-class API would live
- Demo showcase overview — the
accessibility_panelscreen