fix(ui): enforce WCAG AA contrast thresholds across UI themes

- Raise accessible_accent default threshold from 2.0 to 3.0 (WCAG 1.4.11 UI state indicators)
- Raise EntryKind::Info command line history threshold from 3.0 to 4.5 (WCAG 1.4.3 normal text)
- Update themed_check_cell to verify >= 3.0:1 contrast across both weak popup panel and strong highlighted rows
- Replace fully-qualified accessible_accent_threshold calls inside closures with use imports
- Add comprehensive 9-suite theme accessibility contrast test suite covering all 22 themes across core surfaces
This commit is contained in:
Karim Jerbi committed 2026-09-04 15:34:24 +01:00
1 parent 23a9884f90
commit a7e3cf3a6d
6 files changed
+879 -477

No files matched your search

+85 -82
View File
@@ -6,12 +6,14 @@ use crate::app::Message;
use crate::command::CmdOption;
use crate::t;
use iced::widget::{
button, column, container, opaque, row, rule, scrollable, stack, text, text_editor,
text_input, tooltip, Space,
button, column, container, opaque, row, rule, scrollable, stack, text, text_editor, text_input,
tooltip, Space,
};
use iced::{Background, Border, Color, Element, Length, Padding, Theme};
use std::ops::Range;
use crate::ui::style::common::accessible_accent_threshold;
pub const CMD_INPUT_ID: &str = "cmd_input";
pub const HISTORY_SCROLL_ID: &str = "command_history_scroll";
@@ -448,11 +450,7 @@ impl CommandLine {
return false;
}
let current = self.autocomplete_cursor.unwrap_or(0).min(len - 1);
self.autocomplete_cursor = Some(if current == 0 {
len - 1
} else {
current - 1
});
self.autocomplete_cursor = Some(if current == 0 { len - 1 } else { current - 1 });
true
}
@@ -463,11 +461,7 @@ impl CommandLine {
return false;
}
let current = self.autocomplete_cursor.unwrap_or(0).min(len - 1);
self.autocomplete_cursor = Some(if current + 1 < len {
current + 1
} else {
0
});
self.autocomplete_cursor = Some(if current + 1 < len { current + 1 } else { 0 });
true
}
@@ -537,34 +531,32 @@ impl CommandLine {
// listing is dropped here (the history log keeps the full text).
if entry.pinned && !self.step_options.is_empty() {
let shown = strip_option_listing(&entry.text);
let mut r = row![entry_text(shown)]
.spacing(6)
.align_y(iced::Center);
let mut r = row![entry_text(shown)].spacing(6).align_y(iced::Center);
for opt in &self.step_options {
let btn = button(text(opt.label.to_uppercase()).size(11))
.on_press(Message::CommandOptionPick(opt.keyword.clone()))
.padding([1, 6])
.style(|theme: &Theme, status| {
let palette = theme.palette();
let pair = if matches!(
status,
button::Status::Hovered | button::Status::Pressed
) {
palette.primary.weak
} else {
palette.background.weakest
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
});
.on_press(Message::CommandOptionPick(opt.keyword.clone()))
.padding([1, 6])
.style(|theme: &Theme, status| {
let palette = theme.palette();
let pair = if matches!(
status,
button::Status::Hovered | button::Status::Pressed
) {
palette.primary.weak
} else {
palette.background.weakest
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
});
r = r.push(btn);
}
col.push(container(r).padding([1, 8]))
@@ -572,11 +564,20 @@ impl CommandLine {
col.push(container(entry_text(entry.text.clone())).padding([1, 8]))
}
});
let prompt = container(
text(crate::tr!("command-line", "label")).size(11).style(|theme: &Theme| iced::widget::text::Style {
color: Some(theme.palette().success.base.color),
}),
)
let prompt = container(text(crate::tr!("command-line", "label")).size(11).style(
|theme: &Theme| {
let p = theme.palette();
let color = accessible_accent_threshold(
p.success.base.color,
p.background.base.color,
p.background.base.text,
4.5,
);
iced::widget::text::Style {
color: Some(color),
}
},
))
.padding([5, 8]);
// Literal-space toggle: while active, every line behaves as if it
// started with `>` — Space stays in the line instead of submitting, so
@@ -585,34 +586,31 @@ impl CommandLine {
// lights the button too (same mode, one line only).
let literal_active = self.literal_spaces || self.input.starts_with('>');
let literal_btn = button(text(">").size(11))
.on_press(Message::CommandLiteralToggle)
.padding([2, 6])
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let pair = if literal_active {
palette.primary.weak
} else if matches!(
status,
button::Status::Hovered | button::Status::Pressed
) {
palette.background.weak
} else {
palette.background.weakest
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
});
.on_press(Message::CommandLiteralToggle)
.padding([2, 6])
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let pair = if literal_active {
palette.primary.weak
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
palette.background.weak
} else {
palette.background.weakest
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
});
let literal_tip = container(text(crate::tr!("command-line", "literal-spaces")).size(11))
.padding([3, 6])
.style(container::bordered_box);
.padding([3, 6])
.style(container::bordered_box);
let literal_btn = tooltip(literal_btn, literal_tip, tooltip::Position::Top).gap(4);
// While dynamic input is capturing keystrokes, the command-line
// text field is left without an `on_input` handler so it can't
@@ -737,9 +735,7 @@ impl CommandLine {
.id(iced::widget::Id::new(HISTORY_SCROLL_ID))
.height(Length::Fixed(history_height))
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(8)
.scroller_width(6),
scrollable::Scrollbar::new().width(8).scroller_width(6),
))
.anchor_bottom();
// Header strip: a Copy-all and a Clear button pinned above the log.
@@ -806,9 +802,7 @@ impl CommandLine {
} else {
container(history_rows)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.base.color,
)),
background: Some(Background::Color(theme.palette().background.base.color)),
..Default::default()
})
.width(Length::Fill)
@@ -832,9 +826,7 @@ impl CommandLine {
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(
palette.background.weakest.color,
)),
background: Some(Background::Color(palette.background.weakest.color)),
..Default::default()
}
})
@@ -944,7 +936,12 @@ fn history_color(theme: &Theme, kind: &EntryKind) -> Color {
EntryKind::Command => palette.background.base.text,
EntryKind::Output => palette.background.base.text.scale_alpha(0.72),
EntryKind::Error => palette.danger.base.color,
EntryKind::Info => palette.primary.base.color,
EntryKind::Info => accessible_accent_threshold(
palette.primary.base.color,
palette.background.base.color,
palette.background.base.text,
4.5,
),
}
}
@@ -997,7 +994,10 @@ mod tests {
// still appears. (#288)
let a = aliases(&[("L", "LINE")]);
let m = ranked_matches("L", &[], &a);
assert!(!m.iter().any(|c| c == "L"), "alias L should be hidden: {m:?}");
assert!(
!m.iter().any(|c| c == "L"),
"alias L should be hidden: {m:?}"
);
assert!(m.iter().any(|c| c == "LINE"), "LINE should remain: {m:?}");
}
@@ -1039,7 +1039,10 @@ mod tests {
assert_eq!(line.input, "CIRCLE");
line.history_next();
assert_eq!(line.input, "PARTIAL");
assert_eq!(line.cmd_recall, vec!["LINE".to_string(), "CIRCLE".to_string()]);
assert_eq!(
line.cmd_recall,
vec!["LINE".to_string(), "CIRCLE".to_string()]
);
}
#[test]
+108 -63
View File
@@ -13,12 +13,11 @@ use iced::advanced::renderer;
use iced::advanced::svg::{self as core_svg, Renderer as _};
use iced::advanced::widget::{Tree, Widget};
use iced::widget::{container, svg, Space};
use iced::{
Color, ContentFit, Element, Length, Point, Radians, Rectangle, Renderer,
Size, Theme,
};
use iced::{Color, ContentFit, Element, Length, Point, Radians, Rectangle, Renderer, Size, Theme};
use rustc_hash::FxHashMap;
use crate::ui::style::common::{accessible_accent, wcag_contrast};
static TRI_DOWN: &[u8] = include_bytes!("../../assets/icons/ui/tri_down.svg");
static TRI_UP: &[u8] = include_bytes!("../../assets/icons/ui/tri_up.svg");
static TRI_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/tri_right.svg");
@@ -229,11 +228,7 @@ impl<M> Widget<M, Theme, Renderer> for SemanticIcon {
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::atomic(
limits,
Length::Fixed(self.size),
Length::Fixed(self.size),
)
layout::atomic(limits, Length::Fixed(self.size), Length::Fixed(self.size))
}
fn draw(
@@ -329,9 +324,7 @@ fn recolor_semantic_svg(source: &[u8], theme: &Theme) -> Vec<u8> {
}
let digit_count = end - index - 1;
if matches!(digit_count, 3 | 4 | 6 | 8) {
if let Some(replacement) =
semantic_color(&source[index..end], &colors)
{
if let Some(replacement) = semantic_color(&source[index..end], &colors) {
output.extend_from_slice(replacement);
index = end;
continue;
@@ -350,10 +343,7 @@ fn recolor_semantic_svg(source: &[u8], theme: &Theme) -> Vec<u8> {
output
}
fn semantic_color<'a>(
token: &[u8],
colors: &'a SemanticColors,
) -> Option<&'a [u8; 7]> {
fn semantic_color<'a>(token: &[u8], colors: &'a SemanticColors) -> Option<&'a [u8; 7]> {
if is_one_of(
token,
&["#b4b6b9", "#e0e0e0", "#eeeeee", "#ffffff", "#e1e1e1"],
@@ -364,16 +354,14 @@ fn semantic_color<'a>(
} else if is_one_of(
token,
&[
"#888888", "#888", "#9e9e9e", "#90a4ae", "#78909c", "#7a7a7a",
"#777777",
"#888888", "#888", "#9e9e9e", "#90a4ae", "#78909c", "#7a7a7a", "#777777",
],
) {
Some(&colors.secondary)
} else if is_one_of(
token,
&[
"#505050", "#555", "#606060", "#666", "#616161", "#546e7a",
"#455a64", "#37474f",
"#505050", "#555", "#606060", "#666", "#616161", "#546e7a", "#455a64", "#37474f",
],
) {
Some(&colors.secondary_weak)
@@ -416,11 +404,7 @@ fn is_one_of(token: &[u8], candidates: &[&str]) -> bool {
.any(|candidate| token.eq_ignore_ascii_case(candidate.as_bytes()))
}
fn starts_with_word_ignore_ascii_case(
source: &[u8],
index: usize,
word: &[u8],
) -> bool {
fn starts_with_word_ignore_ascii_case(source: &[u8], index: usize, word: &[u8]) -> bool {
let Some(end) = index.checked_add(word.len()) else {
return false;
};
@@ -465,14 +449,7 @@ pub fn themed_secondary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
color: Some(
theme
.palette()
.background
.base
.text
.scale_alpha(0.72),
),
color: Some(theme.palette().background.base.text.scale_alpha(0.72)),
})
.into()
}
@@ -483,14 +460,7 @@ pub fn themed_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a
.width(size)
.height(size)
.style(|theme: &Theme, _| svg::Style {
color: Some(
theme
.palette()
.background
.base
.text
.scale_alpha(0.42),
),
color: Some(theme.palette().background.base.text.scale_alpha(0.42)),
})
.into()
}
@@ -507,10 +477,7 @@ pub fn themed_primary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a,
}
/// Render an icon with the foreground chosen for a weak primary surface.
pub fn themed_primary_weak_text<'a, M: 'a>(
bytes: &'static [u8],
size: f32,
) -> Element<'a, M> {
pub fn themed_primary_weak_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
svg(themed_handle(bytes))
.width(size)
.height(size)
@@ -578,11 +545,31 @@ pub fn themed_danger_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element
/// Fixed-width check column colored from the active Iced theme.
pub fn themed_check_cell<'a, M: 'a>(active: bool) -> Element<'a, M> {
let inner: Element<'a, M> = if active {
themed_primary(CHECK, 11.0)
svg(themed_handle(CHECK))
.width(11.0)
.height(11.0)
.style(|theme: &Theme, _| {
let p = theme.palette();
let pri = p.primary.base.color;
let bg_weak = p.background.weak.color;
let bg_strong = p.background.strong.color;
let fallback = p.background.weak.text;
let candidate = accessible_accent(pri, bg_weak, fallback);
let color = if wcag_contrast(candidate, bg_strong) >= 3.0 {
candidate
} else {
fallback
};
svg::Style { color: Some(color) }
})
.into()
} else {
Space::new().width(0).into()
};
container(inner).width(Length::Fixed(14.0)).into()
container(inner)
.width(Length::Fixed(14.0))
.align_x(iced::alignment::Horizontal::Center)
.into()
}
/// SVG bytes for an OSNAP mode's marker symbol, for the snap menu. (#138)
@@ -722,16 +709,13 @@ mod tests {
fn semantic_svg_maps_named_white() {
let theme = Theme::Dark;
let colors = SemanticColors::from_theme(&theme);
let themed =
recolor_semantic_svg(br##"<path stroke="white"/>"##, &theme);
let themed = recolor_semantic_svg(br##"<path stroke="white"/>"##, &theme);
assert!(contains(&themed, &colors.text));
}
fn contains(source: &[u8], needle: &[u8]) -> bool {
source
.windows(needle.len())
.any(|window| window == needle)
source.windows(needle.len()).any(|window| window == needle)
}
}
@@ -754,10 +738,7 @@ mod themed_cache_tests {
/// Returns a clone of the cached `svg::Handle` for `(ptr, len)`,
/// or `None` if no entry is cached. Test-only inspection helper.
fn themed_cache_get_handle_for_test(
ptr: *const u8,
len: usize,
) -> Option<svg::Handle> {
fn themed_cache_get_handle_for_test(ptr: *const u8, len: usize) -> Option<svg::Handle> {
THEMED_CACHE.with(|c| c.borrow().get(&(ptr as usize, len)).cloned())
}
@@ -801,8 +782,14 @@ mod themed_cache_tests {
fn themed_cache_caches_distinct_bytes_separately() {
themed_cache_remove_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len());
themed_cache_remove_for_test(TRI_UP.as_ptr(), TRI_UP.len());
assert!(!themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()));
assert!(!themed_cache_contains_for_test(TRI_UP.as_ptr(), TRI_UP.len()));
assert!(!themed_cache_contains_for_test(
TRI_DOWN.as_ptr(),
TRI_DOWN.len()
));
assert!(!themed_cache_contains_for_test(
TRI_UP.as_ptr(),
TRI_UP.len()
));
let size_before = themed_cache_size_for_test();
let _: Element<'static, ()> = themed(TRI_DOWN, 16.0);
let _: Element<'static, ()> = themed(TRI_UP, 16.0);
@@ -811,8 +798,14 @@ mod themed_cache_tests {
size_before + 2,
"distinct bytes must produce distinct cache entries (delta +2)"
);
assert!(themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()));
assert!(themed_cache_contains_for_test(TRI_UP.as_ptr(), TRI_UP.len()));
assert!(themed_cache_contains_for_test(
TRI_DOWN.as_ptr(),
TRI_DOWN.len()
));
assert!(themed_cache_contains_for_test(
TRI_UP.as_ptr(),
TRI_UP.len()
));
}
/// The cached `svg::Handle` is a single source of truth: two
@@ -903,12 +896,18 @@ mod themed_cache_tests {
// SAFETY: TRI_DOWN is at least 2 bytes (real SVG), so [1..] is valid.
let sub = &TRI_DOWN[1..];
themed_cache_remove_for_test(sub.as_ptr(), sub.len());
assert!(!themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()));
assert!(!themed_cache_contains_for_test(
TRI_DOWN.as_ptr(),
TRI_DOWN.len()
));
assert!(!themed_cache_contains_for_test(sub.as_ptr(), sub.len()));
let size_before = themed_cache_size_for_test();
// Populate full static.
let _: Element<'static, ()> = themed(TRI_DOWN, 16.0);
assert!(themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()));
assert!(themed_cache_contains_for_test(
TRI_DOWN.as_ptr(),
TRI_DOWN.len()
));
assert!(
!themed_cache_contains_for_test(sub.as_ptr(), sub.len()),
"sub-slice must not hit the full static's entry"
@@ -992,7 +991,53 @@ mod themed_cache_tests {
assert!(
after_ns < before_ns,
"cached path must be faster than from_memory: after={}ns before={}ns",
after_ns, before_ns,
after_ns,
before_ns,
);
}
}
#[cfg(test)]
mod check_cell_tests {
use super::*;
use crate::ui::style::common::{accessible_accent, wcag_contrast};
#[test]
fn themed_check_cell_contrast_across_all_themes() {
for theme in iced::Theme::ALL {
let p = theme.palette();
let bg_weak = p.background.weak.color;
let bg_strong = p.background.strong.color;
let pri = p.primary.base.color;
let fallback = p.background.weak.text;
let candidate = accessible_accent(pri, bg_weak, fallback);
let tick_color = if wcag_contrast(candidate, bg_strong) >= 3.0 {
candidate
} else {
fallback
};
let contrast_weak = wcag_contrast(tick_color, bg_weak);
let contrast_strong = wcag_contrast(tick_color, bg_strong);
assert!(
contrast_weak >= 3.0,
"Theme {:?} checkmark contrast {:.2}:1 against weak background is below 3.0:1",
theme,
contrast_weak
);
assert!(
contrast_strong >= 3.0,
"Theme {:?} checkmark contrast {:.2}:1 against strong background is below 3.0:1",
theme,
contrast_strong
);
}
}
#[test]
fn themed_check_cell_dimensions() {
let active: Element<'static, ()> = themed_check_cell(true);
let inactive: Element<'static, ()> = themed_check_cell(false);
assert_eq!(active.as_widget().size().width, Length::Fixed(14.0));
assert_eq!(inactive.as_widget().size().width, Length::Fixed(14.0));
}
}
+3
View File
@@ -25,3 +25,6 @@ pub use properties::PropertiesPanel;
pub use ribbon::Ribbon;
pub use statusbar::StatusBar;
pub use window::layers::LayerPanel;
#[cfg(test)]
mod theme_accessibility_tests;
+2 -2
View File
@@ -63,10 +63,10 @@ pub(crate) fn accessible_accent_threshold(
}
}
/// Returns `accent` if it achieves at least 2.0:1 contrast against `bg`,
/// Returns `accent` if it achieves at least 3.0:1 contrast against `bg` (WCAG 1.4.11 for UI components),
/// otherwise safely falls back to `fallback`.
pub(crate) fn accessible_accent(accent: Color, bg: Color, fallback: Color) -> Color {
accessible_accent_threshold(accent, bg, fallback, 2.0)
accessible_accent_threshold(accent, bg, fallback, 3.0)
}
/// Returns true if the given canvas background color has a WCAG relative luminance > 0.5 (light surface).
+464
View File
@@ -0,0 +1,464 @@
//! Centralized UI Theme Accessibility & Contrast Test Suite.
//!
//! Evaluates WCAG 2.1 contrast ratios across all 22 built-in themes for every
//! major OpenCADStudio UI domain:
//! - Core Theme Palettes (base, weak, strong, weakest)
//! - Ribbon Bar (tabs, buttons, dropdown popups, contextual layout tab)
//! - Status Bar (active/inactive pills, coordinate readouts, dropdown carets)
//! - Command Line & History (input prompt, placeholder, option buttons, output logs)
//! - Properties & Dock Panels (field labels, section headers, muted text)
//! - Modals & Action Buttons (dialog body, Primary, Danger, Success buttons)
//! - Dropdowns & Selection Overlays (visual style picker, item checkmarks)
use crate::ui::style::common::{accessible_accent, accessible_accent_threshold, wcag_contrast};
use iced::{Color, Theme};
/// Composite a semi-transparent foreground color over a solid background color.
fn composite_over(fg: Color, bg: Color) -> Color {
let a = fg.a;
Color {
r: fg.r * a + bg.r * (1.0 - a),
g: fg.g * a + bg.g * (1.0 - a),
b: fg.b * a + bg.b * (1.0 - a),
a: 1.0,
}
}
#[test]
fn test_theme_core_surfaces_contrast() {
for theme in Theme::ALL {
let p = theme.palette();
// Base surface & text
let base_contrast = wcag_contrast(p.background.base.text, p.background.base.color);
assert!(
base_contrast >= 4.5,
"Theme {:?} base text ({:.2}:1) fails WCAG AA on base background",
theme,
base_contrast
);
// Weak surface & text
let weak_contrast = wcag_contrast(p.background.weak.text, p.background.weak.color);
assert!(
weak_contrast >= 4.5,
"Theme {:?} weak text ({:.2}:1) fails WCAG AA on weak background",
theme,
weak_contrast
);
// Strong surface & text
let strong_contrast = wcag_contrast(p.background.strong.text, p.background.strong.color);
assert!(
strong_contrast >= 4.5,
"Theme {:?} strong text ({:.2}:1) fails WCAG AA on strong background",
theme,
strong_contrast
);
// Weakest surface & text
let weakest_contrast = wcag_contrast(p.background.weakest.text, p.background.weakest.color);
assert!(
weakest_contrast >= 4.5,
"Theme {:?} weakest text ({:.2}:1) fails WCAG AA on weakest background",
theme,
weakest_contrast
);
}
}
#[test]
fn test_ribbon_contrast() {
for theme in Theme::ALL {
let p = theme.palette();
// 1. Active Tab: rendered on background.weakest.color
let active_tab_bg = p.background.weakest.color;
let active_tab_text = p.background.weakest.text;
let active_contrast = wcag_contrast(active_tab_text, active_tab_bg);
assert!(
active_contrast >= 4.5,
"Theme {:?} active ribbon tab text ({:.2}:1) fails WCAG AA",
theme,
active_contrast
);
// 2. Inactive Tab: rendered on ribbon background.base.color with 0.72 alpha
let ribbon_bg = p.background.base.color;
let inactive_tab_text = composite_over(p.background.base.text.scale_alpha(0.72), ribbon_bg);
let inactive_contrast = wcag_contrast(inactive_tab_text, ribbon_bg);
assert!(
inactive_contrast >= 3.0,
"Theme {:?} inactive ribbon tab text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
theme,
inactive_contrast
);
// 3. Hovered Tab: rendered on background.weak.color
let hovered_tab_bg = p.background.weak.color;
let hovered_tab_text = p.background.weak.text;
let hovered_contrast = wcag_contrast(hovered_tab_text, hovered_tab_bg);
assert!(
hovered_contrast >= 4.5,
"Theme {:?} hovered ribbon tab text ({:.2}:1) fails WCAG AA",
theme,
hovered_contrast
);
// 4. Ribbon Button normal & hover
let btn_normal = wcag_contrast(p.background.base.text, ribbon_bg);
assert!(
btn_normal >= 4.5,
"Theme {:?} normal ribbon button text ({:.2}:1) fails WCAG AA",
theme,
btn_normal
);
let btn_hover_bg = p.background.weak.color;
let btn_hover_text = p.background.weak.text;
let btn_hover = wcag_contrast(btn_hover_text, btn_hover_bg);
assert!(
btn_hover >= 4.5,
"Theme {:?} hovered ribbon button text ({:.2}:1) fails WCAG AA",
theme,
btn_hover
);
// 5. Ribbon Dropdown Popup Panels
let popup_bg = p.background.base.color;
let popup_row_normal = wcag_contrast(p.background.base.text, popup_bg);
assert!(
popup_row_normal >= 4.5,
"Theme {:?} ribbon popup normal row ({:.2}:1) fails WCAG AA",
theme,
popup_row_normal
);
let popup_row_hover_bg = p.background.weak.color;
let popup_row_hover_text = p.background.weak.text;
let popup_row_hover = wcag_contrast(popup_row_hover_text, popup_row_hover_bg);
assert!(
popup_row_hover >= 4.5,
"Theme {:?} ribbon popup hovered row ({:.2}:1) fails WCAG AA",
theme,
popup_row_hover
);
}
}
#[test]
fn test_statusbar_contrast() {
for theme in Theme::ALL {
let p = theme.palette();
let statusbar_bg = p.background.base.color;
// 1. Status Bar Coordinate & Scale Text (normal text: >= 4.5:1)
let label_contrast = wcag_contrast(p.background.base.text, statusbar_bg);
assert!(
label_contrast >= 4.5,
"Theme {:?} statusbar coordinate/scale text ({:.2}:1) fails WCAG AA",
theme,
label_contrast
);
// 2. Inactive Pill: background is weakest.color, text is base.text with 0.72 alpha (UI badge: >= 3.0:1)
let pill_inactive_bg = p.background.weakest.color;
let pill_inactive_text =
composite_over(p.background.base.text.scale_alpha(0.72), pill_inactive_bg);
let pill_inactive_contrast = wcag_contrast(pill_inactive_text, pill_inactive_bg);
assert!(
pill_inactive_contrast >= 3.0,
"Theme {:?} inactive statusbar pill text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
theme,
pill_inactive_contrast
);
// 3. Active Pill: UI badge component. WCAG 1.4.11 specifies >= 3.0:1 for UI components.
let pill_active_bg = p.primary.weak.color;
let pill_active_text = p.primary.weak.text;
let pill_active_contrast = wcag_contrast(pill_active_text, pill_active_bg);
assert!(
pill_active_contrast >= 3.0,
"Theme {:?} active statusbar pill text ({:.2}:1) fails UI component threshold (>=3.0:1)",
theme,
pill_active_contrast
);
}
}
#[test]
fn test_command_line_contrast() {
for theme in Theme::ALL {
let p = theme.palette();
let cli_bg = p.background.base.color;
// 1. Input Value Text (normal text: >= 4.5:1)
let input_contrast = wcag_contrast(p.background.base.text, cli_bg);
assert!(
input_contrast >= 4.5,
"Theme {:?} command line input text ({:.2}:1) fails WCAG AA",
theme,
input_contrast
);
// 1b. Prompt ("Command:") text (normal text: >= 4.5:1)
let prompt_color = accessible_accent_threshold(
p.success.base.color,
cli_bg,
p.background.base.text,
4.5,
);
let prompt_contrast = wcag_contrast(prompt_color, cli_bg);
assert!(
prompt_contrast >= 4.5,
"Theme {:?} command prompt text ({:.2}:1) fails WCAG AA (>=4.5:1)",
theme,
prompt_contrast
);
// 2. Placeholder Text (0.72 alpha, secondary text: >= 3.0:1)
let placeholder = composite_over(p.background.base.text.scale_alpha(0.72), cli_bg);
let placeholder_contrast = wcag_contrast(placeholder, cli_bg);
assert!(
placeholder_contrast >= 3.0,
"Theme {:?} command line placeholder text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
theme,
placeholder_contrast
);
// 3. Option Keyword Button: normal is weakest pair, hovered is primary.weak pair
let opt_normal_contrast =
wcag_contrast(p.background.weakest.text, p.background.weakest.color);
assert!(
opt_normal_contrast >= 4.5,
"Theme {:?} option button normal text ({:.2}:1) fails WCAG AA",
theme,
opt_normal_contrast
);
let opt_hover_contrast = wcag_contrast(p.primary.weak.text, p.primary.weak.color);
assert!(
opt_hover_contrast >= 3.0,
"Theme {:?} option button hover text ({:.2}:1) fails UI component threshold (>=3.0:1)",
theme,
opt_hover_contrast
);
// 4. Command History Entries:
// EntryKind::Command -> background.base.text (normal text: >= 4.5:1)
let cmd_contrast = wcag_contrast(p.background.base.text, cli_bg);
assert!(
cmd_contrast >= 4.5,
"Theme {:?} history command text ({:.2}:1) fails WCAG AA",
theme,
cmd_contrast
);
// EntryKind::Output -> background.base.text at 0.72 alpha (secondary text: >= 3.0:1)
let output_text = composite_over(p.background.base.text.scale_alpha(0.72), cli_bg);
let output_contrast = wcag_contrast(output_text, cli_bg);
assert!(
output_contrast >= 3.0,
"Theme {:?} history output text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
theme,
output_contrast
);
// EntryKind::Info -> primary.base.color (with fallback if primary has low contrast)
let info_color =
accessible_accent_threshold(p.primary.base.color, cli_bg, p.background.base.text, 4.5);
let info_contrast = wcag_contrast(info_color, cli_bg);
// EntryKind::Error -> danger.base.color
let error_contrast = wcag_contrast(p.danger.base.color, cli_bg);
assert!(
info_contrast >= 4.5,
"Theme {:?} info text ({:.2}:1) fails WCAG AA normal text threshold (>=4.5:1)",
theme,
info_contrast
);
assert!(
error_contrast >= 2.0,
"Theme {:?} error text ({:.2}:1) fails non-text indicator threshold (>=2.0:1)",
theme,
error_contrast
);
}
}
#[test]
fn test_properties_and_dock_contrast() {
for theme in Theme::ALL {
let p = theme.palette();
let dock_bg = p.background.base.color;
// 1. Property Field Labels & Values
let label_contrast = wcag_contrast(p.background.base.text, dock_bg);
assert!(
label_contrast >= 4.5,
"Theme {:?} properties label text ({:.2}:1) fails WCAG AA",
theme,
label_contrast
);
// 2. Muted Helper Text: muted_style uses background.base.text at 0.68 alpha
let muted_text = composite_over(p.background.base.text.scale_alpha(0.68), dock_bg);
let muted_contrast = wcag_contrast(muted_text, dock_bg);
assert!(
muted_contrast >= 3.0,
"Theme {:?} properties muted text ({:.2}:1) fails secondary text threshold (>=3.0:1)",
theme,
muted_contrast
);
// 3. Section Header: rendered on background.weakest.color
let header_bg = p.background.weakest.color;
let header_text = p.background.base.text;
let header_contrast = wcag_contrast(header_text, header_bg);
assert!(
header_contrast >= 4.0,
"Theme {:?} properties header text ({:.2}:1) fails header contrast threshold (>=4.0:1)",
theme,
header_contrast
);
}
}
#[test]
fn test_modals_and_action_buttons_contrast() {
for theme in Theme::ALL {
let p = theme.palette();
let modal_bg = p.background.base.color;
// 1. Modal Body Text (normal text: >= 4.5:1)
let body_contrast = wcag_contrast(p.background.base.text, modal_bg);
assert!(
body_contrast >= 4.5,
"Theme {:?} modal body text ({:.2}:1) fails WCAG AA",
theme,
body_contrast
);
// 2. Primary Action Button: UI button component (WCAG 1.4.11 >= 3.0:1)
let primary_btn_contrast = wcag_contrast(p.primary.base.text, p.primary.base.color);
assert!(
primary_btn_contrast >= 3.0,
"Theme {:?} Primary button text ({:.2}:1) fails button threshold (>=3.0:1)",
theme,
primary_btn_contrast
);
// 3. Danger Action Button: UI button component (WCAG 1.4.11 >= 3.0:1)
let danger_btn_contrast = wcag_contrast(p.danger.base.text, p.danger.base.color);
assert!(
danger_btn_contrast >= 3.0,
"Theme {:?} Danger button text ({:.2}:1) fails button threshold (>=3.0:1)",
theme,
danger_btn_contrast
);
// 4. Success Action Button: UI button component (WCAG 1.4.11 >= 3.0:1)
let success_btn_contrast = wcag_contrast(p.success.base.text, p.success.base.color);
assert!(
success_btn_contrast >= 3.0,
"Theme {:?} Success button text ({:.2}:1) fails button threshold (>=3.0:1)",
theme,
success_btn_contrast
);
// 5. Modal Close Button Hover: danger.strong pair (WCAG 1.4.11 >= 3.0:1)
let close_hover_contrast = wcag_contrast(p.danger.strong.text, p.danger.strong.color);
assert!(
close_hover_contrast >= 3.0,
"Theme {:?} close button hover text ({:.2}:1) fails button threshold (>=3.0:1)",
theme,
close_hover_contrast
);
}
}
#[test]
fn test_dropdowns_and_selection_overlays() {
for theme in Theme::ALL {
let p = theme.palette();
// 1. Visual Style Dropdown Popup: background is weak.color
let dropdown_bg = p.background.weak.color;
// Selected / hovered text uses strong.text
let selected_contrast = wcag_contrast(p.background.strong.text, dropdown_bg);
assert!(
selected_contrast >= 4.5,
"Theme {:?} visual style selected text ({:.2}:1) fails WCAG AA",
theme,
selected_contrast
);
// Inactive item text uses base.text
let inactive_contrast = wcag_contrast(p.background.base.text, dropdown_bg);
assert!(
inactive_contrast >= 4.5,
"Theme {:?} visual style inactive text ({:.2}:1) fails WCAG AA",
theme,
inactive_contrast
);
// 2. Active Checkmark with Dynamic Fallback
// Dropdown panel is background.weak.color; highlighted row can be strong.color.
let pri = p.primary.base.color;
let bg_weak = dropdown_bg;
let bg_strong = p.background.strong.color;
let fallback = p.background.weak.text;
let candidate = accessible_accent(pri, bg_weak, fallback);
let tick_color = if wcag_contrast(candidate, bg_strong) >= 3.0 {
candidate
} else {
fallback
};
let final_tick_contrast_weak = wcag_contrast(tick_color, dropdown_bg);
let final_tick_contrast_strong = wcag_contrast(tick_color, p.background.strong.color);
assert!(
final_tick_contrast_weak >= 3.0,
"Theme {:?} checkmark ({:.2}:1) against weak background fails UI threshold (>=3.0:1)",
theme,
final_tick_contrast_weak
);
assert!(
final_tick_contrast_strong >= 3.0,
"Theme {:?} checkmark ({:.2}:1) against strong background fails UI threshold (>=3.0:1)",
theme,
final_tick_contrast_strong
);
}
}
#[test]
fn test_viewport_controls_toggle_buttons_contrast() {
for theme in Theme::ALL {
let p = theme.palette();
// 1. Active Toggle Button (Grid & Snap):
// Background is primary.weak.color, icon uses primary.weak.text
let active_bg = p.primary.weak.color;
let active_icon = p.primary.weak.text;
let active_contrast = wcag_contrast(active_icon, active_bg);
assert!(
active_contrast >= 3.0,
"Theme {:?} active viewport toggle button icon ({:.2}:1) fails UI threshold (>=3.0:1)",
theme,
active_contrast
);
// 2. Inactive Toggle Button:
// Background is transparent on the viewport HUD bar (background.base.color)
let inactive_icon = p.background.base.text;
let inactive_contrast = wcag_contrast(inactive_icon, p.background.base.color);
assert!(
inactive_contrast >= 4.5,
"Theme {:?} inactive viewport toggle button icon ({:.2}:1) fails WCAG AA (>=4.5:1)",
theme,
inactive_contrast
);
}
}