Files
Ely-GPUI-Components/src/tooling/a11y.rs
T
ZacharyZhang-NY a7e492f28e Tooling · Catalogs: the audit reads active and code; avatar and container sizes
The audit now holds fg_subtle on active, a selected row's fill, and
each syntax color over the editor's surface, a code block and the
current line. What it found is fixed at the source: fg_subtle is
#696764 light and #94928f dark, syntax.comment follows it, and the
light number, constant and attribute darken to #a3591b, #aa5529 and
#9f5d12. The token viewer adds avatar and container sizes, draws a
container at its share of the widest so it stays in its row, and
names what it shows: the shared colors and scales. Tests pin every
variant, the hue each chart row reads and the code rows.
2026-09-28 08:57:52 -04:00

282 lines
9.2 KiB
Rust

use gpui::{
App, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled,
Window, div,
};
use crate::{
data_display::{Badge, Tone},
devtools::{contrast, over},
theme::{ActiveTheme, HUE_NAMES, Palette, Radius, Syntax, TextSize},
typography::{Ellipsis, literal, tabular},
};
/// What body text needs on its surface, as WCAG 1.4.3 has it.
const TEXT: f32 = 4.5;
/// What a mark needs beside its surface, as WCAG 1.4.11 has it.
const MARK: f32 = 3.0;
/// The text colors Ely draws, each over the surfaces it sits on.
const TEXT_PAIRS: &[(&str, &[&str])] = &[
(
"fg",
&["bg", "surface", "sunken", "overlay", "hover", "active"],
),
(
"fg_muted",
&["bg", "surface", "sunken", "overlay", "hover", "active"],
),
(
"fg_subtle",
&["bg", "surface", "sunken", "overlay", "hover", "active"],
),
("link", &["bg", "surface", "sunken"]),
("on_accent", &["accent", "accent_hover"]),
("tooltip_fg", &["tooltip_bg"]),
("success", &["bg", "success_subtle"]),
("warning", &["bg", "warning_subtle"]),
("danger", &["bg", "danger_subtle"]),
("info", &["bg", "info_subtle"]),
];
/// The surfaces code sits on: the editor, a code block, the editor's current line.
const CODE: [&str; 3] = ["surface", "sunken", "hover"];
/// A color, what it stands on, the contrast between them, and the contrast it needs.
#[derive(Clone, Debug)]
struct Check {
front: String,
back: &'static str,
fg: Hsla,
bg: Hsla,
ratio: f32,
need: f32,
}
impl Check {
fn passes(&self) -> bool {
self.ratio >= self.need
}
fn grade(&self) -> &'static str {
match self.passes() {
false => "Fails",
true if self.need == TEXT && self.ratio >= 7.0 => "AAA",
true => "AA",
}
}
}
/// Every pair in `palette`: its text over its surfaces and its code colors over the code surfaces at 4.5:1, the focus ring and the chart hues beside the page at 3:1.
fn audit(palette: &Palette) -> Vec<Check> {
let check = |front: String, fg: Hsla, back: &'static str, need: f32| {
let bg = palette.token(back);
Check {
front,
back,
fg,
bg,
ratio: contrast(over(fg, bg), bg),
need,
}
};
let text = TEXT_PAIRS.iter().flat_map(|(front, backs)| {
backs
.iter()
.map(move |back| check(front.to_string(), palette.token(front), back, TEXT))
});
let code = Syntax::NAMES.iter().flat_map(|name| {
CODE.iter().map(move |back| {
check(
format!("syntax.{name}"),
palette.syntax.token(name),
back,
TEXT,
)
})
});
let focus = ["bg", "surface"]
.into_iter()
.map(|back| check("focus".into(), palette.focus, back, MARK));
let hues = HUE_NAMES.iter().enumerate().map(|(ix, name)| {
check(
format!("chart {name}"),
palette.hue(ix, "a11y checker"),
"bg",
MARK,
)
});
text.chain(code).chain(focus).chain(hues).collect()
}
/// Audits the theme's contrast in the mode shown: each text color over the surfaces it sits on and each code color over the editor's surfaces at 4.5:1, the focus ring and the chart hues beside the page at 3:1, failures first under a count. It reads the theme as it stands, an owner's palette and high contrast included.
#[derive(IntoElement, Default)]
pub struct A11yChecker;
impl A11yChecker {
pub fn new() -> Self {
Self
}
}
impl RenderOnce for A11yChecker {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let mut checks = audit(colors);
checks.sort_by_key(Check::passes);
let (total, failing) = (checks.len(), checks.iter().filter(|c| !c.passes()).count());
let summary = match failing {
0 => Badge::new(format!("All {total} pass")).tone(Tone::Success),
n => Badge::new(format!("{n} of {total} fail")).tone(Tone::Danger),
};
let rows = checks.into_iter().map(|check| {
let (label, grade) = (format!("{} on {}", check.front, check.back), check.grade());
let shown = format!("contrast-{}-{}-{grade}", check.front, check.back);
div()
.debug_selector(move || shown)
.flex()
.items_center()
.gap_3()
.child(
div()
.flex_none()
.px_2()
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(colors.border)
.bg(check.bg)
.text_color(check.fg)
.font_weight(FontWeight::SEMIBOLD)
.child("Aa"),
)
.child(
literal(div())
.flex_1()
.min_w_0()
.child(Ellipsis::new(label)),
)
.child(
tabular(div())
.flex_none()
.text_color(colors.fg_muted)
.child(format!("{:.2}:1", check.ratio)),
)
.child(
div()
.flex_none()
.child(Badge::new(grade).tone(match check.passes() {
true => Tone::Success,
false => Tone::Danger,
})),
)
});
div()
.flex()
.flex_col()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.debug_selector(move || format!("contrast-summary-{total}-{failing}"))
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(div().font_weight(FontWeight::SEMIBOLD).child("Contrast"))
.child(summary),
)
.children(rows)
}
}
#[cfg(test)]
mod tests {
use super::{Check, audit};
use crate::theme::Palette;
#[test]
fn every_ely_palette_passes_its_audit() {
for high_contrast in [false, true] {
for palette in [Palette::light(high_contrast), Palette::dark(high_contrast)] {
let failing: Vec<String> = audit(&palette)
.iter()
.filter(|check| !check.passes())
.map(|check| format!("{} on {} {:.2}", check.front, check.back, check.ratio))
.collect();
assert!(
failing.is_empty(),
"high contrast {high_contrast}: {failing:?}"
);
}
}
}
#[test]
fn a_pair_under_its_need_fails() {
let mut palette = Palette::light(false);
palette.fg_subtle = palette.bg;
let checks = audit(&palette);
let subtle: Vec<&Check> = checks.iter().filter(|c| c.front == "fg_subtle").collect();
assert!(subtle.iter().all(|check| check.grade() == "Fails"));
assert_eq!(
checks.len(),
81,
"32 text pairs, 39 for code, 2 for the ring, 8 hues"
);
assert_eq!(subtle.len(), 6);
}
#[test]
fn a_mark_needs_less_than_text() {
let mut palette = Palette::light(false);
let gray = gpui::rgb(0x8a8a8a).into();
(palette.focus, palette.fg_muted) = (gray, gray);
let checks = audit(&palette);
let grade = |front: &str| {
checks
.iter()
.find(|c| c.front == front && c.back == "bg")
.map(Check::grade)
};
assert_eq!(grade("focus"), Some("AA"), "a ring at 3.3:1");
assert_eq!(grade("fg_muted"), Some("Fails"), "text at 3.3:1");
}
#[test]
fn code_and_hues_read_their_own_colors() {
let mut palette = Palette::light(false);
palette.syntax.comment = palette.surface;
let checks = audit(&palette);
let failing: Vec<&str> = checks
.iter()
.filter(|check| !check.passes())
.map(|check| check.back)
.collect();
assert_eq!(
failing,
["surface", "sunken", "hover"],
"the comment over code"
);
for (ix, name) in crate::theme::HUE_NAMES.iter().enumerate() {
let hue = checks.iter().find(|c| c.front == format!("chart {name}"));
assert_eq!(hue.map(|check| check.fg), Some(palette.chart[ix]));
}
}
#[test]
fn translucent_text_counts_as_it_shows() {
let mut palette = Palette::light(false);
palette.fg_muted = palette.fg.alpha(0.2);
let checks = audit(&palette);
let muted = checks
.iter()
.find(|c| c.front == "fg_muted" && c.back == "bg");
assert_eq!(
muted.map(Check::grade),
Some("Fails"),
"a fifth of fg over the page"
);
}
}