| 1 | //! WCAG relative-luminance and contrast enforcement. |
| 2 | //! |
| 3 | //! The palette's dark→light adaptation ([`super::adapt`]) is an equality |
| 4 | //! whitelist: a token that isn't literally listed passes through unchanged. |
| 5 | //! That is fine when the surface is the one the tokens were tuned for, and |
| 6 | //! illegible when it isn't — a near-white body token landing on a near-white |
| 7 | //! terminal background (#4833). |
| 8 | //! |
| 9 | //! This module is the enumeration-independent backstop. It works on the pair |
| 10 | //! that actually matters — *resolved foreground* and *effective surface* — and |
| 11 | //! lifts any foreground that falls under the floor, whether or not anyone |
| 12 | //! remembered to add it to a whitelist. |
| 13 | //! |
| 14 | //! Everything here is a pure function over colors, so contrast can be asserted |
| 15 | //! in unit tests without a terminal. |
| 16 | |
| 17 | use ratatui::style::Color; |
| 18 | |
| 19 | use super::adapt::blend; |
| 20 | use super::themes::UiTheme; |
| 21 | |
| 22 | /// WCAG 2.x AA contrast floor for body text. Applied to every resolved |
| 23 | /// foreground we can reason about. |
| 24 | pub const AA_BODY_CONTRAST: f32 = 4.5; |
| 25 | |
| 26 | /// Contrast floor for secondary chrome: hint/dim text and status roles. |
| 27 | /// Matches the WCAG 2.x AA threshold for large text and UI components (3:1). |
| 28 | /// Status roles qualify because they are redundant by design — every status |
| 29 | /// also carries a glyph and a word label, so color is never the only channel. |
| 30 | /// |
| 31 | /// Consumed by the theme audit below, which runs as a test gate rather than |
| 32 | /// at runtime — hence the `dead_code` allowance on this audit surface. |
| 33 | #[allow(dead_code)] |
| 34 | pub const SECONDARY_CHROME_CONTRAST: f32 = 3.0; |
| 35 | |
| 36 | /// Relative luminance per WCAG 2.x, in `0.0..=1.0`. |
| 37 | /// |
| 38 | /// Returns `None` for colors whose true RGB we cannot know: |
| 39 | /// - [`Color::Reset`] — the terminal decides. |
| 40 | /// - Named ANSI colors and `Indexed(0..=15)` — remapped by the user's terminal |
| 41 | /// profile, so any RGB we assumed would be a guess. |
| 42 | /// |
| 43 | /// `Indexed(16..=255)` is resolvable: the 6x6x6 cube and the grayscale ramp are |
| 44 | /// fixed by the xterm specification, not user-configurable. |
| 45 | #[must_use] |
| 46 | pub fn relative_luminance(color: Color) -> Option<f32> { |
| 47 | let (r, g, b) = resolvable_rgb(color)?; |
| 48 | Some(luminance_rgb(r, g, b)) |
| 49 | } |
| 50 | |
| 51 | /// The RGB triple a color is *known* to render as, or `None` when the terminal |
| 52 | /// owns that decision. See [`relative_luminance`]. |
| 53 | #[must_use] |
| 54 | pub fn resolvable_rgb(color: Color) -> Option<(u8, u8, u8)> { |
| 55 | match color { |
| 56 | Color::Rgb(r, g, b) => Some((r, g, b)), |
| 57 | Color::Indexed(index) if index >= 16 => Some(indexed_rgb(index)), |
| 58 | _ => None, |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | fn luminance_rgb(r: u8, g: u8, b: u8) -> f32 { |
| 63 | fn channel(value: u8) -> f32 { |
| 64 | let c = f32::from(value) / 255.0; |
| 65 | if c <= 0.03928 { |
| 66 | c / 12.92 |
| 67 | } else { |
| 68 | ((c + 0.055) / 1.055).powf(2.4) |
| 69 | } |
| 70 | } |
| 71 | 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) |
| 72 | } |
| 73 | |
| 74 | /// Contrast ratio between two relative luminances, in `1.0..=21.0`. |
| 75 | #[must_use] |
| 76 | pub fn contrast_from_luminance(a: f32, b: f32) -> f32 { |
| 77 | let (hi, lo) = if a >= b { (a, b) } else { (b, a) }; |
| 78 | (hi + 0.05) / (lo + 0.05) |
| 79 | } |
| 80 | |
| 81 | /// WCAG contrast ratio between two colors, or `None` if either side is |
| 82 | /// terminal-defined (see [`relative_luminance`]). |
| 83 | #[must_use] |
| 84 | pub fn contrast_ratio(fg: Color, bg: Color) -> Option<f32> { |
| 85 | Some(contrast_from_luminance( |
| 86 | relative_luminance(fg)?, |
| 87 | relative_luminance(bg)?, |
| 88 | )) |
| 89 | } |
| 90 | |
| 91 | /// `true` when the pair is known to clear `min_ratio`. An unknowable pair is |
| 92 | /// *not* reported as passing — callers use this to decide whether to intervene, |
| 93 | /// and we only intervene on evidence. |
| 94 | #[must_use] |
| 95 | pub fn meets_contrast(fg: Color, bg: Color, min_ratio: f32) -> bool { |
| 96 | contrast_ratio(fg, bg).is_some_and(|ratio| ratio >= min_ratio) |
| 97 | } |
| 98 | |
| 99 | /// Lift `fg` until it clears `min_ratio` against `surface`, preserving hue as |
| 100 | /// far as the floor allows. |
| 101 | /// |
| 102 | /// Returns `fg` unchanged when: |
| 103 | /// - the pair already clears the floor, |
| 104 | /// - either side is terminal-defined (we refuse to rewrite colors whose |
| 105 | /// rendering the user's terminal profile owns — that is what the `Terminal` |
| 106 | /// theme is *for*), |
| 107 | /// - or `fg` is not [`Color::Rgb`], since blending an indexed color would |
| 108 | /// silently opt it out of the depth-adaptation stage. |
| 109 | /// |
| 110 | /// Otherwise the color is blended toward whichever pole (black or white) has |
| 111 | /// more contrast headroom against the surface, by the smallest amount that |
| 112 | /// satisfies the floor. Blending is monotonic in luminance, so a bisection |
| 113 | /// finds that minimum. If even the pole cannot reach `min_ratio` — a |
| 114 | /// mid-luminance surface — the pole is returned as the best available. |
| 115 | #[must_use] |
| 116 | pub fn enforce_contrast(fg: Color, surface: Color, min_ratio: f32) -> Color { |
| 117 | if !matches!(fg, Color::Rgb(..)) { |
| 118 | return fg; |
| 119 | } |
| 120 | let (Some(fg_luma), Some(bg_luma)) = (relative_luminance(fg), relative_luminance(surface)) |
| 121 | else { |
| 122 | return fg; |
| 123 | }; |
| 124 | if contrast_from_luminance(fg_luma, bg_luma) >= min_ratio { |
| 125 | return fg; |
| 126 | } |
| 127 | |
| 128 | const BLACK: Color = Color::Rgb(0, 0, 0); |
| 129 | const WHITE: Color = Color::Rgb(255, 255, 255); |
| 130 | let black_ratio = contrast_from_luminance(0.0, bg_luma); |
| 131 | let white_ratio = contrast_from_luminance(1.0, bg_luma); |
| 132 | let (pole, pole_ratio) = if white_ratio >= black_ratio { |
| 133 | (WHITE, white_ratio) |
| 134 | } else { |
| 135 | (BLACK, black_ratio) |
| 136 | }; |
| 137 | if pole_ratio < min_ratio { |
| 138 | return pole; |
| 139 | } |
| 140 | |
| 141 | // Bisect the blend factor: 0.0 keeps `fg`, 1.0 is the pole. Contrast is |
| 142 | // monotonically non-decreasing along this path, so the invariant "lo fails, |
| 143 | // hi passes" holds and converges on the least-shifted compliant color. |
| 144 | let mut lo = 0.0_f32; |
| 145 | let mut hi = 1.0_f32; |
| 146 | let mut best = pole; |
| 147 | for _ in 0..20 { |
| 148 | let mid = f32::midpoint(lo, hi); |
| 149 | let candidate = blend(pole, fg, mid); |
| 150 | if meets_contrast(candidate, surface, min_ratio) { |
| 151 | best = candidate; |
| 152 | hi = mid; |
| 153 | } else { |
| 154 | lo = mid; |
| 155 | } |
| 156 | } |
| 157 | best |
| 158 | } |
| 159 | |
| 160 | /// Pick the surface a foreground is actually drawn on. |
| 161 | /// |
| 162 | /// Cells frequently carry [`Color::Reset`] for the background — meaning "let |
| 163 | /// the terminal show through". In that case the real surface is the terminal's |
| 164 | /// own background, which is exactly what [`super::detect::TerminalBackground`] |
| 165 | /// carries. |
| 166 | /// |
| 167 | /// There is deliberately no theme-surface fallback. The theme surface is what |
| 168 | /// we *intended* to paint; on a `Reset` cell it is precisely what the user is |
| 169 | /// not seeing, and #4833 is what happens when you reason against it. With no |
| 170 | /// painted background and no measurement we return `None` and leave the color |
| 171 | /// alone — declining to act beats acting on a guess. |
| 172 | #[must_use] |
| 173 | pub fn effective_surface(cell_bg: Color, detected_background: Option<Color>) -> Option<Color> { |
| 174 | if resolvable_rgb(cell_bg).is_some() { |
| 175 | return Some(cell_bg); |
| 176 | } |
| 177 | detected_background.filter(|color| resolvable_rgb(*color).is_some()) |
| 178 | } |
| 179 | |
| 180 | /// Whether a cell's symbol carries text, and therefore needs the body-text |
| 181 | /// contrast floor. |
| 182 | /// |
| 183 | /// Box-drawing, block, and geometric-shape glyphs are frame chrome. This |
| 184 | /// palette uses deliberately quiet borders (`BORDER_COLOR` sits at 1.9:1 on the |
| 185 | /// dark stage — a design choice, not a defect), and clamping them to a text |
| 186 | /// floor would rewrite the visual weight of every frame. Blank cells have no |
| 187 | /// foreground to speak of. #4833 is a body-text bug; the floor stays on text. |
| 188 | #[must_use] |
| 189 | pub fn symbol_needs_text_contrast(symbol: &str) -> bool { |
| 190 | symbol.chars().any(|ch| { |
| 191 | !ch.is_whitespace() |
| 192 | && !matches!( |
| 193 | ch, |
| 194 | // Box Drawing, Block Elements, Geometric Shapes, |
| 195 | // Miscellaneous Symbols/Arrows drawing glyphs, Braille. |
| 196 | '\u{2500}'..='\u{259F}' |
| 197 | | '\u{25A0}'..='\u{25FF}' |
| 198 | | '\u{2800}'..='\u{28FF}' |
| 199 | ) |
| 200 | }) |
| 201 | } |
| 202 | |
| 203 | /// RGB for an xterm palette index `>= 16` (6x6x6 cube then grayscale ramp). |
| 204 | fn indexed_rgb(index: u8) -> (u8, u8, u8) { |
| 205 | const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255]; |
| 206 | if index < 232 { |
| 207 | let i = u16::from(index) - 16; |
| 208 | let r = CUBE_LEVELS[(i / 36) as usize]; |
| 209 | let g = CUBE_LEVELS[((i / 6) % 6) as usize]; |
| 210 | let b = CUBE_LEVELS[(i % 6) as usize]; |
| 211 | (r, g, b) |
| 212 | } else { |
| 213 | let level = 8 + 10 * (index - 232); |
| 214 | (level, level, level) |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | /// A single theme color pair that fails its contrast floor. See |
| 219 | /// [`theme_contrast_violations`]. |
| 220 | #[allow(dead_code)] |
| 221 | #[derive(Debug, Clone, Copy, PartialEq)] |
| 222 | pub struct ThemeContrastViolation { |
| 223 | /// Static name of the failing pair, e.g. `"text_muted on panel_bg"`. |
| 224 | pub pair: &'static str, |
| 225 | /// The foreground as shipped by the theme. |
| 226 | pub fg: Color, |
| 227 | /// The surface it sits on. |
| 228 | pub bg: Color, |
| 229 | /// The measured WCAG contrast ratio (always below `floor`). |
| 230 | pub ratio: f32, |
| 231 | /// The floor the pair was audited against. |
| 232 | pub floor: f32, |
| 233 | } |
| 234 | |
| 235 | /// `true` when the theme's surfaces belong to the terminal, not to us. |
| 236 | /// |
| 237 | /// The `Terminal` theme paints [`Color::Reset`] surfaces so the host |
| 238 | /// terminal's own scheme shows through. Those colors are terminal-owned and |
| 239 | /// not enforceable: we cannot know their RGB, so the audit neither passes nor |
| 240 | /// fails them — it stands down. This function is how callers tell that |
| 241 | /// exemption apart from a clean bill of health. |
| 242 | #[allow(dead_code)] |
| 243 | #[must_use] |
| 244 | pub fn theme_uses_terminal_owned_surfaces(theme: &UiTheme) -> bool { |
| 245 | theme.surface_bg == Color::Reset |
| 246 | } |
| 247 | |
| 248 | /// Audit a theme's text and status color pairs against their contrast floors. |
| 249 | /// |
| 250 | /// Pair table and floors: |
| 251 | /// - `text_body`, `text_soft`, `text_muted` on each of `surface_bg`, |
| 252 | /// `panel_bg`, `composer_bg`, `elevated_bg` — [`AA_BODY_CONTRAST`] (4.5:1). |
| 253 | /// - `text_hint` on the same four surfaces, and `text_dim` on `surface_bg` — |
| 254 | /// [`SECONDARY_CHROME_CONTRAST`] (3:1). Hint/dim are secondary chrome: |
| 255 | /// de-emphasized metadata, never the sole carrier of meaning. |
| 256 | /// - `status_ready` / `status_working` / `status_warning` and |
| 257 | /// `warning` / `success` / `info` on `surface_bg` — 3:1, because these |
| 258 | /// roles are redundant: every status is also spelled out by a glyph and a |
| 259 | /// word label, so color is never the only signal. |
| 260 | /// - `text_body` on `selection_bg` — 4.5:1 (the theme picker renders body |
| 261 | /// text on the selection surface). |
| 262 | /// - `diff_added_fg` on `diff_added_bg`, `diff_deleted_fg` on |
| 263 | /// `diff_deleted_bg` — 3:1. |
| 264 | /// - `error_text` on `error_surface` — 4.5:1. |
| 265 | /// |
| 266 | /// Pairs where either color is terminal-defined ([`Color::Reset`], named |
| 267 | /// ANSI, `Indexed(0..=15)`) are *skipped*, not passed: their real RGB is |
| 268 | /// owned by the user's terminal profile and cannot be audited. The |
| 269 | /// `Terminal` theme is therefore largely exempt by design — see |
| 270 | /// [`theme_uses_terminal_owned_surfaces`], which makes that exemption |
| 271 | /// explicit rather than silent. |
| 272 | #[allow(dead_code)] |
| 273 | #[must_use] |
| 274 | pub fn theme_contrast_violations(theme: &UiTheme) -> Vec<ThemeContrastViolation> { |
| 275 | let mut violations = Vec::new(); |
| 276 | let mut check = |pair: &'static str, fg: Color, bg: Color, floor: f32| { |
| 277 | // An unresolvable side means the terminal owns the color: skip the |
| 278 | // pair rather than recording a pass we cannot substantiate. |
| 279 | if let Some(ratio) = contrast_ratio(fg, bg) |
| 280 | && ratio < floor |
| 281 | { |
| 282 | violations.push(ThemeContrastViolation { |
| 283 | pair, |
| 284 | fg, |
| 285 | bg, |
| 286 | ratio, |
| 287 | floor, |
| 288 | }); |
| 289 | } |
| 290 | }; |
| 291 | macro_rules! audit { |
| 292 | ($floor:expr; $(($fg:ident, $bg:ident)),+ $(,)?) => { |
| 293 | $(check( |
| 294 | concat!(stringify!($fg), " on ", stringify!($bg)), |
| 295 | theme.$fg, |
| 296 | theme.$bg, |
| 297 | $floor, |
| 298 | );)+ |
| 299 | }; |
| 300 | } |
| 301 | audit!(AA_BODY_CONTRAST; |
| 302 | (text_body, surface_bg), |
| 303 | (text_body, panel_bg), |
| 304 | (text_body, composer_bg), |
| 305 | (text_body, elevated_bg), |
| 306 | (text_soft, surface_bg), |
| 307 | (text_soft, panel_bg), |
| 308 | (text_soft, composer_bg), |
| 309 | (text_soft, elevated_bg), |
| 310 | (text_muted, surface_bg), |
| 311 | (text_muted, panel_bg), |
| 312 | (text_muted, composer_bg), |
| 313 | (text_muted, elevated_bg), |
| 314 | (text_body, selection_bg), |
| 315 | (error_text, error_surface), |
| 316 | ); |
| 317 | audit!(SECONDARY_CHROME_CONTRAST; |
| 318 | (text_hint, surface_bg), |
| 319 | (text_hint, panel_bg), |
| 320 | (text_hint, composer_bg), |
| 321 | (text_hint, elevated_bg), |
| 322 | (text_dim, surface_bg), |
| 323 | (status_ready, surface_bg), |
| 324 | (status_working, surface_bg), |
| 325 | (status_warning, surface_bg), |
| 326 | (warning, surface_bg), |
| 327 | (success, surface_bg), |
| 328 | (info, surface_bg), |
| 329 | (diff_added_fg, diff_added_bg), |
| 330 | (diff_deleted_fg, diff_deleted_bg), |
| 331 | ); |
| 332 | violations |
| 333 | } |
| 334 |