diff --git a/AGENTS.md b/AGENTS.md index 5138081..d85635b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike. - Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`). - Messages go through `i18n::I18n`, a global the app sets: `cx.global::().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui 0.2.2 wraps shaped glyphs in the order they are drawn, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment. +- gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it. +- Color-blind safe charts swap 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 (`Theme::set_color_blind_safe`, in Ely's own palettes alone). ## Decisions diff --git a/examples/gallery/pages/i18n/a11y.rs b/examples/gallery/pages/i18n/a11y.rs new file mode 100644 index 0000000..d92bf3d --- /dev/null +++ b/examples/gallery/pages/i18n/a11y.rs @@ -0,0 +1,101 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + forms::Switch, + i18n::SkipLink, + theme::{ActiveTheme, HUE_NAMES, Radius, Theme}, +}; +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, Window, + div, px, +}; + +use crate::probe::probe; +use crate::ui::{blocked, section, specimen, specimens}; + +const NAV: [&str; 5] = ["Home", "Docs", "Pricing", "Blog", "About"]; + +pub fn skip_link(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let main = window + .use_keyed_state("skip-main", cx, |_, cx| cx.focus_handle()) + .read(cx) + .clone(); + let theme = cx.theme(); + let nav = NAV.map(|name| { + let id = ElementId::Name(SharedString::from(format!("skip-nav-{name}"))); + Button::new(id, name).variant(ButtonVariant::Ghost) + }); + section( + "SkipLink · KeyboardNavigationHelper", + "A link that shows only while focused and moves focus past the nav to the main part: Tab into the card, then Enter. Arrows among a group's items live in interaction::RovingFocus.", + cx, + ) + .child( + probe( + "skip-demo", + div() + .max_w(px(480.0)) + .flex() + .flex_col() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .child(SkipLink::new("skip", "Skip to content", &main)) + .child( + div() + .flex() + .flex_wrap() + .gap_1() + .p_2() + .border_b_1() + .border_color(theme.colors.border) + .children(nav), + ) + .child( + div() + .track_focus(&main) + .flex() + .flex_col() + .items_start() + .gap_3() + .p_4() + .text_color(theme.colors.fg_muted) + .child("The main part starts here.") + .child(Button::new("skip-read", "Read the guide")), + ), + ), + ) + .child(blocked( + "LiveRegion / Announcer, AccessibleIcon, AriaLabel, ScreenReaderOnly: gpui 0.2.2 exposes no accessibility tree, so no label or live region reaches a screen reader.", + cx, + )) +} + +pub fn color_blind(cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + let on = theme.color_blind_safe(); + let swatches = HUE_NAMES + .iter() + .zip(theme.colors.chart) + .map(|(name, color)| { + specimen( + *name, + div() + .size(px(40.0)) + .rounded(theme.radius(Radius::Md)) + .bg(color), + cx, + ) + }); + section( + "ColorBlindSafe Palette", + "Ely's chart hues share one lightness, so protanopia, deuteranopia and tritanopia run some together. The safe hues differ in lightness too: every pair stays apart for each, and each is 3:1 on its page.", + cx, + ) + .child(probe( + "color-blind", + Switch::new("color-blind", on) + .label("Color-blind safe charts") + .on_change(|on, _, cx| Theme::set_color_blind_safe(on, cx)), + )) + .child(specimens().gap_3().children(swatches)) +} diff --git a/examples/gallery/pages/i18n/mod.rs b/examples/gallery/pages/i18n/mod.rs index 652f1b5..a092bb9 100644 --- a/examples/gallery/pages/i18n/mod.rs +++ b/examples/gallery/pages/i18n/mod.rs @@ -8,6 +8,8 @@ use ely_gpui_component::{ use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px}; use jiff::{Timestamp, tz::TimeZone}; +mod a11y; + use super::Page; use crate::probe::probe; use crate::script::Step; @@ -29,6 +31,19 @@ pub const PAGE: Page = Page { Step::Key("home"), Step::Key("enter"), Step::Wait(300), + Step::Key("tab"), + Step::HoverAt("skip-demo", 300.0, 70.0), + Step::Wait(300), + Step::Shot("skip-link"), + Step::Key("enter"), + Step::Key("tab"), + Step::Wait(300), + Step::Shot("skipped"), + Step::Click("color-blind"), + Step::Wait(300), + Step::Shot("color-blind"), + Step::Click("color-blind"), + Step::Wait(300), ], }; @@ -105,11 +120,13 @@ pub fn install(cx: &mut App) { ); } -fn render(_: &mut Window, cx: &mut App) -> AnyElement { +fn render(window: &mut Window, cx: &mut App) -> AnyElement { div() .child(messages(cx)) .child(formats(cx)) .child(mirrored(cx)) + .child(a11y::skip_link(window, cx)) + .child(a11y::color_blind(cx)) .into_any_element() } diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 25cc363..6c51a2d 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -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>; diff --git a/src/i18n/skip.rs b/src/i18n/skip.rs new file mode 100644 index 0000000..d68a0c3 --- /dev/null +++ b/src/i18n/skip.rs @@ -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, + label: impl Into, + 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)), + )) + }) + } +} diff --git a/src/i18n/tests/a11y.rs b/src/i18n/tests/a11y.rs new file mode 100644 index 0000000..0308d55 --- /dev/null +++ b/src/i18n/tests/a11y.rs @@ -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::(); + 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) -> 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, &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, + 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" + ); +} diff --git a/src/i18n/tests.rs b/src/i18n/tests/mod.rs similarity index 99% rename from src/i18n/tests.rs rename to src/i18n/tests/mod.rs index c1789ae..34cb5b1 100644 --- a/src/i18n/tests.rs +++ b/src/i18n/tests/mod.rs @@ -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( diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 77fb61b..e219779 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -62,6 +62,7 @@ impl From for Mode { pub struct Theme { mode: Mode, high_contrast: bool, + color_blind_safe: bool, transition: u64, custom: [Option; 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::().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, 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) { diff --git a/src/theme/palette.rs b/src/theme/palette.rs index 9759409..43c7c66 100644 --- a/src/theme/palette.rs +++ b/src/theme/palette.rs @@ -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 { diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 3aa511e..aa330f9 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -280,13 +280,13 @@ ## T40b a11y — `src/i18n` -- [ ] LiveRegion / Announcer — blocked: gpui 0.2.2 has no accessibility tree -- [ ] SkipLink +- [ ] LiveRegion / Announcer — blocked: gpui 0.2.2 has no accessibility tree; AppKit's announcement request needs none, but no process here is trusted to observe VoiceOver take it +- [x] SkipLink → `i18n::SkipLink`: a Tab stop in flow that shows only while focused, raised over the page, and hands focus to its target - [ ] AccessibleIcon — blocked: no accessibility tree - [ ] AriaLabel helper — blocked: no accessibility tree -- [ ] KeyboardNavigationHelper +- [x] KeyboardNavigationHelper → `interaction::RovingFocus` - [ ] ScreenReaderOnly — blocked: no accessibility tree -- [ ] ColorBlindSafe Palette +- [x] ColorBlindSafe Palette → `Theme::set_color_blind_safe`: chart hues fitted to stay apart under Machado's simulated protanopia, deuteranopia and tritanopia, each 3:1 on its page ## T41 Maps — `src/maps`