Skip to main content

max / makenotwork

theme-common 0.6.0: perceptual (OKLab) derivations + WCAG contrast Grounds the derived interactive states in research on legible, quickly- comprehensible color: - Hover/active/selection/status-surfaces/row-stripe/border-strong are now computed in OKLab (Ottosson 2020, perceptually uniform) instead of sRGB, so equal steps look equal across every theme's hues. - content-on-action is picked by the WCAG 2.x contrast ratio (readable_on), not a naive luminance threshold, so text on accents meets AA where achievable. - OKLab + WCAG implemented in-house (no new dependency). Authored intent set unchanged (kept surface.overlay: it's a distinct, non-derivable level GoingsOn/Balanced Breakfast use, and Carbon/Material both support 4 surface layers). MNW style.css default intent block regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-13 01:26 UTC
Commit: 55e3c2f357235415b1d8b6e32e1caafe3b552443
Parent: 6e84ecf
5 files changed, +149 insertions, -68 deletions
@@ -7059,7 +7059,7 @@ checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
7059 7059
7060 7060 [[package]]
7061 7061 name = "theme-common"
7062 - version = "0.5.0"
7062 + version = "0.6.0"
7063 7063 dependencies = [
7064 7064 "serde",
7065 7065 "toml",
@@ -156,21 +156,21 @@
156 156 --content-muted: #8a8480;
157 157 --content-on-action: #ffffff;
158 158 --action: #6c5ce7;
159 - --action-hover: #7b6ce9;
160 - --action-active: #6153d0;
159 + --action-hover: #7a6cf8;
160 + --action-active: #5f4cd6;
161 161 --danger: #c0392b;
162 162 --success: #27ae60;
163 163 --warning: #f59e0b;
164 164 --info: #17a2b8;
165 - --danger-surface: #e8d3cb;
166 - --success-surface: #d5e1d2;
167 - --warning-surface: #eedfc7;
168 - --info-surface: #d3e0dc;
165 + --danger-surface: #eacfc5;
166 + --success-surface: #d4e0cd;
167 + --warning-surface: #efdec9;
168 + --info-surface: #d3dedb;
169 169 --border: #d0cbb8;
170 - --border-strong: #bbb7a6;
170 + --border-strong: #c0bba8;
171 171 --focus-ring: #6c5ce7;
172 - --selection: #c6bee3;
173 - --row-stripe: #efeae4;
172 + --selection: #d0cee6;
173 + --row-stripe: #f0ece6;
174 174 --hover-surface: #ddd7c5;
175 175 --category-one: #c0392b;
176 176 --category-two: #27ae60;
@@ -273,7 +273,7 @@ dependencies = [
273 273
274 274 [[package]]
275 275 name = "theme-common"
276 - version = "0.5.0"
276 + version = "0.6.0"
277 277 dependencies = [
278 278 "serde",
279 279 "tempfile",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "theme-common"
3 - version = "0.5.0"
3 + version = "0.6.0"
4 4 edition = "2024"
5 5
6 6 [dependencies]
@@ -61,7 +61,13 @@ pub struct ThemeColors {
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,37 +107,94 @@ impl Rgb {
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,24 +268,25 @@ pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
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,7 +294,7 @@ pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
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,34 +666,51 @@ mod tests {
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,14 +786,14 @@ six = "#88c0d0"
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,7 +809,7 @@ six = "#88c0d0"
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]