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.
This commit is contained in:
1 parent
aa39fba87f
commit
1ac5c04fb0
10 files changed
+463
-7
No files matched your search
@@ -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::<I18n>().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
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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)),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+4
-4
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user