Skip to main content

max / makeover

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