Improve contrast of built-in themes by leveraging Oklch

This commit is contained in:
Héctor Ramón Jiménez 2025-08-05 09:21:57 +02:00
parent 148fc77b8f
commit 0857eb3bde
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
28 changed files with 124 additions and 116 deletions

View file

@ -90,12 +90,12 @@ impl Color {
} }
} }
Self { Self::new(
r: gamma_component(r), gamma_component(r),
g: gamma_component(g), gamma_component(g),
b: gamma_component(b), gamma_component(b),
a, a,
} )
} }
/// Parses a [`Color`] from a hex string. /// Parses a [`Color`] from a hex string.

View file

@ -28,7 +28,7 @@ impl Palette {
text: Color::BLACK, text: Color::BLACK,
primary: color!(0x5865F2), primary: color!(0x5865F2),
success: color!(0x12664f), success: color!(0x12664f),
warning: color!(0xffc14e), warning: color!(0xb77e33),
danger: color!(0xc3423f), danger: color!(0xc3423f),
}; };
@ -453,9 +453,13 @@ pub struct Background {
/// The weakest version of the base background color. /// The weakest version of the base background color.
pub weakest: Pair, pub weakest: Pair,
/// A weaker version of the base background color. /// A weaker version of the base background color.
pub weaker: Pair,
/// A weak version of the base background color.
pub weak: Pair, pub weak: Pair,
/// A stronger version of the base background color. /// A strong version of the base background color.
pub strong: Pair, pub strong: Pair,
/// A stronger version of the base background color.
pub stronger: Pair,
/// The strongest version of the base background color. /// The strongest version of the base background color.
pub strongest: Pair, pub strongest: Pair,
} }
@ -464,15 +468,19 @@ impl Background {
/// Generates a set of [`Background`] colors from the base and text colors. /// Generates a set of [`Background`] colors from the base and text colors.
pub fn new(base: Color, text: Color) -> Self { pub fn new(base: Color, text: Color) -> Self {
let weakest = deviate(base, 0.03); let weakest = deviate(base, 0.03);
let weak = muted(deviate(base, 0.1)); let weaker = deviate(base, 0.07);
let strong = muted(deviate(base, 0.2)); let weak = deviate(base, 0.1);
let strongest = muted(deviate(base, 0.3)); let strong = deviate(base, 0.15);
let stronger = deviate(base, 0.175);
let strongest = deviate(base, 0.20);
Self { Self {
base: Pair::new(base, text), base: Pair::new(base, text),
weakest: Pair::new(weakest, text), weakest: Pair::new(weakest, text),
weaker: Pair::new(weaker, text),
weak: Pair::new(weak, text), weak: Pair::new(weak, text),
strong: Pair::new(strong, text), strong: Pair::new(strong, text),
stronger: Pair::new(stronger, text),
strongest: Pair::new(strongest, text), strongest: Pair::new(strongest, text),
} }
} }
@ -517,9 +525,11 @@ pub struct Secondary {
impl Secondary { impl Secondary {
/// Generates a set of [`Secondary`] colors from the base and text colors. /// Generates a set of [`Secondary`] colors from the base and text colors.
pub fn generate(base: Color, text: Color) -> Self { pub fn generate(base: Color, text: Color) -> Self {
let base = mix(base, text, 0.2); let bump = if is_dark(base) { 0.0 } else { 0.01 };
let weak = mix(base, text, 0.1);
let strong = mix(base, text, 0.3); let weak = mix(deviate(base, 0.1 + bump), text, 0.4);
let base = mix(deviate(base, 0.3 + bump), text, 0.4);
let strong = mix(deviate(base, 0.5 + bump), text, 0.4);
Self { Self {
base: Pair::new(base, text), base: Pair::new(base, text),
@ -604,53 +614,51 @@ impl Danger {
} }
} }
struct Hsl { struct Oklch {
h: f32,
s: f32,
l: f32, l: f32,
c: f32,
h: f32,
a: f32, a: f32,
} }
fn darken(color: Color, amount: f32) -> Color { fn darken(color: Color, amount: f32) -> Color {
let mut hsl = to_hsl(color); let mut oklch = to_oklch(color);
hsl.l = if hsl.l - amount < 0.0 { // We try to bump the chroma a bit for more colorful palettes
oklch.c *= 1.0 + 2.0 * amount / oklch.l.max(0.05);
oklch.l = if oklch.l - amount < 0.0 {
0.0 0.0
} else { } else {
hsl.l - amount oklch.l - amount
}; };
from_hsl(hsl) from_oklch(oklch)
} }
fn lighten(color: Color, amount: f32) -> Color { fn lighten(color: Color, amount: f32) -> Color {
let mut hsl = to_hsl(color); let mut oklch = to_oklch(color);
hsl.l = if hsl.l + amount > 1.0 { // We try to bump the chroma a bit for more colorful palettes
oklch.c *= 1.0 + 2.0 * amount / oklch.l.max(0.05);
oklch.l = if oklch.l + amount > 1.0 {
1.0 1.0
} else { } else {
hsl.l + amount oklch.l + amount
}; };
from_hsl(hsl) from_oklch(oklch)
} }
fn deviate(color: Color, amount: f32) -> Color { fn deviate(color: Color, amount: f32) -> Color {
if is_dark(color) { if is_dark(color) {
lighten(color, amount) lighten(color, amount)
} else { } else {
darken(color, amount * 0.8) darken(color, amount)
} }
} }
fn muted(color: Color) -> Color {
let mut hsl = to_hsl(color);
hsl.s = hsl.s.min(0.5);
from_hsl(hsl)
}
fn mix(a: Color, b: Color, factor: f32) -> Color { fn mix(a: Color, b: Color, factor: f32) -> Color {
let b_amount = factor.clamp(0.0, 1.0); let b_amount = factor.clamp(0.0, 1.0);
let a_amount = 1.0 - b_amount; let a_amount = 1.0 - b_amount;
@ -680,6 +688,12 @@ fn readable(background: Color, text: Color) -> Color {
return candidate; return candidate;
} }
let candidate = improve(text, 0.2);
if is_readable(background, candidate) {
return candidate;
}
let white_contrast = relative_contrast(background, Color::WHITE); let white_contrast = relative_contrast(background, Color::WHITE);
let black_contrast = relative_contrast(background, Color::BLACK); let black_contrast = relative_contrast(background, Color::BLACK);
@ -691,11 +705,11 @@ fn readable(background: Color, text: Color) -> Color {
} }
fn is_dark(color: Color) -> bool { fn is_dark(color: Color) -> bool {
to_hsl(color).l < 0.6 to_oklch(color).l < 0.6
} }
fn is_readable(a: Color, b: Color) -> bool { fn is_readable(a: Color, b: Color) -> bool {
relative_contrast(a, b) >= 7.0 relative_contrast(a, b) >= 6.0
} }
// https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio // https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
@ -711,65 +725,57 @@ fn relative_luminance(color: Color) -> f32 {
0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
} }
// https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB // https://en.wikipedia.org/wiki/Oklab_color_space#Conversions_between_color_spaces
fn to_hsl(color: Color) -> Hsl { fn to_oklch(color: Color) -> Oklch {
let x_max = color.r.max(color.g).max(color.b); let [r, g, b, alpha] = color.into_linear();
let x_min = color.r.min(color.g).min(color.b);
let c = x_max - x_min;
let l = x_max.midpoint(x_min);
let h = if c == 0.0 { // linear RGB → LMS
0.0 let l = 0.41222146 * r + 0.53633255 * g + 0.051445995 * b;
} else if x_max == color.r { let m = 0.2119035 * r + 0.6806995 * g + 0.10739696 * b;
60.0 * ((color.g - color.b) / c).rem_euclid(6.0) let s = 0.08830246 * r + 0.28171885 * g + 0.6299787 * b;
} else if x_max == color.g {
60.0 * (((color.b - color.r) / c) + 2.0)
} else {
// x_max == color.b
60.0 * (((color.r - color.g) / c) + 4.0)
};
let s = if l == 0.0 || l == 1.0 { // Nonlinear transform (cube root)
0.0 let l_ = l.cbrt();
} else { let m_ = m.cbrt();
(x_max - l) / l.min(1.0 - l) let s_ = s.cbrt();
};
Hsl { // LMS → Oklab
h, let l = 0.21045426 * l_ + 0.7936178 * m_ - 0.004072047 * s_;
s, let a = 1.9779985 * l_ - 2.4285922 * m_ + 0.4505937 * s_;
l, let b = 0.025904037 * l_ + 0.78277177 * m_ - 0.80867577 * s_;
a: color.a,
} // Oklab → Oklch
let c = (a * a + b * b).sqrt();
let h = b.atan2(a); // radians
Oklch { l, c, h, a: alpha }
} }
// https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB // https://en.wikipedia.org/wiki/Oklab_color_space#Conversions_between_color_spaces
fn from_hsl(hsl: Hsl) -> Color { fn from_oklch(oklch: Oklch) -> Color {
let c = (1.0 - (2.0 * hsl.l - 1.0).abs()) * hsl.s; let Oklch { l, c, h, a: alpha } = oklch;
let h = hsl.h / 60.0;
let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs());
let (r1, g1, b1) = if h < 1.0 { let a = c * h.cos();
(c, x, 0.0) let b = c * h.sin();
} else if h < 2.0 {
(x, c, 0.0)
} else if h < 3.0 {
(0.0, c, x)
} else if h < 4.0 {
(0.0, x, c)
} else if h < 5.0 {
(x, 0.0, c)
} else {
// h < 6.0
(c, 0.0, x)
};
let m = hsl.l - (c / 2.0); // Oklab → LMS (nonlinear)
let l_ = l + 0.39633778 * a + 0.21580376 * b;
let m_ = l - 0.105561346 * a - 0.06385417 * b;
let s_ = l - 0.08948418 * a - 1.2914855 * b;
Color { // Cubing back
r: r1 + m, let l = l_ * l_ * l_;
g: g1 + m, let m = m_ * m_ * m_;
b: b1 + m, let s = s_ * s_ * s_;
a: hsl.a,
} let r = 4.0767417 * l - 3.3077116 * m + 0.23096994 * s;
let g = -1.268438 * l + 2.6097574 * m - 0.34131938 * s;
let b = -0.0041960863 * l - 0.7034186 * m + 1.7076147 * s;
Color::from_linear_rgba(
r.clamp(0.0, 1.0),
g.clamp(0.0, 1.0),
b.clamp(0.0, 1.0),
alpha,
)
} }

View file

@ -441,7 +441,7 @@ pub fn primary(theme: &Theme) -> Style {
/// Text conveying some secondary information, like a footnote. /// Text conveying some secondary information, like a footnote.
pub fn secondary(theme: &Theme) -> Style { pub fn secondary(theme: &Theme) -> Style {
Style { Style {
color: Some(theme.extended_palette().secondary.strong.color), color: Some(theme.extended_palette().secondary.base.color),
} }
} }

View file

@ -1 +1 @@
e440e5e00db4fff0a79251aaf493c4af86241196a746d9147a895d14f01b1283 a496cc09220283466d5eb3635f6b63d011ec1dc765726b81213a823825836e4a

View file

@ -1 +1 @@
d81cf7c3974fc5b49251e8287c536fd3477c18e7afd3e9e42619364db7787fcc 661e3f5313b98c65fd0ce9615d9a42201f2b1bafe0285b4a9c5c5af403782452

View file

@ -1 +1 @@
ad4fbda3cc3d60209320dd713b7be6b0a5e6b55650689d70ac563a0a81f2eefc a530ad9b59628d95e9aaa187af2681b26e7219786b5a114848f41690ed25ef21

View file

@ -1 +1 @@
3863252d1f15750d7f5375bd23e8363c6a70d77ac9aac853e3515f7ff5170bce f959b1b8ca2d7f87110e0decf63bf04bb685a8958de6879e008edb40415a84e8

View file

@ -1 +1 @@
c0bc58fcbde8e7ac4447ba4a454c5c2cb2c6d570e873897887c626b2dc07329b 043d53e59e66ed06105abac613ed49c6beaf57f5009e605107cb77307807790b

View file

@ -1 +1 @@
1f17779b3291c133399f1eba9f7e517889108d60dbaa8d5b6cd024ad67ef1c8c f7894c6738710e8054b41c2d958eb50182d9655d8651d1fd2371a9ed05f28ddc

View file

@ -1 +1 @@
51d7bfc1af8ba7503b9800ae7eaaa660047d9b16b0bdc1e8a3850df1d9901652 e0ec3911121e303bdfe3956c72af423ec21a17a958768116ee452adcace02e15

View file

@ -1 +1 @@
5423fa06a1dc73f9cce42447f3134768114e8321c8f2367da1e04db4d4673f52 81e4f065fc985a2c5fc9d15fea941667e914dbc6fe7a5262dac5390955e34f67

View file

@ -1 +1 @@
b7ea69b9ce1f73694cccb8b0bc9acab35fc4dcedd01795e3897215c416550fc2 9d91bfb3504ebf59881ca1554fa63f7293c2750f7ff15a91ff8e86ccf65a0f6f

View file

@ -1 +1 @@
fa43206adec5dce05c4181ec5d9c71d15067dd3b1d7f5b3267c452cf1de2ca95 491041848f77c6e9b70b7e677d0809bff5ceae783326f6dbdd10bdcd45eb56e2

View file

@ -1 +1 @@
5d7445700ff415428ae43d32ef30b279c38ddc6ffd0e3619ea28044b1d8c709b 8209ef9862c56280f711a477843f6d9ab2d1b201ba257f553c9ad2caf0c78731

View file

@ -1 +1 @@
52d424e4dfa3ae9b1195e95f17eb655675e4507ec8fb4e36372612fd57a5d86d 304163accbfcd947b5231beaeb4d8194aab02409df9e334862fe5eb79a835116

View file

@ -1 +1 @@
a0c84b36979f6a6f780af80689425b92a1b68743c39f8e519efcb11b46b9de42 dd82fb5d72db1a33c52bbd27836dbed0c51af5a8bc726c4d73865af779701aba

View file

@ -1 +1 @@
21820cf793f0a3af6fd339132b32cc77cbca52f1ca32f7ae334f74849a046ba8 bc88452bb81a702a8c29c9dde933bf0bdb1723af1c910cb44e56180dc64270b5

View file

@ -1 +1 @@
1ba92e64ddcc5f5fc6f841d5fde961382099e7698a253ac93670b900287ee55c 140ca7cf98bec0d955678b5160e0ccb43d85a829eed62c54f09d5e0b1a401e6f

View file

@ -1 +1 @@
f6559f58f9acae81d929ee1aa5f8db33fca9d4ff49d21acf1e627d891ed77565 e8b2b050896d2b2cfd1c31b2c8773f74b2c23b94b3d94e8f16b8fb6604e8b2f2

View file

@ -1 +1 @@
d3d34b3b02b11156cb9a545d60cba078e78e4124c8f3ae61be66908a42799166 d40514ab3f75e4aa08d8d21e197e5fa486dd0689a4e81027ae335e9abd2ea311

View file

@ -1 +1 @@
3c983866c4feaa10ed34206c1a4c98933e87ba6239849bd7e1b7876f6c9596e7 3ea703ef5d769c6184777bee6caa71e49adc381e1a47f3241f03250ab27b6988

View file

@ -1 +1 @@
dc6fc66e651d938a42f0a9c642a99e33865b87aefc3fdeafa1848f6a3fab20c3 73f01efd56087daa9372aed4b5e5d72d8f5713d75812978bf09a726684ea2b40

View file

@ -1 +1 @@
7104099471f9c712f00cd2ac80e150e10f7d21604d7edc6b13181e74d42eb877 82a173e0c1b9c665fbf06de60acbb0523b7f9ab5af429d5ec29ce908f640d5ec

View file

@ -1 +1 @@
263b5eb190d7db2ab0a67dc8b73778d9130755b07a8295345c758e158fb52881 91d09c382a9d615a1ba68f74463c9a92baba705e35bb84173945d2fa5fd240d0

View file

@ -1 +1 @@
6fd5700c7a63ba004957601e0d91b02e8d08b710cff81710d3b8cd1cb668faa6 dfc3317f39c204b8509c1b8f7e8e1d9b0911bfc1a7bd5fbde822a7a5c6c54e12

View file

@ -718,13 +718,13 @@ pub fn subtle(theme: &Theme, status: Status) -> Style {
Status::Active => base, Status::Active => base,
Status::Pressed => Style { Status::Pressed => Style {
background: Some(Background::Color( background: Some(Background::Color(
palette.background.strongest.color, palette.background.strong.color,
)), )),
..base ..base
}, },
Status::Hovered => Style { Status::Hovered => Style {
background: Some(Background::Color( background: Some(Background::Color(
palette.background.strong.color, palette.background.weaker.color,
)), )),
..base ..base
}, },

View file

@ -1423,7 +1423,7 @@ pub fn default(theme: &Theme, status: Status) -> Style {
color: palette.background.strong.color, color: palette.background.strong.color,
}, },
icon: palette.background.weak.text, icon: palette.background.weak.text,
placeholder: palette.background.strong.color, placeholder: palette.secondary.base.color,
value: palette.background.base.text, value: palette.background.base.text,
selection: palette.primary.weak.color, selection: palette.primary.weak.color,
}; };
@ -1447,6 +1447,7 @@ pub fn default(theme: &Theme, status: Status) -> Style {
Status::Disabled => Style { Status::Disabled => Style {
background: Background::Color(palette.background.weak.color), background: Background::Color(palette.background.weak.color),
value: active.placeholder, value: active.placeholder,
placeholder: palette.background.strongest.color,
..active ..active
}, },
} }

View file

@ -1817,10 +1817,10 @@ pub fn default(theme: &Theme, status: Status) -> Style {
border: Border { border: Border {
radius: 2.0.into(), radius: 2.0.into(),
width: 1.0, width: 1.0,
color: palette.background.strongest.color, color: palette.background.strong.color,
}, },
icon: palette.background.weak.text, icon: palette.background.weak.text,
placeholder: palette.background.strongest.color, placeholder: palette.secondary.base.color,
value: palette.background.base.text, value: palette.background.base.text,
selection: palette.primary.weak.color, selection: palette.primary.weak.color,
}; };
@ -1844,6 +1844,7 @@ pub fn default(theme: &Theme, status: Status) -> Style {
Status::Disabled => Style { Status::Disabled => Style {
background: Background::Color(palette.background.weak.color), background: Background::Color(palette.background.weak.color),
value: active.placeholder, value: active.placeholder,
placeholder: palette.background.strongest.color,
..active ..active
}, },
} }