Strengthen palette roles and source selection contrast
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user