From a7e3cf3a6d78ad55276be73da8ffe92f767b9b1e Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Fri, 4 Sep 2026 15:34:24 +0100 Subject: [PATCH] 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 --- src/app/view/controls.rs | 547 +++++++++++----------------- src/ui/command_line.rs | 167 ++++----- src/ui/icons.rs | 171 +++++---- src/ui/mod.rs | 3 + src/ui/style/common.rs | 4 +- src/ui/theme_accessibility_tests.rs | 464 +++++++++++++++++++++++ 6 files changed, 879 insertions(+), 477 deletions(-) create mode 100644 src/ui/theme_accessibility_tests.rs diff --git a/src/app/view/controls.rs b/src/app/view/controls.rs index 7fb595e7f..a98b3e4a4 100644 --- a/src/app/view/controls.rs +++ b/src/app/view/controls.rs @@ -1,8 +1,8 @@ -use super::*; use super::super::document::{DynComponent, DynFieldEntry}; use super::super::Message; -use iced::widget::{button, canvas, column, container, mouse_area, row, text, tooltip}; -use iced::{Background, Border, Color, Element, Length, Point, Rectangle, Theme}; +use super::*; +use iced::widget::{button, column, container, mouse_area, row, text, tooltip}; +use iced::{Background, Border, Element, Length, Theme}; use std::time::Duration; fn viewport_tooltip<'a>( @@ -22,189 +22,6 @@ fn viewport_tooltip<'a>( .into() } -#[derive(Clone, Copy)] -struct RenderModePreview { - mode: acadrust::entities::ViewportRenderMode, -} - -impl canvas::Program for RenderModePreview { - type State = (); - - fn draw( - &self, - _state: &(), - renderer: &iced::Renderer, - theme: &Theme, - bounds: Rectangle, - _cursor: iced::mouse::Cursor, - ) -> Vec { - - use acadrust::entities::ViewportRenderMode as M; - let mut frame = canvas::Frame::new(renderer, bounds.size()); - let palette = theme.palette(); - let ink = palette.background.base.text.scale_alpha(0.86); - let faint = ink.scale_alpha(0.34); - let accent = palette.primary.base.color; - let face = palette.primary.weak.color.scale_alpha(0.72); - let face_alt = palette.primary.strong.color.scale_alpha(0.64); - let edge = if self.mode == M::Wireframe3D { accent } else { ink }; - let shaded = matches!( - self.mode, - M::FlatShaded - | M::GouraudShaded - | M::FlatShadedWithEdges - | M::GouraudShadedWithEdges - ); - let flat = matches!(self.mode, M::FlatShaded | M::FlatShadedWithEdges); - let smooth = matches!(self.mode, M::GouraudShaded | M::GouraudShadedWithEdges); - let with_edges = matches!( - self.mode, - M::Wireframe2D - | M::Wireframe3D - | M::HiddenLine - | M::FlatShadedWithEdges - | M::GouraudShadedWithEdges - ); - - // A small cube makes hidden/back edges and the shaded-edge variants - // immediately distinguishable without depending on the open drawing. - let a = Point::new(25.0, 35.0); - let b = Point::new(87.0, 35.0); - let c = Point::new(87.0, 94.0); - let d = Point::new(25.0, 94.0); - let off = iced::Vector::new(22.0, -17.0); - let e = a + off; - let f = b + off; - let g = c + off; - let h = d + off; - let quad = |points: [Point; 4]| { - canvas::Path::new(|path| { - path.move_to(points[0]); - path.line_to(points[1]); - path.line_to(points[2]); - path.line_to(points[3]); - path.close(); - }) - }; - if shaded || self.mode == M::HiddenLine { - frame.fill(&quad([a, b, f, e]), face_alt); - frame.fill(&quad([b, c, g, f]), face.scale_alpha(0.82)); - frame.fill(&quad([a, d, c, b]), face); - } - let stroke_line = |frame: &mut canvas::Frame, from: Point, to: Point, color: Color| { - frame.stroke( - &canvas::Path::line(from, to), - canvas::Stroke::default().with_color(color).with_width(1.2), - ); - }; - if matches!(self.mode, M::Wireframe2D | M::Wireframe3D) { - for (from, to) in [(e, f), (f, g), (g, h), (h, e)] { - stroke_line(&mut frame, from, to, faint); - } - } - if with_edges { - for (from, to) in [ - (a, b), (b, c), (c, d), (d, a), - (a, e), (b, f), (c, g), (d, h), - (e, f), (f, g), - ] { - stroke_line(&mut frame, from, to, edge); - } - } - - // Curved sample: faceted tones for Flat, concentric highlight for - // Gouraud, and latitude/longitude lines for the wireframe modes. - let center = Point::new((bounds.width - 55.0).max(145.0), 59.0); - let radius = 32.0; - if flat { - for i in 0..12 { - let a0 = i as f32 * std::f32::consts::TAU / 12.0; - let a1 = (i + 1) as f32 * std::f32::consts::TAU / 12.0; - let wedge = canvas::Path::new(|path| { - path.move_to(center); - path.line_to(Point::new(center.x + radius * a0.cos(), center.y + radius * a0.sin())); - path.line_to(Point::new(center.x + radius * a1.cos(), center.y + radius * a1.sin())); - path.close(); - }); - frame.fill(&wedge, if i % 3 == 0 { face_alt } else { face }); - } - } else if smooth { - frame.fill(&canvas::Path::circle(center, radius), face_alt); - frame.fill( - &canvas::Path::circle(Point::new(center.x - 7.0, center.y - 8.0), radius * 0.72), - face, - ); - frame.fill( - &canvas::Path::circle(Point::new(center.x - 12.0, center.y - 13.0), radius * 0.34), - palette.primary.weak.text.scale_alpha(0.38), - ); - } - if with_edges || self.mode == M::HiddenLine { - frame.stroke( - &canvas::Path::circle(center, radius), - canvas::Stroke::default().with_color(edge).with_width(1.2), - ); - } - if matches!(self.mode, M::Wireframe2D | M::Wireframe3D) { - for scale in [0.38_f32, 0.72] { - let y = radius * scale; - let half = (radius * radius - y * y).sqrt(); - stroke_line( - &mut frame, - Point::new(center.x - half, center.y - y), - Point::new(center.x + half, center.y - y), - faint, - ); - stroke_line( - &mut frame, - Point::new(center.x - half, center.y + y), - Point::new(center.x + half, center.y + y), - faint, - ); - } - stroke_line( - &mut frame, - Point::new(center.x, center.y - radius), - Point::new(center.x, center.y + radius), - faint, - ); - } - - // Bottom samples show the intentional Wireframe 2D/3D distinction: - // the legacy planar solid loses only its interior in the 3D style, - // while a hatch-like pattern remains visible in both. - let solid = quad([ - Point::new(18.0, 118.0), - Point::new(74.0, 112.0), - Point::new(69.0, 140.0), - Point::new(23.0, 143.0), - ]); - if self.mode != M::Wireframe3D { - frame.fill(&solid, accent.scale_alpha(0.48)); - } - frame.stroke( - &solid, - canvas::Stroke::default().with_color(edge).with_width(1.0), - ); - let hatch_box = canvas::Path::rectangle(Point::new(105.0, 112.0), iced::Size::new(82.0, 31.0)); - frame.stroke( - &hatch_box, - canvas::Stroke::default().with_color(ink).with_width(1.0), - ); - for x in (94..188).step_by(9) { - let x = x as f32; - stroke_line( - &mut frame, - Point::new(x.max(105.0), 143.0), - Point::new((x + 24.0).min(187.0), 112.0), - faint, - ); - } - - vec![frame.into_geometry()] - } -} - pub(super) fn viewport_controls<'a>( render_mode: acadrust::entities::ViewportRenderMode, show_grid: bool, @@ -218,29 +35,27 @@ pub(super) fn viewport_controls<'a>( .iter() .map(|style| RenderModeChoice(style.mode)) .collect(); - let danger_btn = move |bytes: &'static [u8], - msg: Message, - title: String, - command: &'static str| { - let button = button(crate::ui::icons::themed_danger(bytes, 15.0)) - .on_press(msg) - .padding([4, 6]) - .style(move |theme: &Theme, status| iced::widget::button::Style { - background: matches!( - status, - iced::widget::button::Status::Hovered - | iced::widget::button::Status::Pressed - ) - .then_some(Background::Color(theme.palette().danger.weak.color)), - border: Border { - radius: 3.0.into(), + let danger_btn = + move |bytes: &'static [u8], msg: Message, title: String, command: &'static str| { + let button = button(crate::ui::icons::themed_danger(bytes, 15.0)) + .on_press(msg) + .padding([4, 6]) + .style(move |theme: &Theme, status| iced::widget::button::Style { + background: matches!( + status, + iced::widget::button::Status::Hovered + | iced::widget::button::Status::Pressed + ) + .then_some(Background::Color(theme.palette().danger.weak.color)), + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + text_color: theme.palette().danger.base.color, ..Default::default() - }, - text_color: theme.palette().danger.base.color, - ..Default::default() - }); - viewport_tooltip(button, title, command) - }; + }); + viewport_tooltip(button, title, command) + }; // Borderless icon button; an `active` toggle gets an accent tint + fill. let icon_btn = move |bytes: &'static [u8], @@ -249,34 +64,33 @@ pub(super) fn viewport_controls<'a>( title: String, command: &'static str| { let icon = if active { - crate::ui::icons::themed_primary(bytes, 15.0) + crate::ui::icons::themed_primary_weak_text(bytes, 15.0) } else { crate::ui::icons::themed(bytes, 15.0) }; - let button = button(icon) - .on_press(msg) - .padding([4, 6]) - .style(move |theme: &Theme, status| { - let palette = theme.palette(); - let pair = match (active, status) { - (_, iced::widget::button::Status::Hovered) => { - Some(palette.background.strong) + let button = + button(icon) + .on_press(msg) + .padding([4, 6]) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match (active, status) { + (_, iced::widget::button::Status::Hovered) => { + Some(palette.background.strong) + } + (true, _) => Some(palette.primary.weak), + (false, _) => None, + }; + iced::widget::button::Style { + background: pair.map(|p| Background::Color(p.color)), + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + text_color: pair.map(|p| p.text).unwrap_or(palette.background.base.text), + ..Default::default() } - (true, _) => Some(palette.primary.weak), - (false, _) => None, - }; - iced::widget::button::Style { - background: pair.map(|p| Background::Color(p.color)), - border: Border { - radius: 3.0.into(), - ..Default::default() - }, - text_color: pair - .map(|p| p.text) - .unwrap_or(palette.background.base.text), - ..Default::default() - } - }); + }); viewport_tooltip(button, title, command) }; @@ -317,27 +131,42 @@ pub(super) fn viewport_controls<'a>( ); let preview_mode = render_mode_preview.unwrap_or(render_mode); - let mut choices = column![].spacing(2).width(Length::Fixed(174.0)); + let max_label_len = render_modes + .iter() + .map(|c| c.to_string().chars().count()) + .max() + .unwrap_or(0); + let dropdown_w = (max_label_len as f32 * 7.5 + 46.0).max(180.0); + + let mut choices = column![].spacing(2).width(Length::Fill); for choice in render_modes { let highlighted = choice.0 == preview_mode; let selected = choice.0 == render_mode; - let option = container(text(choice.to_string()).size(11).width(Length::Fill)) - .padding([6, 8]) + let checkmark = crate::ui::icons::themed_check_cell(selected); + let label = text(choice.to_string()) + .size(11) + .wrapping(iced::advanced::text::Wrapping::None); + let row_content = row![checkmark, label] + .spacing(6) + .align_y(iced::alignment::Vertical::Center); + let option = container(row_content) + .padding([5, 8]) .width(Length::Fill) .style(move |theme: &Theme| { let palette = theme.palette(); - let active = highlighted; + let bg = if highlighted { + Some(Background::Color(palette.background.strong.color)) + } else { + None + }; + let text_color = if highlighted || selected { + palette.background.strong.text + } else { + palette.background.base.text + }; container::Style { - background: active.then_some(Background::Color( - if selected { palette.primary.weak.color } else { palette.background.strong.color }, - )), - text_color: if selected { - Some(palette.primary.weak.text) - } else if active { - Some(palette.background.strong.text) - } else { - Some(palette.background.base.text) - }, + background: bg, + text_color: Some(text_color), border: Border { radius: 3.0.into(), ..Default::default() @@ -345,47 +174,16 @@ pub(super) fn viewport_controls<'a>( ..Default::default() } }); - choices = choices.push(mouse_area(option) - .interaction(iced::mouse::Interaction::Pointer) - .on_enter(Message::PreviewRenderMode(choice.0)) - .on_press(Message::SetRenderMode(choice.0))); + choices = choices.push( + mouse_area(option) + .interaction(iced::mouse::Interaction::Pointer) + .on_enter(Message::PreviewRenderMode(choice.0)) + .on_press(Message::SetRenderMode(choice.0)), + ); } - let preview = container( - column![ - container( - text(RenderModeChoice(preview_mode).to_string()) - .size(12) - .wrapping(iced::advanced::text::Wrapping::None), - ) - .width(Length::Fixed(215.0)) - .height(Length::Fixed(16.0)) - .align_y(iced::alignment::Vertical::Center), - canvas(RenderModePreview { mode: preview_mode }) - .width(Length::Fixed(215.0)) - .height(Length::Fixed(154.0)), - ] - .spacing(6), - ) - .padding(8) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.base.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 3.0.into(), - }, - text_color: Some(palette.background.base.text), - ..Default::default() - } - }); - let popup = container( - row![choices, preview] - .spacing(6) - .align_y(iced::alignment::Vertical::Top), - ) - .padding(6) + let popup = container(choices) + .padding(4) + .width(Length::Fixed(dropdown_w)) .style(|theme: &Theme| { let palette = theme.palette(); container::Style { @@ -399,31 +197,27 @@ pub(super) fn viewport_controls<'a>( ..Default::default() } }); - let picker: Element<'a, Message> = iced_aw::DropDown::new( - picker_head, - popup, - render_mode_menu_open, - ) - .alignment(iced_aw::drop_down::Alignment::Bottom) - .offset(3.0) - .on_dismiss(Message::DismissRenderModeMenu) - .into(); + let picker: Element<'a, Message> = + iced_aw::DropDown::new(picker_head, popup, render_mode_menu_open) + .width(Length::Fixed(dropdown_w)) + .alignment(iced_aw::drop_down::Alignment::Bottom) + .offset(3.0) + .on_dismiss(Message::DismissRenderModeMenu) + .into(); // Thin vertical divider between control groups. let sep = || { container(iced::widget::Space::new().width(1.0).height(16.0)).style(|theme: &Theme| { iced::widget::container::Style { background: Some(Background::Color( - theme.palette().background.neutral.color.scale_alpha(0.7) + theme.palette().background.neutral.color.scale_alpha(0.7), )), ..Default::default() } }) }; - let mut bar = row![] - .spacing(3) - .align_y(iced::alignment::Vertical::Center); + let mut bar = row![].spacing(3).align_y(iced::alignment::Vertical::Center); bar = bar .push(icon_btn( crate::ui::icons::GRID, @@ -466,29 +260,28 @@ pub(super) fn viewport_controls<'a>( // would only fire on release). Placed just left of Close. if tile_count > 1 { let drag = mouse_area( - container(crate::ui::icons::themed_success(crate::ui::icons::MOVE, 15.0)) - .padding([4, 6]) - .style(|_: &Theme| iced::widget::container::Style { - border: Border { - radius: 3.0.into(), - ..Default::default() - }, + container(crate::ui::icons::themed_success( + crate::ui::icons::MOVE, + 15.0, + )) + .padding([4, 6]) + .style(|_: &Theme| iced::widget::container::Style { + border: Border { + radius: 3.0.into(), ..Default::default() - }), + }, + ..Default::default() + }), ) .interaction(iced::mouse::Interaction::Grab) .on_press(Message::PaneMoveStart); let drag = viewport_tooltip(drag, crate::tr!("viewport", "move"), "VPORTS"); - bar = bar - .push(sep()) - .push(drag) - .push(sep()) - .push(danger_btn( - crate::ui::icons::CLOSE, - Message::CloseModelViewport, - crate::tr!("viewport", "close"), - "VPORTS SINGLE", - )); + bar = bar.push(sep()).push(drag).push(sep()).push(danger_btn( + crate::ui::icons::CLOSE, + Message::CloseModelViewport, + crate::tr!("viewport", "close"), + "VPORTS SINGLE", + )); } } @@ -497,15 +290,15 @@ pub(super) fn viewport_controls<'a>( .style(|theme: &Theme| { let palette = theme.palette(); iced::widget::container::Style { - background: Some(Background::Color( - palette.background.weak.color.scale_alpha(0.92) - )), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() + background: Some(Background::Color( + palette.background.weak.color.scale_alpha(0.92), + )), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() } }) .into() @@ -546,8 +339,10 @@ pub(super) fn dyn_component_value( // the user separates the values with `,` the entry is a cartesian // coordinate pair, so the fields read as signed X/Y deltas to match the // committed point (#269). - let wh = matches!(f.role, crate::command::DynRole::Width | crate::command::DynRole::Height) - && relative + let wh = matches!( + f.role, + crate::command::DynRole::Width | crate::command::DynRole::Height + ) && relative && !comma_cartesian; match f.component { DynComponent::X if relative => format!("{:.4}", if wh { dx.abs() } else { dx }), @@ -558,15 +353,107 @@ pub(super) fn dyn_component_value( DynComponent::Z => format!("{:.4}", p.z), // Scaled by the role so a diameter box reads twice the radius. DynComponent::Distance => { - format!("{:.4}", (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64) + format!( + "{:.4}", + (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64 + ) } // Shared rule: unsigned magnitude of the short angle, so CW (below the // reference axis) reads positive (e.g. 30°, not -30°/330°). The // committed value stays signed (see dyn_resolve_point). DynComponent::Angle => { - format!("{:.1}", crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32)) + format!( + "{:.1}", + crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32) + ) } // Typed-only scalar — no geometric value to track when empty. DynComponent::Scalar => String::new(), } } + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::ViewportRenderMode as M; + + #[test] + fn test_viewport_controls_construction() { + for style in crate::modules::view::visual_style::VISUAL_STYLES { + let _el = viewport_controls(style.mode, true, true, true, 1, false, None); + let _el_open = viewport_controls( + style.mode, + false, + false, + false, + 2, + true, + Some(M::Wireframe3D), + ); + } + } + + use crate::ui::style::common::wcag_contrast; + + #[test] + fn test_visual_style_dropdown_contrast_across_all_themes() { + for theme in iced::Theme::ALL { + let p = theme.palette(); + let bg = p.background.weak.color; + + // 1. Text contrast (WCAG standard AA for normal text is >= 4.5:1, large/ui >= 3:1): + let strong_text = p.background.strong.text; + let text_contrast = wcag_contrast(strong_text, bg); + assert!( + text_contrast >= 4.5, + "Theme {:?} selected text contrast {:.2} on background is too low", + theme, + text_contrast + ); + + let base_text = p.background.base.text; + let base_contrast = wcag_contrast(base_text, bg); + assert!( + base_contrast >= 4.5, + "Theme {:?} unselected text contrast {:.2} on background is too low", + theme, + base_contrast + ); + + // 2. Checkmark contrast: + let pri = p.primary.base.color; + let pri_contrast = wcag_contrast(pri, bg); + let tick_color = if pri_contrast >= 2.0 { + pri + } else { + p.background.base.text + }; + let final_tick_contrast = wcag_contrast(tick_color, bg); + assert!( + final_tick_contrast >= 2.0, + "Theme {:?} tick contrast {:.2} on background is too low", + theme, + final_tick_contrast + ); + } + } + + #[test] + fn test_visual_style_dropdown_width_accommodates_all_items() { + let max_label_len = crate::modules::view::visual_style::VISUAL_STYLES + .iter() + .map(|c| c.label.chars().count()) + .max() + .unwrap_or(0); + let dropdown_w = (max_label_len as f32 * 7.5 + 46.0).max(180.0); + + // Longest default label is "Gouraud Shaded + Edges" (22 chars) + assert_eq!(max_label_len, 22); + // Computed width should easily fit the 22-char label + checkmark + padding + assert!(dropdown_w >= 210.0); + + for style in crate::modules::view::visual_style::VISUAL_STYLES { + assert!(!style.label.contains('\n'), "Label should be a single line"); + } + } +} diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index c9232598a..3ab4a610c 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -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] diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 6f38ec873..384929a93 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -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 Widget 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 { } 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 { 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##""##, &theme); + let themed = recolor_semantic_svg(br##""##, &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 { + fn themed_cache_get_handle_for_test(ptr: *const u8, len: usize) -> Option { 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)); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d42db3013..42e2e52d4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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; diff --git a/src/ui/style/common.rs b/src/ui/style/common.rs index a1e15859e..8a8bca239 100644 --- a/src/ui/style/common.rs +++ b/src/ui/style/common.rs @@ -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). diff --git a/src/ui/theme_accessibility_tests.rs b/src/ui/theme_accessibility_tests.rs new file mode 100644 index 000000000..576d167f5 --- /dev/null +++ b/src/ui/theme_accessibility_tests.rs @@ -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 + ); + } +} +