a11y: a skip link, color-blind safe charts

i18n::SkipLink holds a Tab stop in flow at zero size, first where it
sits, and while focused raises a pill over the page; Enter, Space or a
press hands focus to its target. gpui orders stops by tab_index before
paint and registers none for an invisible element, so the stop stays in
flow and only the pill is raised. Theme::set_color_blind_safe swaps Ely's
chart hues for a set fitted to stay apart under Machado's protanopia,
deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab
and each hue 3:1 on its page, and red against green shows the measure
bites. KeyboardNavigationHelper points to interaction::RovingFocus. The
announcer, labels and screen-reader text stay blocked: gpui 0.2.2 has no
accessibility tree, and nothing here is trusted to observe VoiceOver.
The i18n tests move under src/i18n/tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 20:00:26 -04:00
1 parent ff8f4ba84f
commit 6265afbb01
10 files changed
+463 -7

No files matched your search

+2
View File
@@ -1,4 +1,5 @@
mod locale;
mod skip;
#[cfg(test)]
mod tests;
@@ -7,6 +8,7 @@ use std::collections::HashMap;
use gpui::{App, Global, SharedString};
pub use locale::{Direction, LOCALES, Locale};
pub use skip::SkipLink;
type Catalog = HashMap<&'static str, &'static str>;
+70
View File
@@ -0,0 +1,70 @@
use gpui::{
App, ElementId, FocusHandle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, anchored, div, prelude::*,
};
use crate::{
primitives::{raise, tab_stop},
theme::{ActiveTheme, Elevation, Radius, TextSize},
};
/// A link that shows only while focused and hands focus to `target`. Put it before what a keyboard would otherwise tab through, such as a long nav; it takes no room at rest and lies over the page while shown.
#[derive(IntoElement)]
pub struct SkipLink {
id: ElementId,
label: SharedString,
target: FocusHandle,
}
impl SkipLink {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
target: &FocusHandle,
) -> Self {
Self {
id: id.into(),
label: label.into(),
target: target.clone(),
}
}
}
impl RenderOnce for SkipLink {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let handle = tab_stop(self.id.clone(), true, window, cx);
let shown = handle.is_focused(window);
let theme = cx.theme();
let (target, label) = (self.target, self.label.clone());
let skip = move |window: &mut Window| {
log::info!("skip link {label:?}: focus to its target");
window.focus(&target);
};
let press = skip.clone();
let pill = div()
.id((self.id.clone(), "pill"))
.occlude()
.px_3()
.py_1p5()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.overlay)
.border_1()
.border_color(theme.colors.focus)
.shadow(theme.elevation(Elevation::Floating))
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg)
.cursor_pointer()
.child(self.label)
.on_click(move |_, window, _| press(window));
div()
.id(self.id.clone())
.track_focus(&handle)
.on_click(move |_, window, _| skip(window))
.when(shown, |link| {
link.child(raise(
self.id.clone(),
anchored().snap_to_window().child(div().p_2().child(pill)),
))
})
}
}
+230
View File
@@ -0,0 +1,230 @@
use gpui::{
Context, FocusHandle, Hsla, InteractiveElement, IntoElement, KeyBinding, KeyUpEvent, Keystroke,
Modifiers, ParentElement, Render, Rgba, StatefulInteractiveElement, Styled, TestAppContext,
VisualTestContext, Window, div, point, px, rgb,
};
use crate::{
devtools::contrast,
i18n::SkipLink,
primitives::{FocusNext, FocusScope},
theme::{ActiveTheme, Mode, Theme},
};
/// Machado, Oliveira and Fernandes (2009) at severity 1, on linear sRGB.
const DICHROMACY: [(&str, [[f32; 3]; 3]); 3] = [
(
"protanopia",
[
[0.152286, 1.052583, -0.204868],
[0.114503, 0.786281, 0.099216],
[-0.003882, -0.048116, 1.051998],
],
),
(
"deuteranopia",
[
[0.367322, 0.860646, -0.227968],
[0.280085, 0.672501, 0.047413],
[-0.011820, 0.042940, 0.968881],
],
),
(
"tritanopia",
[
[1.255528, -0.076749, -0.178779],
[-0.078411, 0.930809, 0.147602],
[0.004733, 0.691367, 0.303900],
],
),
];
/// A color in linear sRGB, as an eye with `matrix` sees it.
fn seen(color: Hsla, matrix: Option<&[[f32; 3]; 3]>) -> [f32; 3] {
let rgb = Rgba::from(color);
let linear = [rgb.r, rgb.g, rgb.b].map(|channel| {
if channel <= 0.04045 {
channel / 12.92
} else {
((channel + 0.055) / 1.055).powf(2.4)
}
});
match matrix {
None => linear,
Some(matrix) => matrix.map(|row| {
(row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2]).clamp(0.0, 1.0)
}),
}
}
/// Björn Ottosson's Oklab.
fn oklab([r, g, b]: [f32; 3]) -> [f32; 3] {
let l = (0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt();
let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt();
let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt();
[
0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s,
1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s,
0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s,
]
}
/// The two nearest colors' Oklab distance, in hundredths, as `matrix` sees them.
fn nearest(colors: &[Hsla], matrix: Option<&[[f32; 3]; 3]>) -> f32 {
let labs: Vec<_> = colors
.iter()
.map(|&color| oklab(seen(color, matrix)))
.collect();
let mut near = f32::MAX;
for (ix, a) in labs.iter().enumerate() {
for b in &labs[ix + 1..] {
let distance = a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::<f32>();
near = near.min(distance.sqrt() * 100.0);
}
}
near
}
/// Red and green, far apart for most eyes, run together for deuteranopia: the measure bites.
#[test]
fn red_and_green_run_together_for_deuteranopia() {
let pair = [rgb(0xd62728).into(), rgb(0x2ca02c).into()];
assert!(nearest(&pair, None) > 30.0);
assert!(nearest(&pair, Some(&DICHROMACY[1].1)) < 5.0);
}
/// Color-blind safe charts change Ely's chart hues alone: every pair stays apart for each dichromacy, and each hue is 3:1 on its page.
#[gpui::test]
fn color_blind_safe_hues_stay_apart(cx: &mut TestAppContext) {
cx.update(Theme::init);
for mode in [Mode::Light, Mode::Dark] {
cx.update(|cx| Theme::set_mode(mode, cx));
let own = cx.read(|cx| cx.theme().palette());
cx.update(|cx| Theme::set_color_blind_safe(true, cx));
let safe = cx.read(|cx| cx.theme().palette());
cx.update(|cx| Theme::set_color_blind_safe(false, cx));
let mut expected = own.clone();
expected.chart = safe.chart;
assert_eq!(safe, expected, "{mode:?}: only the chart changes");
assert_ne!(safe.chart, own.chart, "{mode:?}: the chart changes");
assert!(nearest(&safe.chart, None) >= 10.0, "{mode:?}");
for (name, matrix) in &DICHROMACY {
let near = nearest(&safe.chart, Some(matrix));
assert!(near >= 10.0, "{mode:?} for {name}: {near}");
}
for hue in safe.chart {
let ratio = contrast(hue, safe.bg);
assert!(ratio >= 3.0, "{mode:?}: {hue:?} at {ratio}");
}
}
}
/// A nav of three stops between a skip link and the main part.
struct Page {
root: FocusHandle,
nav: [FocusHandle; 3],
main: FocusHandle,
inside: FocusHandle,
}
impl Render for Page {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let stop = |ix: usize, handle: &FocusHandle| {
div()
.id(("nav", ix))
.track_focus(handle)
.h(px(24.0))
.on_click(|_, _, _| {})
};
FocusScope::new(&self.root)
.size_full()
.child(SkipLink::new("skip", "Skip to content", &self.main))
.children(
self.nav
.iter()
.enumerate()
.map(|(ix, handle)| stop(ix, handle)),
)
.child(
div()
.track_focus(&self.main)
.child(div().track_focus(&self.inside).h(px(24.0))),
)
}
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
cx.run_until_parked();
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
cx.run_until_parked();
}
fn page(cx: &mut TestAppContext) -> (gpui::Entity<Page>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
let (view, cx) = cx.add_window_view(|_, cx| Page {
root: cx.focus_handle(),
nav: [0, 1, 2].map(|_| cx.focus_handle().tab_stop(true)),
main: cx.focus_handle(),
inside: cx.focus_handle().tab_stop(true),
});
cx.run_until_parked();
(view, cx)
}
fn focused(
view: &gpui::Entity<Page>,
pick: fn(&Page) -> &FocusHandle,
cx: &mut VisualTestContext,
) -> bool {
let handle = view.read_with(cx, |page, _| pick(page).clone());
cx.update(|window, _| handle.is_focused(window))
}
/// Tab from the page's start reaches the skip link before the nav; Enter hands focus to the main part, and the next Tab lands inside it.
#[gpui::test]
fn a_skip_link_leads_past_the_nav(cx: &mut TestAppContext) {
let (view, cx) = page(cx);
let root = view.read_with(cx, |page, _| page.root.clone());
cx.update(|window, _| window.focus(&root));
press("tab", cx);
assert!(
!focused(&view, |page| &page.nav[0], cx),
"the link comes first"
);
assert!(!focused(&view, |page| &page.root, cx));
press("enter", cx);
assert!(focused(&view, |page| &page.main, cx), "Enter skips");
press("tab", cx);
assert!(
focused(&view, |page| &page.inside, cx),
"Tab goes on inside"
);
}
/// The link shows while focused, so a press at its place skips; at rest nothing lies there and a press reaches the nav.
#[gpui::test]
fn a_skip_link_shows_only_while_focused(cx: &mut TestAppContext) {
let (view, cx) = page(cx);
let spot = point(px(16.0), px(16.0));
cx.simulate_click(spot, Modifiers::none());
cx.run_until_parked();
assert!(
focused(&view, |page| &page.nav[0], cx),
"at rest the nav takes it"
);
let root = view.read_with(cx, |page, _| page.root.clone());
cx.update(|window, _| window.focus(&root));
press("tab", cx);
cx.simulate_click(spot, Modifiers::none());
cx.run_until_parked();
assert!(
focused(&view, |page| &page.main, cx),
"the shown link skips"
);
}
@@ -2,6 +2,9 @@ use jiff::{Timestamp, tz::TimeZone};
use super::{Direction, I18n, LOCALES, Locale, fill};
#[cfg(feature = "test-support")]
mod a11y;
fn i18n() -> I18n {
I18n::new("en-US")
.catalog(
+19 -2
View File
@@ -62,6 +62,7 @@ impl From<WindowAppearance> for Mode {
pub struct Theme {
mode: Mode,
high_contrast: bool,
color_blind_safe: bool,
transition: u64,
custom: [Option<Palette>; 2],
pub colors: Palette,
@@ -91,6 +92,7 @@ impl Theme {
cx.set_global(Theme {
mode: Mode::Light,
high_contrast: false,
color_blind_safe: false,
transition: 0,
custom: [None, None],
colors: Palette::light(false),
@@ -112,6 +114,10 @@ impl Theme {
self.high_contrast
}
pub fn color_blind_safe(&self) -> bool {
self.color_blind_safe
}
pub fn is_dark(&self) -> bool {
self.mode == Mode::Dark
}
@@ -129,7 +135,14 @@ impl Theme {
Self::fade_to_target(cx);
}
/// The owner's palette for `mode`, as an edited or imported theme gives, or none to bring Ely's back. The colors fade to it when `mode` shows. High contrast strengthens Ely's own palettes alone.
/// Chart hues that stay apart for color-blind eyes, in Ely's own palettes.
pub fn set_color_blind_safe(on: bool, cx: &mut App) {
log::info!("theme: color-blind safe charts -> {on}");
cx.global_mut::<Theme>().color_blind_safe = on;
Self::fade_to_target(cx);
}
/// The owner's palette for `mode`, as an edited or imported theme gives, or none to bring Ely's back. The colors fade to it when `mode` shows. High contrast and color-blind safe charts change Ely's own palettes alone.
pub fn set_palette(mode: Mode, palette: Option<Palette>, cx: &mut App) {
log::info!(
"theme: {mode:?} palette -> {}",
@@ -158,10 +171,14 @@ impl Theme {
if let Some(palette) = &self.custom[self.mode as usize] {
return palette.clone();
}
match self.mode {
let mut palette = match self.mode {
Mode::Light => Palette::light(self.high_contrast),
Mode::Dark => Palette::dark(self.high_contrast),
};
if self.color_blind_safe {
palette.chart = palette::color_blind_chart(self.mode);
}
palette
}
fn fade_to_target(cx: &mut App) {
+14
View File
@@ -1,5 +1,7 @@
use gpui::{Hsla, Rgba, rgb, rgba};
use super::Mode;
pub trait Mix {
fn mix(&self, to: &Self, t: f32) -> Self;
}
@@ -142,6 +144,18 @@ pub const HUE_NAMES: [&str; 8] = [
"Blue", "Teal", "Ochre", "Rose", "Violet", "Green", "Rust", "Cyan",
];
/// Chart hues each 3:1 on its mode's page that stay apart for protanopia, deuteranopia and tritanopia; `HUE_NAMES` still name them.
pub(crate) fn color_blind_chart(mode: Mode) -> [Hsla; 8] {
match mode {
Mode::Light => c8([
0x0240b1, 0x08a399, 0xcd8017, 0x844954, 0xa276e3, 0x184606, 0xb84f10, 0x20789d,
]),
Mode::Dark => c8([
0x3471e3, 0x57b6b2, 0xf6a537, 0xde545a, 0x695a91, 0xc4ecb7, 0xa93d0e, 0x52dafe,
]),
}
}
impl Palette {
/// The chart color at `hue`; a hue past the chart fails, naming `owner`.
pub fn hue(&self, hue: usize, owner: impl std::fmt::Display) -> Hsla {