From 8c3e7acd14296fa8ffd381c6e2f16b300571717c Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 12 Sep 2026 17:02:24 -0700 Subject: [PATCH] Strengthen palette roles and source selection contrast --- crates/oa-desktop/src/highlight.rs | 14 +- crates/oa-desktop/src/style.rs | 77 +++++-- crates/oa-desktop/src/style/tests.rs | 297 +++++++++++++++++++++++++++ crates/oa-desktop/src/views.rs | 1 + 4 files changed, 369 insertions(+), 20 deletions(-) create mode 100644 crates/oa-desktop/src/style/tests.rs diff --git a/crates/oa-desktop/src/highlight.rs b/crates/oa-desktop/src/highlight.rs index b1889181..619ff6b6 100644 --- a/crates/oa-desktop/src/highlight.rs +++ b/crates/oa-desktop/src/highlight.rs @@ -61,13 +61,13 @@ impl Highlighter for ArcadiaHighlighter { pub fn format(class: &TokenClass, _theme: &Theme) -> Format { let color = match class { - TokenClass::Keyword => Color::from_rgb8(118, 62, 160), - TokenClass::Identifier => Color::from_rgb8(20, 93, 139), - TokenClass::String => Color::from_rgb8(24, 113, 79), - TokenClass::Number => Color::from_rgb8(163, 78, 30), - TokenClass::Comment => Color::from_rgb8(111, 121, 132), - TokenClass::Error => Color::from_rgb8(186, 38, 62), - TokenClass::Punctuation | TokenClass::Whitespace => Color::from_rgb8(91, 101, 117), + TokenClass::Keyword => Color::from_rgb8(51, 23, 72), + TokenClass::Identifier => Color::from_rgb8(4, 39, 61), + TokenClass::String => Color::from_rgb8(4, 42, 27), + TokenClass::Number => Color::from_rgb8(63, 26, 6), + TokenClass::Comment => Color::from_rgb8(33, 37, 41), + TokenClass::Error => Color::from_rgb8(76, 9, 20), + TokenClass::Punctuation | TokenClass::Whitespace => Color::from_rgb8(32, 37, 44), }; Format { color: Some(color), diff --git a/crates/oa-desktop/src/style.rs b/crates/oa-desktop/src/style.rs index a9a5d6a5..c791b4eb 100644 --- a/crates/oa-desktop/src/style.rs +++ b/crates/oa-desktop/src/style.rs @@ -1,26 +1,75 @@ use iced::{ Border, Color, Theme, - widget::{button, container}, + theme::palette::{Extended, Pair}, + widget::{button, container, text_editor}, }; use oa_core::Kind; pub const BG: Color = Color::from_rgb(0.945, 0.956, 0.969); -pub const INK: Color = Color::from_rgb(0.13, 0.18, 0.25); -pub const MUTED: Color = Color::from_rgb(0.43, 0.48, 0.55); -pub const TEAL: Color = Color::from_rgb(0.0, 0.48, 0.43); +pub const INK: Color = Color::from_rgb8(24, 33, 44); +pub const MUTED: Color = Color::from_rgb8(75, 89, 104); +pub const TEAL: Color = Color::from_rgb8(0, 102, 92); +/// Decorative dividers; essential outlines use BOUNDARY instead. pub const LINE: Color = Color::from_rgb(0.86, 0.89, 0.91); +pub const BOUNDARY: Color = Color::from_rgb8(102, 117, 134); +const WARNING: Color = Color::from_rgb8(134, 91, 0); +const SELECTION: Color = Color::from_rgb8(217, 238, 232); +// Ordinary values remain INK; syntax selection has its own surface and palette. +const INPUT_SELECTION: Color = Color::from_rgb8(123, 142, 134); +const SOURCE_SELECTION: Color = Color::from_rgb8(128, 149, 139); +const SOURCE_INK: Color = Color::from_rgb8(33, 37, 41); pub fn theme() -> Theme { - Theme::custom( + Theme::custom_with_fn( "Arcadia Light", iced::theme::Palette { background: BG, text: INK, primary: TEAL, success: TEAL, - warning: Color::from_rgb(0.72, 0.47, 0.10), + warning: WARNING, danger: Color::from_rgb(0.78, 0.22, 0.24), }, + |palette| { + let mut extended = Extended::generate(palette); + // Pair::new adjusts text toward a 6:1 target. Keep these reviewed + // colors exact; resolved style tests enforce each consumer contract. + let pair = |color, text| Pair { color, text }; + extended.background.base = pair(BG, INK); + extended.background.weakest = pair(Color::WHITE, INK); + extended.background.weaker = pair(BG, INK); + // These pairs also supply pick-list/menu and disabled-control fills. + extended.background.weak = pair(Color::WHITE, INK); + extended.background.neutral = pair(LINE, INK); + extended.background.strong = pair(BOUNDARY, Color::WHITE); + extended.background.stronger = pair(MUTED, Color::WHITE); + extended.background.strongest = pair(MUTED, Color::WHITE); + extended.primary.base = pair(TEAL, Color::WHITE); + extended.primary.weak = pair(INPUT_SELECTION, INK); + extended.primary.strong = pair(Color::from_rgb8(0, 83, 75), Color::WHITE); + // Secondary base is both placeholder ink and a button background. + extended.secondary.base = pair(MUTED, Color::WHITE); + extended.secondary.weak = pair(LINE, INK); + extended.secondary.strong = pair(Color::from_rgb8(54, 68, 82), Color::WHITE); + extended.success.base = extended.primary.base; + extended.success.weak = pair(SELECTION, INK); + extended.success.strong = extended.primary.strong; + extended.warning.base = pair(WARNING, Color::WHITE); + extended.warning.weak = pair(Color::from_rgb8(250, 235, 203), INK); + extended.warning.strong = pair(Color::from_rgb8(103, 70, 0), Color::WHITE); + extended.danger.base = pair(palette.danger, Color::WHITE); + extended.danger.weak = pair(Color::from_rgb8(249, 227, 229), INK); + extended.danger.strong = pair(Color::from_rgb8(156, 34, 40), Color::WHITE); + extended + }, ) } +pub fn source_editor(theme: &Theme, status: text_editor::Status) -> text_editor::Style { + let mut style = text_editor::default(theme, status); + style.background = Color::WHITE.into(); + style.value = SOURCE_INK; + style.placeholder = SOURCE_INK; + style.selection = SOURCE_SELECTION; + style +} pub fn card(_: &Theme) -> container::Style { container::Style { background: Some(Color::WHITE.into()), @@ -66,11 +115,10 @@ pub fn nav(theme: &Theme, status: button::Status) -> button::Style { } pub fn selected(theme: &Theme, status: button::Status) -> button::Style { let mut s = nav(theme, status); - s.background = Some(Color::from_rgb8(218, 238, 235).into()); - s.text_color = INK; + s.background = Some(SELECTION.into()); s.border = Border { - color: Color::from_rgb8(151, 203, 195), - width: 1., + color: TEAL, + width: 2., radius: 7.into(), }; s @@ -78,10 +126,10 @@ pub fn selected(theme: &Theme, status: button::Status) -> button::Style { pub fn kind_color(kind: Kind) -> Color { match kind { Kind::Entity | Kind::System => Color::from_rgb(0.16, 0.40, 0.71), - Kind::Actor | Kind::SystemActor | Kind::Role => Color::from_rgb(0.58, 0.35, 0.70), + Kind::Actor | Kind::SystemActor | Kind::Role => Color::from_rgb8(117, 68, 153), Kind::Activity | Kind::Function => TEAL, Kind::Capability | Kind::SystemCapability | Kind::Mission | Kind::OperationalMission => { - Color::from_rgb(0.72, 0.47, 0.10) + WARNING } Kind::Process | Kind::FunctionalChain @@ -94,6 +142,9 @@ pub fn kind_color(kind: Kind) -> Color { | Kind::Mode | Kind::Initial | Kind::Final => Color::from_rgb(0.38, 0.39, 0.68), - _ => Color::from_rgb(0.25, 0.48, 0.56), + _ => Color::from_rgb8(50, 107, 128), } } + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/style/tests.rs b/crates/oa-desktop/src/style/tests.rs new file mode 100644 index 00000000..04ef9078 --- /dev/null +++ b/crates/oa-desktop/src/style/tests.rs @@ -0,0 +1,297 @@ +use super::*; +use iced::{ + Background, + widget::{checkbox, overlay::menu, pick_list, text_input}, +}; +use oa_core::language::TokenClass; + +fn luminance(color: Color) -> f64 { + [color.r, color.g, color.b] + .into_iter() + .zip([0.2126, 0.7152, 0.0722]) + .map(|(channel, weight)| { + let channel = f64::from(channel); + weight + * if channel <= 0.04045 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } + }) + .sum() +} + +fn contrast(first: Color, second: Color) -> f64 { + assert_eq!(first.a, 1.0, "opaque color contract: {first:?}"); + assert_eq!(second.a, 1.0, "opaque color contract: {second:?}"); + let first = luminance(first); + let second = luminance(second); + (first.max(second) + 0.05) / (first.min(second) + 0.05) +} + +fn require_contrast(label: &str, foreground: Color, background: Color, minimum: f64) { + let measured = contrast(foreground, background); + assert!( + measured >= minimum, + "{label}: {foreground:?} on {background:?} = {measured:.6}, required {minimum}" + ); +} + +fn solid(background: Background) -> Color { + match background { + Background::Color(color) => color, + Background::Gradient(_) => panic!("gradient needs a separate contrast evaluator"), + } +} + +#[test] +fn semantic_text_and_essential_boundaries_have_contrast_on_canvas_surfaces() { + for surface in [ + Color::WHITE, + BG, + Color::from_rgb8(246, 248, 251), + Color::from_rgb8(243, 248, 248), + Color::from_rgb8(234, 241, 248), + ] { + require_contrast("primary text", INK, surface, 4.5); + require_contrast("secondary text", MUTED, surface, 4.5); + require_contrast("essential outline", BOUNDARY, surface, 3.0); + } + for kind in Kind::ALL { + let color = kind_color(kind); + // The architecture painter uses this six-percent encoded-channel tint. + let tint = Color::from_rgb( + 0.94 + 0.06 * color.r, + 0.94 + 0.06 * color.g, + 0.94 + 0.06 * color.b, + ); + require_contrast(&format!("{kind:?} header"), color, tint, 4.5); + } + // Negative controls: do not accidentally treat the decorative divider or + // the old secondary-text palette as adequate for these semantic roles. + assert!(contrast(LINE, Color::from_rgb8(243, 248, 248)) < 3.0); + assert!(contrast(Color::from_rgb(0.43, 0.48, 0.55), BG) < 4.5); +} + +#[test] +fn source_editor_syntax_and_fallback_remain_readable_during_selection() { + let theme = theme(); + for status in [ + text_editor::Status::Active, + text_editor::Status::Hovered, + text_editor::Status::Focused { is_hovered: false }, + text_editor::Status::Focused { is_hovered: true }, + text_editor::Status::Disabled, + ] { + let style = source_editor(&theme, status); + let surface = solid(style.background); + assert_eq!(surface, Color::WHITE); + require_contrast("source selected range", style.selection, surface, 3.0); + require_contrast("source boundary", style.border.color, surface, 3.0); + require_contrast("source placeholder", style.placeholder, surface, 4.5); + for foreground in [style.value, style.placeholder] { + require_contrast("source fallback", foreground, surface, 4.5); + require_contrast("selected fallback", foreground, style.selection, 4.5); + } + for class in [ + TokenClass::Keyword, + TokenClass::Identifier, + TokenClass::String, + TokenClass::Number, + TokenClass::Comment, + TokenClass::Error, + TokenClass::Punctuation, + TokenClass::Whitespace, + ] { + let format = crate::highlight::format(&class, &theme); + assert!(format.font.is_none(), "palette must preserve font metrics"); + let foreground = format.color.expect("syntax class has explicit ink"); + require_contrast(&format!("{class:?} ordinary"), foreground, surface, 4.5); + require_contrast( + &format!("{class:?} selected"), + foreground, + style.selection, + 4.5, + ); + } + } + assert!(contrast(Color::from_rgb8(32, 44, 58), SOURCE_SELECTION) < 4.5); + assert!(contrast(Color::from_rgb8(111, 121, 132), SOURCE_SELECTION) < 4.5); +} + +#[test] +fn default_input_styles_keep_ordinary_values_and_visible_selection() { + let theme = theme(); + for status in [ + text_input::Status::Active, + text_input::Status::Hovered, + text_input::Status::Focused { is_hovered: false }, + text_input::Status::Focused { is_hovered: true }, + ] { + let style = text_input::default(&theme, status); + let surface = solid(style.background); + assert_eq!(surface, BG, "preserve ordinary input surface"); + assert_eq!(style.value, INK, "do not apply source syntax ink to inputs"); + assert_ne!(style.selection, SOURCE_SELECTION); + require_contrast("input value", style.value, surface, 4.5); + require_contrast("input placeholder", style.placeholder, surface, 4.5); + require_contrast("input icon", style.icon, surface, 3.0); + require_contrast("input boundary", style.border.color, surface, 3.0); + require_contrast( + "input boundary exterior", + style.border.color, + Color::WHITE, + 3.0, + ); + require_contrast("input selection", style.selection, surface, 3.0); + require_contrast("input selected value", style.value, style.selection, 4.5); + } + let disabled = text_input::default(&theme, text_input::Status::Disabled); + let surface = solid(disabled.background); + require_contrast("disabled input value", disabled.value, surface, 4.5); + require_contrast( + "disabled input placeholder", + disabled.placeholder, + surface, + 4.5, + ); + // Selection interaction is unavailable while disabled; its unused selected + // value pair is not an active text contrast requirement. +} + +#[test] +fn default_pick_list_and_menu_styles_cover_open_and_selected_states() { + let theme = theme(); + for status in [ + pick_list::Status::Active, + pick_list::Status::Hovered, + pick_list::Status::Opened { is_hovered: false }, + pick_list::Status::Opened { is_hovered: true }, + ] { + let style = pick_list::default(&theme, status); + let surface = solid(style.background); + require_contrast("pick-list value", style.text_color, surface, 4.5); + require_contrast( + "pick-list placeholder", + style.placeholder_color, + surface, + 4.5, + ); + require_contrast("pick-list handle", style.handle_color, surface, 3.0); + require_contrast("pick-list boundary", style.border.color, surface, 3.0); + require_contrast("pick-list exterior", style.border.color, BG, 3.0); + } + let style = menu::default(&theme); + let surface = solid(style.background); + let selection = solid(style.selected_background); + require_contrast("menu text", style.text_color, surface, 4.5); + require_contrast("menu boundary", style.border.color, surface, 3.0); + require_contrast("menu selected row", selection, surface, 3.0); + require_contrast( + "menu selected text", + style.selected_text_color, + selection, + 4.5, + ); +} + +#[test] +fn default_checkbox_styles_keep_checks_and_outlines_distinct() { + let theme = theme(); + for checked in [false, true] { + for status in [ + checkbox::Status::Active { + is_checked: checked, + }, + checkbox::Status::Hovered { + is_checked: checked, + }, + ] { + let style = checkbox::primary(&theme, status); + let surface = solid(style.background); + require_contrast("checkbox boundary exterior", style.border.color, BG, 3.0); + require_contrast("checkbox label", style.text_color.unwrap_or(INK), BG, 4.5); + if checked { + require_contrast("checkbox check", style.icon_color, surface, 3.0); + } else { + require_contrast( + "unchecked checkbox boundary", + style.border.color, + surface, + 3.0, + ); + } + } + } + let disabled = checkbox::primary(&theme, checkbox::Status::Disabled { is_checked: true }); + require_contrast( + "disabled check", + disabled.icon_color, + solid(disabled.background), + 3.0, + ); + // The disabled unchecked outline is intentionally exempt from the active + // control benchmark. It remains an inactive widget, not a missing checked cue. +} + +#[test] +fn button_pair_couplings_cover_hover_and_disabled_states() { + let theme = theme(); + for (name, resolve) in [ + ( + "primary", + button::primary as fn(&Theme, button::Status) -> button::Style, + ), + ("secondary", button::secondary), + ("success", button::success), + ("warning", button::warning), + ("danger", button::danger), + ] { + for status in [ + button::Status::Active, + button::Status::Hovered, + button::Status::Pressed, + ] { + let style = resolve(&theme, status); + let surface = solid(style.background.expect("filled button")); + require_contrast(name, style.text_color, surface, 4.5); + require_contrast("button silhouette", surface, BG, 3.0); + } + let disabled = resolve(&theme, button::Status::Disabled); + // Iced composites disabled colors with alpha; do not feed them into an + // opaque-pair contrast test or claim active-state contrast compliance. + assert!(disabled.text_color.a > 0.0 && disabled.text_color.a < 1.0); + let background = solid(disabled.background.expect("disabled filled button")); + assert!(background.a > 0.0 && background.a < 1.0); + } +} + +#[test] +fn selected_navigation_adds_a_visible_outline_and_preserves_disabled_ink() { + let theme = theme(); + for status in [ + button::Status::Active, + button::Status::Hovered, + button::Status::Pressed, + ] { + let ordinary = nav(&theme, status); + let chosen = selected(&theme, status); + let surface = solid(chosen.background.expect("selected navigation fill")); + assert!(chosen.border.width >= 2.0 && chosen.border.width > ordinary.border.width); + require_contrast("selected navigation text", chosen.text_color, surface, 4.5); + require_contrast( + "selected navigation interior", + chosen.border.color, + surface, + 3.0, + ); + require_contrast( + "selected navigation exterior", + chosen.border.color, + Color::WHITE, + 3.0, + ); + } + let disabled = selected(&theme, button::Status::Disabled); + assert!(disabled.text_color.a > 0.0 && disabled.text_color.a < 1.0); +} diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 832feb57..ddc1c2a5 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -1186,6 +1186,7 @@ fn source(app: &App) -> View<'_, Message> { } let editor = widget::text_editor(&app.source_editor) .id("source-editor") + .style(style::source_editor) .on_action(Message::SourceEdit) .font(Font::MONOSPACE) .size(14)