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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e5910068e5
commit
d2ac6c7753
11 files changed
+757
-8
No files matched your search
@@ -140,7 +140,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`.
|
||||
- A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size.
|
||||
- Tokens live in `src/theme`; the raw `px()` scan skips that folder only.
|
||||
- An owner's palette, edited or imported, reaches the theme per mode through `Theme::set_palette`, and `Theme::palette` names the one the shown mode fades to; high contrast strengthens Ely's own palettes alone. A color goes by its field's name through `Palette::token_mut` and `Syntax::token_mut`.
|
||||
- An owner's palette, edited or imported, reaches the theme per mode through `Theme::set_palette`, and `Theme::palette` names the one the shown mode fades to; high contrast strengthens Ely's own palettes alone. A color goes by its field's name through `token` and `token_mut`, on `Palette` and `Syntax` alike. Ely's palettes pass `tooling::A11yChecker`'s audit in both modes, with high contrast and without; a test holds them there.
|
||||
- `ScrollHandle` keeps its children's bounds unscrolled: a child paints at its bounds plus the offset. A press on a scrolled child subtracts both, and an offset that brings a child to a place is set whole, not added (`documents::find::reveal`).
|
||||
- A find box is `editor::FindWidget`, over code, a terminal or a document's pages. It shows the replace fold and the option toggles only when the owner handles them.
|
||||
- Taffy 0.9 hands negative free space to auto margins and centering alike, so a centered child wider than its scroll box starts past the scroll's reach. Center only what fits the measured box (`documents::DocumentViewer`).
|
||||
@@ -224,3 +224,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- 2026-09-28: QRCodeScanner proven with `rqrr` 0.11: the host hands in frames as `RenderImage`s, read off the main thread; gpui 0.2.2 has no camera.
|
||||
- 2026-09-28: WebView proven with `wry` 0.57: a child WKWebView over the window, placed from a canvas in prepaint at its box each frame and hidden once the box leaves the view. It draws above everything gpui paints and gpui's clipping does not reach it; gpui's test windows have no native handle, so the capture proves it.
|
||||
- 2026-09-28: InspectorOverlay is gpui's inspector drawn by Ely, `tooling::install_inspector`, in debug builds only, as gpui has it.
|
||||
- 2026-09-28: `fg_subtle` meets 4.5:1 on every surface it sits on, #6f6d6a light and #8c8a87 dark; the contrast audit found #82807d and #858380 under it.
|
||||
@@ -0,0 +1,74 @@
|
||||
use ely_gpui_component::{
|
||||
forms::IconPicker,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize},
|
||||
tooling::{A11yChecker, DesignTokenViewer},
|
||||
typography::Code,
|
||||
};
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px};
|
||||
|
||||
use crate::probe::probe;
|
||||
use crate::ui::section;
|
||||
|
||||
pub fn sections(window: &mut Window, cx: &mut App) -> Vec<AnyElement> {
|
||||
let picked = window.use_keyed_state("icon-browser-picked", cx, |_, _| IconName::Search);
|
||||
let icon = *picked.read(cx);
|
||||
let fg = cx.theme().colors.fg;
|
||||
vec![
|
||||
section(
|
||||
"DesignTokenViewer",
|
||||
"Every token of the theme in the mode shown, each named as code reads it: the colors with their hex, the syntax, chart and terminal colors, the type scale, radii, control and icon sizes, elevations and the spacing steps.",
|
||||
cx,
|
||||
)
|
||||
.child(div().max_w(px(840.0)).child(DesignTokenViewer::new()))
|
||||
.into_any_element(),
|
||||
section(
|
||||
"IconBrowser",
|
||||
"The library's icons, searchable: this is forms::IconPicker, its home. Pick one to see the name code uses.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_start()
|
||||
.gap_6()
|
||||
.child(probe(
|
||||
"icon-browser",
|
||||
IconPicker::new("icon-browser")
|
||||
.selected(icon)
|
||||
.on_change(move |next, _, cx| {
|
||||
picked.update(cx, |picked, cx| {
|
||||
*picked = next;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(Icon::new(icon).size(IconSize::Xxl).color(fg))
|
||||
.child(Code::new(format!("IconName::{icon:?}"))),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
section(
|
||||
"A11yChecker",
|
||||
"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. Ely's palettes pass in both modes, with high contrast and without.",
|
||||
cx,
|
||||
)
|
||||
.child(div().max_w(px(560.0)).child(A11yChecker::new()))
|
||||
.into_any_element(),
|
||||
section(
|
||||
"ScreenshotTest Harness",
|
||||
"The gallery photographs itself: --capture writes every page top to bottom, light and dark, then each page's scripted states, with input posted to its own event queue. Each pass starts its page fresh, and reduced motion holds the shots still.",
|
||||
cx,
|
||||
)
|
||||
.child(Code::new(
|
||||
"cargo run --example gallery -- --page tooling --capture shots",
|
||||
))
|
||||
.into_any_element(),
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use ely_gpui_component::{
|
||||
};
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px};
|
||||
|
||||
mod catalogs;
|
||||
mod inspection;
|
||||
|
||||
use super::Page;
|
||||
@@ -17,7 +18,7 @@ pub const PAGE: Page = Page {
|
||||
number: 43,
|
||||
slug: "tooling",
|
||||
title: "Library Tooling",
|
||||
summary: "Tools for building with the library: this gallery, a playground that edits a component live, and tools that inspect a running window: its boxes, frames, renders and input.",
|
||||
summary: "Tools for building with the library: this gallery, a playground that edits a component live, tools that inspect a running window, its boxes, frames, renders and input, and catalogs of its tokens, icons and contrast.",
|
||||
render,
|
||||
script: &[
|
||||
Step::DownAt("button-playground", 472.0, 207.0),
|
||||
@@ -53,11 +54,18 @@ pub const PAGE: Page = Page {
|
||||
Step::Type("Hi"),
|
||||
Step::Key("backspace"),
|
||||
Step::Shot("events"),
|
||||
Step::DownAt("icon-browser", 100.0, 14.0),
|
||||
Step::UpAt("icon-browser", 100.0, 14.0),
|
||||
Step::Type("sun"),
|
||||
Step::DownAt("icon-browser", 16.0, 52.0),
|
||||
Step::UpAt("icon-browser", 16.0, 52.0),
|
||||
Step::Shot("icon-browser"),
|
||||
],
|
||||
};
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let inspection = inspection::sections(window, cx);
|
||||
let catalogs = catalogs::sections(window, cx);
|
||||
div()
|
||||
.child(section(
|
||||
"ComponentGallery / Storybook",
|
||||
@@ -132,5 +140,6 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
),
|
||||
)
|
||||
.children(inspection)
|
||||
.children(catalogs)
|
||||
.into_any_element()
|
||||
}
|
||||
+23
-2
@@ -55,6 +55,14 @@ macro_rules! mixable {
|
||||
pub const NAMES: &'static [&'static str] = &[$(stringify!($color)),*];
|
||||
|
||||
/// The color named `token`, a field's name; any other name fails.
|
||||
pub fn token(&self, token: &str) -> Hsla {
|
||||
match token {
|
||||
$(stringify!($color) => self.$color,)*
|
||||
other => panic!("no {} color {other}", stringify!($name).to_lowercase()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The color named `token`, to change; any other name fails.
|
||||
pub fn token_mut(&mut self, token: &str) -> &mut Hsla {
|
||||
match token {
|
||||
$(stringify!($color) => &mut self.$color,)*
|
||||
@@ -177,7 +185,7 @@ impl Palette {
|
||||
border_strong: c(0xcccac8),
|
||||
fg: c(0x181613),
|
||||
fg_muted: c(0x605d5a),
|
||||
fg_subtle: c(0x82807d),
|
||||
fg_subtle: c(0x6f6d6a),
|
||||
fg_disabled: c(0xacaaa8),
|
||||
accent: c(0x181613),
|
||||
accent_hover: c(0x302d2b),
|
||||
@@ -248,7 +256,7 @@ impl Palette {
|
||||
border_strong: c(0x413f3d),
|
||||
fg: c(0xf3f1f0),
|
||||
fg_muted: c(0xafadab),
|
||||
fg_subtle: c(0x858380),
|
||||
fg_subtle: c(0x8c8a87),
|
||||
fg_disabled: c(0x5c5a58),
|
||||
accent: c(0xf3f1f0),
|
||||
accent_hover: c(0xd9d7d5),
|
||||
@@ -327,6 +335,19 @@ mod tests {
|
||||
assert_eq!(light.mix(&dark, 0.0), light);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_color_goes_by_its_name() {
|
||||
let palette = Palette::dark(false);
|
||||
assert_eq!(palette.token("fg_subtle"), palette.fg_subtle);
|
||||
assert_eq!(palette.syntax.token("keyword"), palette.syntax.keyword);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "no palette color ink_blot")]
|
||||
fn a_name_no_color_has_fails() {
|
||||
Palette::light(false).token("ink_blot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_contrast_strengthens_borders() {
|
||||
let (base, hc) = (Palette::light(false), Palette::light(true));
|
||||
|
||||
@@ -9,6 +9,10 @@ pub struct ToolingSizes {
|
||||
pub graph: (Rems, Rems),
|
||||
/// How tall an event log grows before it scrolls.
|
||||
pub log: Rems,
|
||||
/// A token viewer's tile, and its samples' least width.
|
||||
pub tile: Rems,
|
||||
/// A token viewer's swatch.
|
||||
pub swatch: Rems,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
@@ -16,6 +20,8 @@ impl Theme {
|
||||
ToolingSizes {
|
||||
graph: (px_to_rems(160.0), px_to_rems(40.0)),
|
||||
log: px_to_rems(200.0),
|
||||
tile: px_to_rems(200.0),
|
||||
swatch: px_to_rems(28.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod a11y;
|
||||
mod events;
|
||||
#[cfg(debug_assertions)]
|
||||
mod inspector;
|
||||
@@ -6,10 +7,13 @@ mod playground;
|
||||
mod renders;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod tokens;
|
||||
|
||||
pub use a11y::A11yChecker;
|
||||
pub use events::EventLogger;
|
||||
#[cfg(debug_assertions)]
|
||||
pub use inspector::install_inspector;
|
||||
pub use perf::FpsMeter;
|
||||
pub use playground::{Knob, Playground, Setting, Settings};
|
||||
pub use renders::RenderCounter;
|
||||
pub use tokens::DesignTokenViewer;
|
||||
@@ -0,0 +1,106 @@
|
||||
use gpui::{
|
||||
Context, IntoElement, ParentElement, Render, Rgba, Styled, TestAppContext, VisualTestContext,
|
||||
Window, div, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::hex,
|
||||
theme::{ControlSize, Density, Palette, Theme},
|
||||
tooling::{A11yChecker, DesignTokenViewer},
|
||||
};
|
||||
|
||||
/// gpui takes a static selector, so a formatted one is leaked.
|
||||
fn shown(selector: &str, cx: &mut VisualTestContext) -> bool {
|
||||
cx.debug_bounds(Box::leak(selector.into())).is_some()
|
||||
}
|
||||
|
||||
struct Tokens;
|
||||
|
||||
impl Render for Tokens {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div().w(px(900.0)).child(DesignTokenViewer::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Each token shows its name as code reads it and its value in the mode shown; a changed theme shows through.
|
||||
#[gpui::test]
|
||||
fn the_viewer_names_each_token_with_its_value(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Tokens);
|
||||
cx.run_until_parked();
|
||||
let light = Palette::light(false);
|
||||
let keyword = format!(
|
||||
"token-colors.syntax.keyword-{}",
|
||||
hex(Rgba::from(light.syntax.keyword))
|
||||
);
|
||||
let blue = format!(
|
||||
"token-colors.hue(0) Blue-{}",
|
||||
hex(Rgba::from(light.chart[0]))
|
||||
);
|
||||
for selector in [
|
||||
"token-colors.fg_subtle-#6f6d6a",
|
||||
&keyword,
|
||||
&blue,
|
||||
"token-TextSize::Sm-12",
|
||||
"token-Radius::Md-6",
|
||||
"token-ControlSize::Md-28",
|
||||
"token-IconSize::Md-16",
|
||||
"token-gap_2-8",
|
||||
"token-Elevation::Modal",
|
||||
] {
|
||||
assert!(shown(selector, cx), "{selector}");
|
||||
}
|
||||
cx.update(|_, cx| {
|
||||
Theme::update(cx, |theme| {
|
||||
theme.colors = Palette::dark(false);
|
||||
theme.density = Density::Compact;
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
shown("token-colors.fg_subtle-#8c8a87", cx),
|
||||
"the dark palette"
|
||||
);
|
||||
let compact = cx.update(|window, cx| {
|
||||
use crate::theme::ActiveTheme;
|
||||
cx.theme()
|
||||
.control_height(ControlSize::Md)
|
||||
.to_pixels(window.rem_size())
|
||||
});
|
||||
assert_eq!(compact, px(24.0));
|
||||
assert!(shown("token-ControlSize::Md-24", cx), "compact density");
|
||||
}
|
||||
|
||||
struct Audit;
|
||||
|
||||
impl Render for Audit {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div().w(px(600.0)).child(A11yChecker::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Ely's palette passes; a color moved under its need fails, counted, and lists first.
|
||||
#[gpui::test]
|
||||
fn the_checker_counts_failures_first(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Audit);
|
||||
cx.run_until_parked();
|
||||
assert!(shown("contrast-summary-41-0", cx), "every pair passes");
|
||||
assert!(shown("contrast-fg-bg-AAA", cx));
|
||||
assert!(shown("contrast-fg_subtle-hover-AA", cx), "4.53:1 on hover");
|
||||
assert!(shown("contrast-focus-surface-AA", cx));
|
||||
assert!(shown("contrast-chart Blue-bg-AA", cx));
|
||||
cx.update(|_, cx| Theme::update(cx, |theme| theme.colors.fg_subtle = theme.colors.bg));
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
shown("contrast-summary-41-5", cx),
|
||||
"five surfaces under fg_subtle"
|
||||
);
|
||||
let failing = cx
|
||||
.debug_bounds("contrast-fg_subtle-hover-Fails")
|
||||
.expect("a failing row");
|
||||
let passing = cx
|
||||
.debug_bounds("contrast-fg-bg-AAA")
|
||||
.expect("a passing row");
|
||||
assert!(failing.top() < passing.top(), "failures list first");
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use crate::{
|
||||
tooling::{Knob, Playground},
|
||||
};
|
||||
|
||||
mod catalogs;
|
||||
mod inspection;
|
||||
|
||||
struct Stage {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
use gpui::{
|
||||
AnyElement, App, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels,
|
||||
Rems, RenderOnce, Rgba, Styled, Window, div, rems,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::hex,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{
|
||||
ActiveTheme, ControlSize, Elevation, HUE_NAMES, IconSize, Palette, Radius, Syntax, TextSize,
|
||||
},
|
||||
typography::{Ellipsis, literal, tabular},
|
||||
};
|
||||
|
||||
const ANSI: [&str; 16] = [
|
||||
"black",
|
||||
"red",
|
||||
"green",
|
||||
"yellow",
|
||||
"blue",
|
||||
"magenta",
|
||||
"cyan",
|
||||
"white",
|
||||
"bright black",
|
||||
"bright red",
|
||||
"bright green",
|
||||
"bright yellow",
|
||||
"bright blue",
|
||||
"bright magenta",
|
||||
"bright cyan",
|
||||
"bright white",
|
||||
];
|
||||
|
||||
/// gpui's spacing steps shown, each a quarter rem, as `gap_2` reads.
|
||||
const STEPS: [f32; 6] = [1.0, 2.0, 3.0, 4.0, 6.0, 8.0];
|
||||
|
||||
fn pixels(value: Pixels) -> String {
|
||||
((f32::from(value) * 10.0).round() / 10.0).to_string()
|
||||
}
|
||||
|
||||
/// A titled group of tokens.
|
||||
fn group(title: &'static str, cx: &App) -> gpui::Div {
|
||||
div().flex().flex_col().gap_3().child(
|
||||
div()
|
||||
.text_size(cx.theme().text_size(TextSize::Sm))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(title),
|
||||
)
|
||||
}
|
||||
|
||||
/// A color's tile: its swatch, its name as code reads it, and its hex.
|
||||
fn swatch(name: String, color: Hsla, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let sizes = theme.tooling();
|
||||
let code = hex(Rgba::from(color));
|
||||
let shown = format!("token-{name}-{code}");
|
||||
div()
|
||||
.debug_selector(move || shown)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.w(sizes.tile)
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.size(sizes.swatch)
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(color),
|
||||
)
|
||||
.child(
|
||||
literal(div())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(Ellipsis::new(name))
|
||||
.child(div().text_color(theme.colors.fg_muted).child(code)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A measure's row: its name as code reads it and its size in pixels, then a sample of it.
|
||||
fn measure(name: String, size: Pixels, sample: impl IntoElement, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let value = pixels(size);
|
||||
let shown = format!("token-{name}-{value}");
|
||||
div()
|
||||
.debug_selector(move || shown)
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_x_4()
|
||||
.gap_y_1()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(theme.tooling().tile)
|
||||
.flex()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(literal(div()).child(name))
|
||||
.child(
|
||||
tabular(div())
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(format!("{value} px")),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().flex().items_center().child(sample))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Every token of the theme in the mode shown, each named as code reads it: the colors with their hex, the syntax, chart and terminal colors, the type scale, radii, control and icon sizes, elevations and gpui's spacing steps.
|
||||
#[derive(IntoElement, Default)]
|
||||
pub struct DesignTokenViewer;
|
||||
|
||||
impl DesignTokenViewer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for DesignTokenViewer {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let rem = window.rem_size();
|
||||
let at = |size: Rems| size.to_pixels(rem);
|
||||
let theme = cx.theme();
|
||||
let (colors, sizes) = (&theme.colors, theme.tooling());
|
||||
let tiles = |title, tiles: Vec<AnyElement>| {
|
||||
group(title, cx).child(div().flex().flex_wrap().gap_3().children(tiles))
|
||||
};
|
||||
let palette = Palette::NAMES
|
||||
.iter()
|
||||
.map(|name| swatch(format!("colors.{name}"), colors.token(name), cx));
|
||||
let syntax = Syntax::NAMES.iter().map(|name| {
|
||||
swatch(
|
||||
format!("colors.syntax.{name}"),
|
||||
colors.syntax.token(name),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let chart = HUE_NAMES.iter().enumerate().map(|(ix, name)| {
|
||||
swatch(
|
||||
format!("colors.hue({ix}) {name}"),
|
||||
colors.hue(ix, "token viewer"),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let ansi = ANSI
|
||||
.iter()
|
||||
.zip(colors.ansi)
|
||||
.enumerate()
|
||||
.map(|(ix, (name, color))| swatch(format!("colors.ansi[{ix}] {name}"), color, cx));
|
||||
let text = [
|
||||
(TextSize::Xs, "Xs"),
|
||||
(TextSize::Sm, "Sm"),
|
||||
(TextSize::Base, "Base"),
|
||||
(TextSize::Md, "Md"),
|
||||
(TextSize::Lg, "Lg"),
|
||||
(TextSize::Xl, "Xl"),
|
||||
(TextSize::Xxl, "Xxl"),
|
||||
(TextSize::Display, "Display"),
|
||||
]
|
||||
.map(|(size, name)| {
|
||||
let sample = div()
|
||||
.min_w_0()
|
||||
.text_size(theme.text_size(size))
|
||||
.child(Ellipsis::new("Sphinx of black quartz"));
|
||||
measure(
|
||||
format!("TextSize::{name}"),
|
||||
at(theme.text_size(size)),
|
||||
sample,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let radii = [
|
||||
(Radius::Sm, "Sm"),
|
||||
(Radius::Md, "Md"),
|
||||
(Radius::Lg, "Lg"),
|
||||
(Radius::Xl, "Xl"),
|
||||
]
|
||||
.map(|(radius, name)| {
|
||||
let sample = div()
|
||||
.size(sizes.swatch)
|
||||
.rounded(theme.radius(radius))
|
||||
.border_1()
|
||||
.border_color(colors.border_strong)
|
||||
.bg(colors.sunken);
|
||||
measure(
|
||||
format!("Radius::{name}"),
|
||||
at(theme.radius(radius)),
|
||||
sample,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let controls = [
|
||||
(ControlSize::Sm, "Sm"),
|
||||
(ControlSize::Md, "Md"),
|
||||
(ControlSize::Lg, "Lg"),
|
||||
]
|
||||
.map(|(size, name)| {
|
||||
let height = theme.control_height(size);
|
||||
let sample = div()
|
||||
.h(height)
|
||||
.w(sizes.tile)
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border_strong)
|
||||
.bg(colors.surface);
|
||||
measure(format!("ControlSize::{name}"), at(height), sample, cx)
|
||||
});
|
||||
let icons = [
|
||||
(IconSize::Xs, "Xs"),
|
||||
(IconSize::Sm, "Sm"),
|
||||
(IconSize::Md, "Md"),
|
||||
(IconSize::Lg, "Lg"),
|
||||
(IconSize::Xl, "Xl"),
|
||||
(IconSize::Xxl, "Xxl"),
|
||||
]
|
||||
.map(|(size, name)| {
|
||||
let sample = Icon::new(IconName::Star).size(size).color(colors.fg);
|
||||
measure(
|
||||
format!("IconSize::{name}"),
|
||||
at(theme.icon_size(size)),
|
||||
sample,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let elevations = [
|
||||
(Elevation::Raised, "Raised"),
|
||||
(Elevation::Floating, "Floating"),
|
||||
(Elevation::Modal, "Modal"),
|
||||
]
|
||||
.map(|(level, name)| {
|
||||
let card = div()
|
||||
.w(sizes.tile)
|
||||
.h(sizes.swatch)
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(colors.surface)
|
||||
.shadow(theme.elevation(level));
|
||||
let shown = format!("token-Elevation::{name}");
|
||||
div()
|
||||
.debug_selector(move || shown)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(card)
|
||||
.child(
|
||||
literal(div())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child(format!("Elevation::{name}")),
|
||||
)
|
||||
});
|
||||
let steps = STEPS.map(|step| {
|
||||
let width = rems(step / 4.0);
|
||||
let sample = div()
|
||||
.h_2()
|
||||
.w(width)
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(colors.accent);
|
||||
measure(format!("gap_{step}"), at(width), sample, cx)
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_8()
|
||||
.child(tiles("Colors", palette.collect()))
|
||||
.child(tiles("Syntax", syntax.collect()))
|
||||
.child(tiles("Chart", chart.collect()))
|
||||
.child(tiles("Terminal", ansi.collect()))
|
||||
.child(group("Type", cx).children(text))
|
||||
.child(group("Radii", cx).children(radii))
|
||||
.child(group("Controls", cx).children(controls))
|
||||
.child(group("Icons", cx).children(icons))
|
||||
.child(
|
||||
group("Elevation", cx)
|
||||
.child(div().flex().flex_wrap().gap_6().p_2().children(elevations)),
|
||||
)
|
||||
.child(group("Spacing", cx).children(steps))
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -366,7 +366,7 @@
|
||||
|
||||
## T43c Tooling · Catalogs
|
||||
|
||||
- [ ] DesignTokenViewer
|
||||
- [ ] IconBrowser
|
||||
- [ ] A11yChecker — contrast audit
|
||||
- [ ] ScreenshotTest Harness — self-capture; scripted input since T01
|
||||
- [x] DesignTokenViewer → `tooling::DesignTokenViewer`: every token of the theme in the mode shown, named as code reads it, with its value
|
||||
- [x] IconBrowser → `forms::IconPicker`: the library's icons, searchable
|
||||
- [x] A11yChecker — contrast audit → `tooling::A11yChecker`: each text color over its surfaces at 4.5:1, the focus ring and chart hues beside the page at 3:1, failures first
|
||||
- [x] ScreenshotTest Harness — self-capture; scripted input since T01 → `examples/gallery --capture`: every page light and dark, then its scripted states
|
||||
Reference in new issue
Block a user