//! Color math — perceptual (OKLab) derivations + WCAG contrast. //! //! Interactive states (hover/active/selection/surfaces) are derived in OKLab so //! equal steps look equal across every theme's hues (Ottosson 2020; the modern //! CIELAB). Text-on-color is picked by the WCAG 2.x contrast ratio, not a naive //! luminance threshold, so the choice actually meets AA where achievable. //! This is the single source of truth shared by every product. /// An sRGB color. Hex round-trips losslessly. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Rgb { pub r: u8, pub g: u8, pub b: u8, } impl Rgb { /// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise. pub fn from_hex(s: &str) -> Option { let h = s.strip_prefix('#')?; let (r, g, b) = match h.len() { 6 => ( u8::from_str_radix(&h[0..2], 16).ok()?, u8::from_str_radix(&h[2..4], 16).ok()?, u8::from_str_radix(&h[4..6], 16).ok()?, ), 3 => { let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17); (d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?) } _ => return None, }; Some(Rgb { r, g, b }) } /// Lowercase `#rrggbb`. pub fn to_hex(self) -> String { format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) } pub fn tuple(self) -> (u8, u8, u8) { (self.r, self.g, self.b) } } /// A color in OKLab (perceptually uniform): `l` lightness in \[0,1\], `a`/`b` opponent axes. #[derive(Clone, Copy, Debug)] pub struct Oklab { pub l: f32, pub a: f32, pub b: f32, } fn srgb_to_linear(c: u8) -> f32 { let c = c as f32 / 255.0; if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } fn linear_to_srgb(c: f32) -> u8 { let c = c.clamp(0.0, 1.0); let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }; (v * 255.0).round().clamp(0.0, 255.0) as u8 } impl Rgb { /// Convert to OKLab (Ottosson's sRGB matrices). /// /// The matrix coefficients are quoted at their published precision so they /// can be diffed against the reference. `f32` rounds them at compile time; /// truncating the literals would only make them harder to check. #[allow(clippy::excessive_precision)] pub fn to_oklab(self) -> Oklab { let (r, g, b) = ( srgb_to_linear(self.r), srgb_to_linear(self.g), srgb_to_linear(self.b), ); let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt()); Oklab { l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, } } /// Convert from OKLab back to the nearest in-gamut sRGB. /// /// Published precision, as in [`Rgb::to_oklab`]. #[allow(clippy::excessive_precision)] pub fn from_oklab(c: Oklab) -> Rgb { let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b; let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b; let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b; let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_); Rgb { r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), } } } /// WCAG 2.x relative luminance of an sRGB color. pub(crate) fn rel_luminance(c: Rgb) -> f32 { 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b) } /// WCAG 2.x contrast ratio between two colors, in [1, 21]. pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 { let (la, lb) = (rel_luminance(a), rel_luminance(b)); let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; (hi + 0.05) / (lo + 0.05) } /// Pick black or white for legible text on `bg`, by the higher WCAG contrast /// ratio (so the choice meets AA wherever the background allows it). pub fn readable_on(bg: Rgb) -> Rgb { let white = Rgb { r: 255, g: 255, b: 255, }; let black = Rgb { r: 0, g: 0, b: 0 }; if wcag_contrast(white, bg) >= wcag_contrast(black, bg) { white } else { black } } /// Shift OKLab lightness by `delta` (perceptually uniform). Positive lightens. pub fn lighten(c: Rgb, delta: f32) -> Rgb { let mut lab = c.to_oklab(); lab.l = (lab.l + delta).clamp(0.0, 1.0); Rgb::from_oklab(lab) } /// Shift OKLab lightness down by `delta` (perceptually uniform). pub fn darken(c: Rgb, delta: f32) -> Rgb { lighten(c, -delta) } /// Interpolate between `a` and `b` by `t` in \[0,1\] in OKLab (perceptual blend). pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb { let (x, y) = (a.to_oklab(), b.to_oklab()); Rgb::from_oklab(Oklab { l: x.l + (y.l - x.l) * t, a: x.a + (y.a - x.a) * t, b: x.b + (y.b - x.b) * t, }) } #[cfg(test)] mod tests { use super::*; // ---- color math (formulas must match the apps they came from) ---- #[test] fn rgb_hex_roundtrip() { assert_eq!( Rgb::from_hex("#6196FF").unwrap(), Rgb { r: 0x61, g: 0x96, b: 0xff } ); assert_eq!( Rgb::from_hex("#abc").unwrap(), Rgb { r: 0xaa, g: 0xbb, b: 0xcc } ); assert_eq!( Rgb { r: 0x61, g: 0x96, b: 0xff } .to_hex(), "#6196ff" ); assert!(Rgb::from_hex("not-a-color").is_none()); } #[test] fn oklab_roundtrips_within_tolerance() { for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] { let c = Rgb::from_hex(hex).unwrap(); let back = Rgb::from_oklab(c.to_oklab()); // Gamut round-trip is near-exact (±1 per channel from rounding). assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r"); assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g"); assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b"); } } #[test] fn wcag_contrast_known_pairs() { let white = Rgb { r: 255, g: 255, b: 255, }; let black = Rgb { r: 0, g: 0, b: 0 }; assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01); assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01); } #[test] fn readable_on_picks_by_wcag() { assert_eq!( readable_on(Rgb { r: 255, g: 255, b: 255 }), Rgb { r: 0, g: 0, b: 0 } ); assert_eq!( readable_on(Rgb { r: 0, g: 0, b: 0 }), Rgb { r: 255, g: 255, b: 255 } ); // A light blue action -> black text reads better. let action = Rgb::from_hex("#6196ff").unwrap(); assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 }); } #[test] fn lighten_darken_move_oklab_lightness() { let c = Rgb::from_hex("#6196ff").unwrap(); let l0 = c.to_oklab().l; assert!(lighten(c, 0.05).to_oklab().l > l0); assert!(darken(c, 0.05).to_oklab().l < l0); } #[test] fn mix_endpoints_and_midpoint() { let a = Rgb::from_hex("#000000").unwrap(); let b = Rgb::from_hex("#6196ff").unwrap(); assert_eq!(mix(a, b, 0.0), a); assert_eq!(mix(a, b, 1.0), b); // Midpoint sits between the endpoints in OKLab lightness. let mid = mix(a, b, 0.5).to_oklab().l; assert!(mid > a.to_oklab().l && mid < b.to_oklab().l); } }