Skip to main content

makeover/
color.rs

1//! Color math — perceptual (OKLab) derivations + WCAG contrast.
2//!
3//! Interactive states (hover/active/selection/surfaces) are derived in OKLab so
4//! equal steps look equal across every theme's hues (Ottosson 2020; the modern
5//! CIELAB). Text-on-color is picked by the WCAG 2.x contrast ratio, not a naive
6//! luminance threshold, so the choice actually meets AA where achievable.
7//! This is the single source of truth shared by every product.
8
9/// An sRGB color. Hex round-trips losslessly.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub struct Rgb {
12    pub r: u8,
13    pub g: u8,
14    pub b: u8,
15}
16
17impl Rgb {
18    /// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise.
19    pub fn from_hex(s: &str) -> Option<Rgb> {
20        let h = s.strip_prefix('#')?;
21        // Hex digits only, checked before any slicing: the lengths below are
22        // bytes, so a multibyte character would put a slice mid-character and
23        // panic, and `from_str_radix` alone would take a leading `+`. A theme
24        // is a file someone can import, so this is untrusted input.
25        if !h.bytes().all(|b| b.is_ascii_hexdigit()) {
26            return None;
27        }
28        let (r, g, b) = match h.len() {
29            6 => (
30                u8::from_str_radix(&h[0..2], 16).ok()?,
31                u8::from_str_radix(&h[2..4], 16).ok()?,
32                u8::from_str_radix(&h[4..6], 16).ok()?,
33            ),
34            3 => {
35                let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17);
36                (d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?)
37            }
38            _ => return None,
39        };
40        Some(Rgb { r, g, b })
41    }
42
43    /// Lowercase `#rrggbb`.
44    pub fn to_hex(self) -> String {
45        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
46    }
47
48    pub fn tuple(self) -> (u8, u8, u8) {
49        (self.r, self.g, self.b)
50    }
51}
52
53/// A color in OKLab (perceptually uniform): `l` lightness in \[0,1\], `a`/`b` opponent axes.
54#[derive(Clone, Copy, Debug)]
55pub struct Oklab {
56    pub l: f32,
57    pub a: f32,
58    pub b: f32,
59}
60
61fn srgb_to_linear(c: u8) -> f32 {
62    let c = c as f32 / 255.0;
63    if c <= 0.04045 {
64        c / 12.92
65    } else {
66        ((c + 0.055) / 1.055).powf(2.4)
67    }
68}
69
70fn linear_to_srgb(c: f32) -> u8 {
71    let c = c.clamp(0.0, 1.0);
72    let v = if c <= 0.0031308 {
73        c * 12.92
74    } else {
75        1.055 * c.powf(1.0 / 2.4) - 0.055
76    };
77    (v * 255.0).round().clamp(0.0, 255.0) as u8
78}
79
80impl Rgb {
81    /// Convert to OKLab (Ottosson's sRGB matrices).
82    ///
83    /// The matrix coefficients are quoted at their published precision so they
84    /// can be diffed against the reference. `f32` rounds them at compile time;
85    /// truncating the literals would only make them harder to check.
86    #[allow(clippy::excessive_precision)]
87    pub fn to_oklab(self) -> Oklab {
88        let (r, g, b) = (
89            srgb_to_linear(self.r),
90            srgb_to_linear(self.g),
91            srgb_to_linear(self.b),
92        );
93        let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
94        let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
95        let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
96        let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
97        Oklab {
98            l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
99            a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
100            b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
101        }
102    }
103
104    /// Convert from OKLab back to the nearest in-gamut sRGB.
105    ///
106    /// Published precision, as in [`Rgb::to_oklab`].
107    #[allow(clippy::excessive_precision)]
108    pub fn from_oklab(c: Oklab) -> Rgb {
109        let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b;
110        let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b;
111        let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b;
112        let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
113        Rgb {
114            r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
115            g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
116            b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
117        }
118    }
119}
120
121/// WCAG 2.x relative luminance of an sRGB color.
122pub(crate) fn rel_luminance(c: Rgb) -> f32 {
123    0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
124}
125
126/// WCAG 2.x contrast ratio between two colors, in [1, 21].
127pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 {
128    let (la, lb) = (rel_luminance(a), rel_luminance(b));
129    let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
130    (hi + 0.05) / (lo + 0.05)
131}
132
133/// Pick black or white for legible text on `bg`, by the higher WCAG contrast
134/// ratio (so the choice meets AA wherever the background allows it).
135pub fn readable_on(bg: Rgb) -> Rgb {
136    let white = Rgb {
137        r: 255,
138        g: 255,
139        b: 255,
140    };
141    let black = Rgb { r: 0, g: 0, b: 0 };
142    if wcag_contrast(white, bg) >= wcag_contrast(black, bg) {
143        white
144    } else {
145        black
146    }
147}
148
149/// Shift OKLab lightness by `delta` (perceptually uniform). Positive lightens.
150pub fn lighten(c: Rgb, delta: f32) -> Rgb {
151    let mut lab = c.to_oklab();
152    lab.l = (lab.l + delta).clamp(0.0, 1.0);
153    Rgb::from_oklab(lab)
154}
155
156/// Shift OKLab lightness down by `delta` (perceptually uniform).
157pub fn darken(c: Rgb, delta: f32) -> Rgb {
158    lighten(c, -delta)
159}
160
161/// Interpolate between `a` and `b` by `t` in \[0,1\] in OKLab (perceptual blend).
162pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb {
163    let (x, y) = (a.to_oklab(), b.to_oklab());
164    Rgb::from_oklab(Oklab {
165        l: x.l + (y.l - x.l) * t,
166        a: x.a + (y.a - x.a) * t,
167        b: x.b + (y.b - x.b) * t,
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    // ---- color math (formulas must match the apps they came from) ----
176
177    #[test]
178    fn rgb_hex_roundtrip() {
179        assert_eq!(
180            Rgb::from_hex("#6196FF").unwrap(),
181            Rgb {
182                r: 0x61,
183                g: 0x96,
184                b: 0xff
185            }
186        );
187        assert_eq!(
188            Rgb::from_hex("#abc").unwrap(),
189            Rgb {
190                r: 0xaa,
191                g: 0xbb,
192                b: 0xcc
193            }
194        );
195        assert_eq!(
196            Rgb {
197                r: 0x61,
198                g: 0x96,
199                b: 0xff
200            }
201            .to_hex(),
202            "#6196ff"
203        );
204        assert!(Rgb::from_hex("not-a-color").is_none());
205    }
206
207    #[test]
208    fn hostile_hex_is_refused_rather_than_panicking() {
209        // Six and three bytes, so the length alone would have sliced them.
210        for s in [
211            "#aéfff", "#éa", "#1é234", "#+f+f+f", "#+ff", "#-01020", "# 12345",
212        ] {
213            assert_eq!(Rgb::from_hex(s), None, "{s:?}");
214        }
215    }
216
217    #[test]
218    fn oklab_roundtrips_within_tolerance() {
219        for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] {
220            let c = Rgb::from_hex(hex).unwrap();
221            let back = Rgb::from_oklab(c.to_oklab());
222            // Gamut round-trip is near-exact (±1 per channel from rounding).
223            assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r");
224            assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g");
225            assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b");
226        }
227    }
228
229    #[test]
230    fn wcag_contrast_known_pairs() {
231        let white = Rgb {
232            r: 255,
233            g: 255,
234            b: 255,
235        };
236        let black = Rgb { r: 0, g: 0, b: 0 };
237        assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01);
238        assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01);
239    }
240
241    #[test]
242    fn readable_on_picks_by_wcag() {
243        assert_eq!(
244            readable_on(Rgb {
245                r: 255,
246                g: 255,
247                b: 255
248            }),
249            Rgb { r: 0, g: 0, b: 0 }
250        );
251        assert_eq!(
252            readable_on(Rgb { r: 0, g: 0, b: 0 }),
253            Rgb {
254                r: 255,
255                g: 255,
256                b: 255
257            }
258        );
259        // A light blue action -> black text reads better.
260        let action = Rgb::from_hex("#6196ff").unwrap();
261        assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 });
262    }
263
264    #[test]
265    fn lighten_darken_move_oklab_lightness() {
266        let c = Rgb::from_hex("#6196ff").unwrap();
267        let l0 = c.to_oklab().l;
268        assert!(lighten(c, 0.05).to_oklab().l > l0);
269        assert!(darken(c, 0.05).to_oklab().l < l0);
270    }
271
272    #[test]
273    fn mix_endpoints_and_midpoint() {
274        let a = Rgb::from_hex("#000000").unwrap();
275        let b = Rgb::from_hex("#6196ff").unwrap();
276        assert_eq!(mix(a, b, 0.0), a);
277        assert_eq!(mix(a, b, 1.0), b);
278        // Midpoint sits between the endpoints in OKLab lightness.
279        let mid = mix(a, b, 0.5).to_oklab().l;
280        assert!(mid > a.to_oklab().l && mid < b.to_oklab().l);
281    }
282}