UI: sharper, higher-contrast text, most visible on Windows (#76)

- Glyphs are rendered on whole pixels: egui's sub-pixel binning draws
  each glyph at quarter-pixel offsets, which it notes makes text
  blurrier, most visibly at the 100-150 % scaling common on Windows.
- Light theme: a mild gamma (0.75) on glyph coverage darkens the
  antialiased edges of dark text, which egui renders linearly (thin and
  grey next to the system's text). The dark theme keeps egui's curve.
- The faintest text colour met only 3.3:1 (light) and 3.9:1 (dark).
  It is now #6B6B73 / #97979E, so every text colour meets WCAG AA
  (4.5:1) on every surface in both themes, enforced by a test.

Compared in headless renders at 1x and 2x: crisper stems at 1x, little
change at 2x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-07 01:50:38 +07:00
co-authored by Claude Opus 5.5
parent 90c56e9ff0
commit 71fcf5be8e
2 changed files with 45 additions and 4 deletions
+43 -2
View File
@@ -55,7 +55,7 @@ impl Tokens {
divider: Color32::from_rgb(0xE8, 0xE8, 0xEB),
text: Color32::from_rgb(0x22, 0x22, 0x26),
text_muted: Color32::from_rgb(0x5E, 0x5E, 0x66),
text_faint: Color32::from_rgb(0x8E, 0x8E, 0x96),
text_faint: Color32::from_rgb(0x6B, 0x6B, 0x73),
icon: Color32::from_rgb(0x44, 0x44, 0x4B),
hover: Color32::from_rgb(0xF0, 0xF0, 0xF3),
pressed: Color32::from_rgb(0xE4, 0xE4, 0xE9),
@@ -79,7 +79,7 @@ impl Tokens {
divider: Color32::from_rgb(0x33, 0x33, 0x39),
text: Color32::from_rgb(0xEC, 0xEC, 0xEF),
text_muted: Color32::from_rgb(0xAE, 0xAE, 0xB6),
text_faint: Color32::from_rgb(0x80, 0x80, 0x89),
text_faint: Color32::from_rgb(0x97, 0x97, 0x9E),
icon: Color32::from_rgb(0xD4, 0xD4, 0xDA),
hover: Color32::from_rgb(0x34, 0x34, 0x3A),
pressed: Color32::from_rgb(0x3E, 0x3E, 0x45),
@@ -180,6 +180,15 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
v.widgets.active.weak_bg_fill = t.pressed;
v.widgets.active.bg_fill = t.pressed;
v.widgets.open.weak_bg_fill = t.hover;
// Crisper text at 100–150 % scaling (#76): glyphs sit on whole pixels instead of being
// rendered at quarter-pixel offsets, which egui notes blurs them. In the light theme, a mild
// gamma darkens the antialiased edges of dark text (egui's default is linear, which reads thin
// and grey next to the system's text); the dark theme keeps egui's own curve. At 200 % both
// make little difference.
v.text_options.subpixel_binning = false;
if !t.dark() {
v.text_options.color_transfer_function = egui::epaint::FontColorTransferFunction::Gamma(0.75);
}
ctx.set_visuals(v);
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(8.0, 6.0);
@@ -194,3 +203,35 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
s.interaction.tooltip_delay = 0.35;
});
}
#[cfg(test)]
mod tests {
use super::*;
/// WCAG 2 contrast ratio between two opaque colours.
fn contrast(a: Color32, b: Color32) -> f32 {
let lum = |c: Color32| {
let lin = |v: u8| {
let s = v as f32 / 255.0;
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
};
0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
};
let (x, y) = (lum(a) + 0.05, lum(b) + 0.05);
x.max(y) / x.min(y)
}
#[test]
fn text_is_readable_on_every_surface() {
// #76: the faintest text (hints, zoom level, captions) was 3.3:1 in the light theme.
for kind in [ThemeKind::Light, ThemeKind::Dark] {
let t = Tokens::for_kind(kind);
for (name, fg) in [("text", t.text), ("text_muted", t.text_muted), ("text_faint", t.text_faint)] {
for bg in [t.chrome, t.panel, t.card, t.pasteboard] {
let r = contrast(fg, bg);
assert!(r >= 4.5, "{kind:?} {name} on {bg:?}: {r:.2}:1, WCAG AA needs 4.5:1");
}
}
}
}
}
+2 -2
View File
@@ -1586,8 +1586,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.theme"]
tools = []
evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply"]
notes = ""
evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/src/theme.rs::text_is_readable_on_every_surface"]
notes = "every text colour meets WCAG AA (4.5:1) on every surface in both themes; glyphs are pixel-aligned for sharper text at 100–150 % scaling"
[[feature]]
id = "view.theme-system"