From b596bfc73004f412c90e869326d54700b50ad617 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 29 Oct 2026 22:42:27 -0400 Subject: [PATCH] Bottom bar: keep active toggles readable when the selected fill is as dark as the accent (#9168) (#9171) Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/ui_kit/navigation.rs | 36 ++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ui_kit/navigation.rs b/crates/ui-egui/src/ui_kit/navigation.rs index a3322a85cb..dd13ee40a3 100644 --- a/crates/ui-egui/src/ui_kit/navigation.rs +++ b/crates/ui-egui/src/ui_kit/navigation.rs @@ -101,6 +101,19 @@ pub(crate) fn navigation_fill(t: &Tokens, fill: NavigationFill) -> Option egui::Color32 { + if fill != NavigationFill::Active { + return t.form_ink; + } + if crate::theme::contrast_ratio(t.form_primary, t.selected_bg) >= NAV_ACTIVE_MIN_CONTRAST { t.form_primary } else { t.selected_ink } +} + +/// WCAG small-text contrast for an active slot's label on its selected fill. +pub(crate) const NAV_ACTIVE_MIN_CONTRAST: f64 = 4.5; + pub(crate) fn navigation_opacity(idle: Idle, active: bool) -> f32 { match idle { _ if active => 1.0, @@ -135,7 +148,7 @@ pub(crate) fn navigation_button_editable(ui: &mut Ui, rect: Rect, button: Naviga egui::StrokeKind::Inside, ); } - let ink = if fill == NavigationFill::Active { t.form_primary } else { t.form_ink }; + let ink = navigation_ink(&t, fill); let text = button.labels.then(|| ui.painter().layout_no_wrap(button.label.to_owned(), crate::fonts::medium(NAV_TEXT), ink)); let mut x = if button.menu { rect.left() + NAV_MENU_LEFT + if editing { NAV_BORDER } else { 0.0 } @@ -213,3 +226,24 @@ pub(crate) fn navigation_surface(ui: &Ui, rect: Rect, size: Size, editing: bool) .corner_radius(NavigationMetrics::for_size(size).radius); super::Container::paint_frame(ui.painter(), frame, rect.shrink(NAV_BORDER)); } + +#[cfg(test)] +mod active_ink_tests { + use super::{NavigationFill, navigation_fill, navigation_ink}; + + #[test] + fn active_navigation_slots_stay_readable_in_every_built_in_theme() { + let mut themes = vec![("Dark", crate::theme::Tokens::dark()), ("Light", crate::theme::Tokens::light())]; + themes.extend(crate::theme_files::builtin().iter().map(|theme| (theme.name.as_str(), crate::theme_files::resolve(theme)))); + let mut failures = Vec::new(); + for (name, tokens) in themes { + let Some(fill) = navigation_fill(&tokens, NavigationFill::Active) else { continue }; + let ink = navigation_ink(&tokens, NavigationFill::Active); + let ratio = crate::theme::contrast_ratio(ink, fill); + if ratio < 3.0 { + failures.push(format!("{name}: {ratio:.2}:1 ({ink:?} on {fill:?})")); + } + } + assert!(failures.is_empty(), "active navigation slot unreadable:\n{}", failures.join("\n")); + } +}