Tooling · Catalogs: token viewer, contrast audit; fg_subtle meets AA
DesignTokenViewer shows every token of the theme in the mode shown, named as code reads it: colors with their hex, syntax, chart and terminal colors, the type scale, radii, control and icon sizes, elevations and spacing. A11yChecker audits each text color over the surfaces it sits on at 4.5:1 and the focus ring and chart hues at 3:1, failures first. The audit found fg_subtle under 4.5:1 on hover and overlay; it is #6f6d6a light and #8c8a87 dark now, and a test holds all four Ely palettes to the audit. IconBrowser points to forms::IconPicker and the ScreenshotTest Harness to the gallery's capture, each with a section on the tooling page.
This commit is contained in:
1 parent
bfd1285064
commit
d5271d03ea
11 files changed
+757
-8
No files matched your search
@@ -0,0 +1,242 @@
|
||||
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, 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"],
|
||||
),
|
||||
("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"]),
|
||||
];
|
||||
|
||||
/// 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 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 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(focus).chain(hues).collect()
|
||||
}
|
||||
|
||||
/// Audits the theme's contrast in the mode shown: each text color over the surfaces it sits on 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(), 41, "31 text pairs, 2 for the ring, 8 hues");
|
||||
}
|
||||
|
||||
#[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 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user