| 61 |
61 |
|
}
|
| 62 |
62 |
|
|
| 63 |
63 |
|
// ============================================================================
|
| 64 |
|
- |
// Color math (single source of truth for the four derivation ops)
|
|
64 |
+ |
// Color math — perceptual (OKLab) derivations + WCAG contrast.
|
|
65 |
+ |
//
|
|
66 |
+ |
// Interactive states (hover/active/selection/surfaces) are derived in OKLab so
|
|
67 |
+ |
// equal steps look equal across every theme's hues (Ottosson 2020; the modern
|
|
68 |
+ |
// CIELAB). Text-on-color is picked by the WCAG 2.x contrast ratio, not a naive
|
|
69 |
+ |
// luminance threshold, so the choice actually meets AA where achievable.
|
|
70 |
+ |
// This is the single source of truth shared by every product.
|
| 65 |
71 |
|
// ============================================================================
|
| 66 |
72 |
|
|
| 67 |
73 |
|
/// An sRGB color. Hex round-trips losslessly.
|
| 101 |
107 |
|
}
|
| 102 |
108 |
|
}
|
| 103 |
109 |
|
|
| 104 |
|
- |
/// ITU-R BT.601 relative luminance in [0,1].
|
| 105 |
|
- |
fn luminance(c: Rgb) -> f32 {
|
| 106 |
|
- |
(0.299 * c.r as f32 + 0.587 * c.g as f32 + 0.114 * c.b as f32) / 255.0
|
|
110 |
+ |
/// A color in OKLab (perceptually uniform): `l` lightness in [0,1], `a`/`b` opponent axes.
|
|
111 |
+ |
#[derive(Clone, Copy, Debug)]
|
|
112 |
+ |
pub struct Oklab {
|
|
113 |
+ |
pub l: f32,
|
|
114 |
+ |
pub a: f32,
|
|
115 |
+ |
pub b: f32,
|
| 107 |
116 |
|
}
|
| 108 |
117 |
|
|
| 109 |
|
- |
/// Black or white, whichever contrasts with `bg`. Matches BB `contrastColor`
|
| 110 |
|
- |
/// and AF `contrast_color` (luminance > 0.5 → black).
|
| 111 |
|
- |
pub fn contrast_color(bg: Rgb) -> Rgb {
|
| 112 |
|
- |
if luminance(bg) > 0.5 {
|
| 113 |
|
- |
Rgb { r: 0, g: 0, b: 0 }
|
| 114 |
|
- |
} else {
|
| 115 |
|
- |
Rgb { r: 255, g: 255, b: 255 }
|
|
118 |
+ |
fn srgb_to_linear(c: u8) -> f32 {
|
|
119 |
+ |
let c = c as f32 / 255.0;
|
|
120 |
+ |
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
|
121 |
+ |
}
|
|
122 |
+ |
|
|
123 |
+ |
fn linear_to_srgb(c: f32) -> u8 {
|
|
124 |
+ |
let c = c.clamp(0.0, 1.0);
|
|
125 |
+ |
let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 };
|
|
126 |
+ |
(v * 255.0).round().clamp(0.0, 255.0) as u8
|
|
127 |
+ |
}
|
|
128 |
+ |
|
|
129 |
+ |
impl Rgb {
|
|
130 |
+ |
/// Convert to OKLab (Ottosson's sRGB matrices).
|
|
131 |
+ |
pub fn to_oklab(self) -> Oklab {
|
|
132 |
+ |
let (r, g, b) = (srgb_to_linear(self.r), srgb_to_linear(self.g), srgb_to_linear(self.b));
|
|
133 |
+ |
let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
134 |
+ |
let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
135 |
+ |
let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
136 |
+ |
let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
|
|
137 |
+ |
Oklab {
|
|
138 |
+ |
l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
|
|
139 |
+ |
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
|
|
140 |
+ |
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
|
|
141 |
+ |
}
|
|
142 |
+ |
}
|
|
143 |
+ |
|
|
144 |
+ |
/// Convert from OKLab back to the nearest in-gamut sRGB.
|
|
145 |
+ |
pub fn from_oklab(c: Oklab) -> Rgb {
|
|
146 |
+ |
let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b;
|
|
147 |
+ |
let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b;
|
|
148 |
+ |
let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b;
|
|
149 |
+ |
let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
|
|
150 |
+ |
Rgb {
|
|
151 |
+ |
r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
|
152 |
+ |
g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
|
153 |
+ |
b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
|
|
154 |
+ |
}
|
| 116 |
155 |
|
}
|
| 117 |
156 |
|
}
|
| 118 |
157 |
|
|
| 119 |
|
- |
/// Lighten each channel toward white by `pct` percent. Matches BB `lighten`.
|
| 120 |
|
- |
pub fn lighten(c: Rgb, pct: f32) -> Rgb {
|
| 121 |
|
- |
let f = |ch: u8| (ch as f32 + (255.0 - ch as f32) * (pct / 100.0)).round().clamp(0.0, 255.0) as u8;
|
| 122 |
|
- |
Rgb { r: f(c.r), g: f(c.g), b: f(c.b) }
|
|
158 |
+ |
/// WCAG 2.x relative luminance of an sRGB color.
|
|
159 |
+ |
fn rel_luminance(c: Rgb) -> f32 {
|
|
160 |
+ |
0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
|
|
161 |
+ |
}
|
|
162 |
+ |
|
|
163 |
+ |
/// WCAG 2.x contrast ratio between two colors, in [1, 21].
|
|
164 |
+ |
pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 {
|
|
165 |
+ |
let (la, lb) = (rel_luminance(a), rel_luminance(b));
|
|
166 |
+ |
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
|
167 |
+ |
(hi + 0.05) / (lo + 0.05)
|
| 123 |
168 |
|
}
|
| 124 |
169 |
|
|
| 125 |
|
- |
/// Darken each channel toward black by `pct` percent. Matches BB `darken`.
|
| 126 |
|
- |
pub fn darken(c: Rgb, pct: f32) -> Rgb {
|
| 127 |
|
- |
let f = |ch: u8| (ch as f32 * (1.0 - pct / 100.0)).round().clamp(0.0, 255.0) as u8;
|
| 128 |
|
- |
Rgb { r: f(c.r), g: f(c.g), b: f(c.b) }
|
|
170 |
+ |
/// Pick black or white for legible text on `bg`, by the higher WCAG contrast
|
|
171 |
+ |
/// ratio (so the choice meets AA wherever the background allows it).
|
|
172 |
+ |
pub fn readable_on(bg: Rgb) -> Rgb {
|
|
173 |
+ |
let white = Rgb { r: 255, g: 255, b: 255 };
|
|
174 |
+ |
let black = Rgb { r: 0, g: 0, b: 0 };
|
|
175 |
+ |
if wcag_contrast(white, bg) >= wcag_contrast(black, bg) { white } else { black }
|
| 129 |
176 |
|
}
|
| 130 |
177 |
|
|
| 131 |
|
- |
/// Linear blend from `a` to `b` by `t` in [0,1], per channel. Matches AF `lerp_color`.
|
| 132 |
|
- |
pub fn lerp(a: Rgb, b: Rgb, t: f32) -> Rgb {
|
| 133 |
|
- |
let f = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round().clamp(0.0, 255.0) as u8;
|
| 134 |
|
- |
Rgb { r: f(a.r, b.r), g: f(a.g, b.g), b: f(a.b, b.b) }
|
|
178 |
+ |
/// Shift OKLab lightness by `delta` (perceptually uniform). Positive lightens.
|
|
179 |
+ |
pub fn lighten(c: Rgb, delta: f32) -> Rgb {
|
|
180 |
+ |
let mut lab = c.to_oklab();
|
|
181 |
+ |
lab.l = (lab.l + delta).clamp(0.0, 1.0);
|
|
182 |
+ |
Rgb::from_oklab(lab)
|
|
183 |
+ |
}
|
|
184 |
+ |
|
|
185 |
+ |
/// Shift OKLab lightness down by `delta` (perceptually uniform).
|
|
186 |
+ |
pub fn darken(c: Rgb, delta: f32) -> Rgb {
|
|
187 |
+ |
lighten(c, -delta)
|
|
188 |
+ |
}
|
|
189 |
+ |
|
|
190 |
+ |
/// Interpolate between `a` and `b` by `t` in [0,1] in OKLab (perceptual blend).
|
|
191 |
+ |
pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb {
|
|
192 |
+ |
let (x, y) = (a.to_oklab(), b.to_oklab());
|
|
193 |
+ |
Rgb::from_oklab(Oklab {
|
|
194 |
+ |
l: x.l + (y.l - x.l) * t,
|
|
195 |
+ |
a: x.a + (y.a - x.a) * t,
|
|
196 |
+ |
b: x.b + (y.b - x.b) * t,
|
|
197 |
+ |
})
|
| 135 |
198 |
|
}
|
| 136 |
199 |
|
|
| 137 |
200 |
|
// ============================================================================
|
| 205 |
268 |
|
// Helper: parse an already-resolved token to Rgb.
|
| 206 |
269 |
|
let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));
|
| 207 |
270 |
|
|
| 208 |
|
- |
// 2. Derived intents.
|
|
271 |
+ |
// 2. Derived intents — perceptual (OKLab) steps + WCAG-picked text.
|
|
272 |
+ |
// Lightness deltas are in OKLab L units; mix ratios interpolate in OKLab.
|
| 209 |
273 |
|
let mut derived: Vec<(String, Rgb)> = Vec::new();
|
| 210 |
274 |
|
if let Some(action) = get(&intents, "action") {
|
| 211 |
|
- |
derived.push(("action-hover".into(), lighten(action, 10.0)));
|
| 212 |
|
- |
derived.push(("action-active".into(), darken(action, 10.0)));
|
| 213 |
|
- |
derived.push(("content-on-action".into(), contrast_color(action)));
|
|
275 |
+ |
derived.push(("action-hover".into(), lighten(action, 0.05)));
|
|
276 |
+ |
derived.push(("action-active".into(), darken(action, 0.05)));
|
|
277 |
+ |
derived.push(("content-on-action".into(), readable_on(action)));
|
| 214 |
278 |
|
derived.push(("focus-ring".into(), action));
|
| 215 |
279 |
|
if let Some(page) = get(&intents, "surface-page") {
|
| 216 |
|
- |
derived.push(("selection".into(), lerp(page, action, 0.3)));
|
|
280 |
+ |
derived.push(("selection".into(), mix(page, action, 0.2)));
|
| 217 |
281 |
|
}
|
| 218 |
282 |
|
}
|
| 219 |
283 |
|
if let Some(page) = get(&intents, "surface-page") {
|
| 220 |
284 |
|
if let Some(overlay) = get(&intents, "surface-overlay") {
|
| 221 |
|
- |
derived.push(("row-stripe".into(), lerp(page, overlay, 0.3)));
|
|
285 |
+ |
derived.push(("row-stripe".into(), mix(page, overlay, 0.5)));
|
| 222 |
286 |
|
}
|
| 223 |
287 |
|
for status in ["danger", "success", "warning", "info"] {
|
| 224 |
288 |
|
if let Some(c) = get(&intents, status) {
|
| 225 |
|
- |
derived.push((format!("{status}-surface"), lerp(page, c, 0.12)));
|
|
289 |
+ |
derived.push((format!("{status}-surface"), mix(page, c, 0.15)));
|
| 226 |
290 |
|
}
|
| 227 |
291 |
|
}
|
| 228 |
292 |
|
}
|
| 230 |
294 |
|
derived.push(("hover-surface".into(), sunken));
|
| 231 |
295 |
|
}
|
| 232 |
296 |
|
if let Some(border) = get(&intents, "border") {
|
| 233 |
|
- |
derived.push(("border-strong".into(), darken(border, 10.0)));
|
|
297 |
+ |
derived.push(("border-strong".into(), darken(border, 0.05)));
|
| 234 |
298 |
|
}
|
| 235 |
299 |
|
|
| 236 |
300 |
|
for (token, rgb) in derived {
|
| 602 |
666 |
|
}
|
| 603 |
667 |
|
|
| 604 |
668 |
|
#[test]
|
| 605 |
|
- |
fn contrast_picks_black_on_light_white_on_dark() {
|
| 606 |
|
- |
assert_eq!(contrast_color(Rgb { r: 255, g: 255, b: 255 }), Rgb { r: 0, g: 0, b: 0 });
|
| 607 |
|
- |
assert_eq!(contrast_color(Rgb { r: 0, g: 0, b: 0 }), Rgb { r: 255, g: 255, b: 255 });
|
|
669 |
+ |
fn oklab_roundtrips_within_tolerance() {
|
|
670 |
+ |
for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] {
|
|
671 |
+ |
let c = Rgb::from_hex(hex).unwrap();
|
|
672 |
+ |
let back = Rgb::from_oklab(c.to_oklab());
|
|
673 |
+ |
// Gamut round-trip is near-exact (±1 per channel from rounding).
|
|
674 |
+ |
assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r");
|
|
675 |
+ |
assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g");
|
|
676 |
+ |
assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b");
|
|
677 |
+ |
}
|
| 608 |
678 |
|
}
|
| 609 |
679 |
|
|
| 610 |
680 |
|
#[test]
|
| 611 |
|
- |
fn lighten_matches_bb_formula() {
|
| 612 |
|
- |
// BB: Math.round(ch + (255-ch)*pct/100). #6196ff @10% -> per channel.
|
| 613 |
|
- |
let c = Rgb::from_hex("#6196ff").unwrap();
|
| 614 |
|
- |
let r = lighten(c, 10.0);
|
| 615 |
|
- |
assert_eq!(r.r, (0x61 as f32 + (255.0 - 0x61 as f32) * 0.1).round() as u8);
|
| 616 |
|
- |
assert_eq!(r.b, 255); // 0xff stays 255
|
|
681 |
+ |
fn wcag_contrast_known_pairs() {
|
|
682 |
+ |
let white = Rgb { r: 255, g: 255, b: 255 };
|
|
683 |
+ |
let black = Rgb { r: 0, g: 0, b: 0 };
|
|
684 |
+ |
assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01);
|
|
685 |
+ |
assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01);
|
| 617 |
686 |
|
}
|
| 618 |
687 |
|
|
| 619 |
688 |
|
#[test]
|
| 620 |
|
- |
fn darken_matches_bb_formula() {
|
| 621 |
|
- |
let c = Rgb::from_hex("#a0a0a0").unwrap();
|
| 622 |
|
- |
let r = darken(c, 10.0);
|
| 623 |
|
- |
assert_eq!(r.r, (160.0_f32 * 0.9).round() as u8); // 144
|
|
689 |
+ |
fn readable_on_picks_by_wcag() {
|
|
690 |
+ |
assert_eq!(readable_on(Rgb { r: 255, g: 255, b: 255 }), Rgb { r: 0, g: 0, b: 0 });
|
|
691 |
+ |
assert_eq!(readable_on(Rgb { r: 0, g: 0, b: 0 }), Rgb { r: 255, g: 255, b: 255 });
|
|
692 |
+ |
// A light blue action -> black text reads better.
|
|
693 |
+ |
let action = Rgb::from_hex("#6196ff").unwrap();
|
|
694 |
+ |
assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 });
|
|
695 |
+ |
}
|
|
696 |
+ |
|
|
697 |
+ |
#[test]
|
|
698 |
+ |
fn lighten_darken_move_oklab_lightness() {
|
|
699 |
+ |
let c = Rgb::from_hex("#6196ff").unwrap();
|
|
700 |
+ |
let l0 = c.to_oklab().l;
|
|
701 |
+ |
assert!(lighten(c, 0.05).to_oklab().l > l0);
|
|
702 |
+ |
assert!(darken(c, 0.05).to_oklab().l < l0);
|
| 624 |
703 |
|
}
|
| 625 |
704 |
|
|
| 626 |
705 |
|
#[test]
|
| 627 |
|
- |
fn lerp_matches_af_formula() {
|
| 628 |
|
- |
let a = Rgb { r: 0, g: 0, b: 0 };
|
| 629 |
|
- |
let b = Rgb { r: 100, g: 200, b: 50 };
|
| 630 |
|
- |
assert_eq!(lerp(a, b, 0.3), Rgb { r: 30, g: 60, b: 15 });
|
| 631 |
|
- |
assert_eq!(lerp(a, b, 0.0), a);
|
| 632 |
|
- |
assert_eq!(lerp(a, b, 1.0), b);
|
|
706 |
+ |
fn mix_endpoints_and_midpoint() {
|
|
707 |
+ |
let a = Rgb::from_hex("#000000").unwrap();
|
|
708 |
+ |
let b = Rgb::from_hex("#6196ff").unwrap();
|
|
709 |
+ |
assert_eq!(mix(a, b, 0.0), a);
|
|
710 |
+ |
assert_eq!(mix(a, b, 1.0), b);
|
|
711 |
+ |
// Midpoint sits between the endpoints in OKLab lightness.
|
|
712 |
+ |
let mid = mix(a, b, 0.5).to_oklab().l;
|
|
713 |
+ |
assert!(mid > a.to_oklab().l && mid < b.to_oklab().l);
|
| 633 |
714 |
|
}
|
| 634 |
715 |
|
|
| 635 |
716 |
|
// ---- extract + resolve ----
|
| 705 |
786 |
|
let t = resolve(&theme);
|
| 706 |
787 |
|
let action = Rgb::from_hex("#81a1c1").unwrap();
|
| 707 |
788 |
|
let page = Rgb::from_hex("#2e3440").unwrap();
|
| 708 |
|
- |
assert_eq!(t.hex("action-hover").unwrap(), lighten(action, 10.0).to_hex());
|
| 709 |
|
- |
assert_eq!(t.hex("action-active").unwrap(), darken(action, 10.0).to_hex());
|
| 710 |
|
- |
assert_eq!(t.hex("content-on-action").unwrap(), contrast_color(action).to_hex());
|
|
789 |
+ |
assert_eq!(t.hex("action-hover").unwrap(), lighten(action, 0.05).to_hex());
|
|
790 |
+ |
assert_eq!(t.hex("action-active").unwrap(), darken(action, 0.05).to_hex());
|
|
791 |
+ |
assert_eq!(t.hex("content-on-action").unwrap(), readable_on(action).to_hex());
|
| 711 |
792 |
|
assert_eq!(t.hex("focus-ring"), Some("#81a1c1"));
|
| 712 |
|
- |
assert_eq!(t.hex("selection").unwrap(), lerp(page, action, 0.3).to_hex());
|
|
793 |
+ |
assert_eq!(t.hex("selection").unwrap(), mix(page, action, 0.2).to_hex());
|
| 713 |
794 |
|
assert_eq!(t.hex("hover-surface"), Some("#434c5e")); // = surface.sunken
|
| 714 |
795 |
|
assert_eq!(t.hex("danger-surface").unwrap(),
|
| 715 |
|
- |
lerp(page, Rgb::from_hex("#bf616a").unwrap(), 0.12).to_hex());
|
|
796 |
+ |
mix(page, Rgb::from_hex("#bf616a").unwrap(), 0.15).to_hex());
|
| 716 |
797 |
|
}
|
| 717 |
798 |
|
|
| 718 |
799 |
|
#[test]
|
| 728 |
809 |
|
assert!(t.hex("action").is_none());
|
| 729 |
810 |
|
assert!(t.hex("action-hover").is_none());
|
| 730 |
811 |
|
assert!(t.hex("selection").is_none());
|
| 731 |
|
- |
assert_eq!(t.hex("border-strong").unwrap(), darken(Rgb::from_hex("#222222").unwrap(), 10.0).to_hex());
|
|
812 |
+ |
assert_eq!(t.hex("border-strong").unwrap(), darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex());
|
| 732 |
813 |
|
}
|
| 733 |
814 |
|
|
| 734 |
815 |
|
#[test]
|