Strengthen palette roles and source selection contrast

This commit is contained in:
2026-09-12 17:21:13 -07:00
parent 26ca4a4c81
commit 8c3e7acd14
4 changed files with 369 additions and 20 deletions
+297
View File
@@ -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);
}