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
+7 -7
View File
@@ -61,13 +61,13 @@ impl Highlighter for ArcadiaHighlighter {
pub fn format(class: &TokenClass, _theme: &Theme) -> Format<Font> { pub fn format(class: &TokenClass, _theme: &Theme) -> Format<Font> {
let color = match class { let color = match class {
TokenClass::Keyword => Color::from_rgb8(118, 62, 160), TokenClass::Keyword => Color::from_rgb8(51, 23, 72),
TokenClass::Identifier => Color::from_rgb8(20, 93, 139), TokenClass::Identifier => Color::from_rgb8(4, 39, 61),
TokenClass::String => Color::from_rgb8(24, 113, 79), TokenClass::String => Color::from_rgb8(4, 42, 27),
TokenClass::Number => Color::from_rgb8(163, 78, 30), TokenClass::Number => Color::from_rgb8(63, 26, 6),
TokenClass::Comment => Color::from_rgb8(111, 121, 132), TokenClass::Comment => Color::from_rgb8(33, 37, 41),
TokenClass::Error => Color::from_rgb8(186, 38, 62), TokenClass::Error => Color::from_rgb8(76, 9, 20),
TokenClass::Punctuation | TokenClass::Whitespace => Color::from_rgb8(91, 101, 117), TokenClass::Punctuation | TokenClass::Whitespace => Color::from_rgb8(32, 37, 44),
}; };
Format { Format {
color: Some(color), color: Some(color),
+64 -13
View File
@@ -1,26 +1,75 @@
use iced::{ use iced::{
Border, Color, Theme, Border, Color, Theme,
widget::{button, container}, theme::palette::{Extended, Pair},
widget::{button, container, text_editor},
}; };
use oa_core::Kind; use oa_core::Kind;
pub const BG: Color = Color::from_rgb(0.945, 0.956, 0.969); 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 INK: Color = Color::from_rgb8(24, 33, 44);
pub const MUTED: Color = Color::from_rgb(0.43, 0.48, 0.55); pub const MUTED: Color = Color::from_rgb8(75, 89, 104);
pub const TEAL: Color = Color::from_rgb(0.0, 0.48, 0.43); 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 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 { pub fn theme() -> Theme {
Theme::custom( Theme::custom_with_fn(
"Arcadia Light", "Arcadia Light",
iced::theme::Palette { iced::theme::Palette {
background: BG, background: BG,
text: INK, text: INK,
primary: TEAL, primary: TEAL,
success: 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), 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 { pub fn card(_: &Theme) -> container::Style {
container::Style { container::Style {
background: Some(Color::WHITE.into()), 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 { pub fn selected(theme: &Theme, status: button::Status) -> button::Style {
let mut s = nav(theme, status); let mut s = nav(theme, status);
s.background = Some(Color::from_rgb8(218, 238, 235).into()); s.background = Some(SELECTION.into());
s.text_color = INK;
s.border = Border { s.border = Border {
color: Color::from_rgb8(151, 203, 195), color: TEAL,
width: 1., width: 2.,
radius: 7.into(), radius: 7.into(),
}; };
s s
@@ -78,10 +126,10 @@ pub fn selected(theme: &Theme, status: button::Status) -> button::Style {
pub fn kind_color(kind: Kind) -> Color { pub fn kind_color(kind: Kind) -> Color {
match kind { match kind {
Kind::Entity | Kind::System => Color::from_rgb(0.16, 0.40, 0.71), 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::Activity | Kind::Function => TEAL,
Kind::Capability | Kind::SystemCapability | Kind::Mission | Kind::OperationalMission => { Kind::Capability | Kind::SystemCapability | Kind::Mission | Kind::OperationalMission => {
Color::from_rgb(0.72, 0.47, 0.10) WARNING
} }
Kind::Process Kind::Process
| Kind::FunctionalChain | Kind::FunctionalChain
@@ -94,6 +142,9 @@ pub fn kind_color(kind: Kind) -> Color {
| Kind::Mode | Kind::Mode
| Kind::Initial | Kind::Initial
| Kind::Final => Color::from_rgb(0.38, 0.39, 0.68), | 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;
+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);
}
+1
View File
@@ -1186,6 +1186,7 @@ fn source(app: &App) -> View<'_, Message> {
} }
let editor = widget::text_editor(&app.source_editor) let editor = widget::text_editor(&app.source_editor)
.id("source-editor") .id("source-editor")
.style(style::source_editor)
.on_action(Message::SourceEdit) .on_action(Message::SourceEdit)
.font(Font::MONOSPACE) .font(Font::MONOSPACE)
.size(14) .size(14)