From 9ee0c7f57268d0f6bf370aa799be61c356ea489f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:57:20 +0700 Subject: [PATCH] M1: readable disabled and selected text (#890) The dim text token (disabled buttons, placeholders, hints) was 2.1:1 on the light theme's panels; disabled buttons also faded to 40% opacity, and selected text was drawn in the dark green, 1.4:1 on the dark theme's selection highlight. The dim text token is now at least 3:1 against panels, fields and selected rows in both themes while staying dimmer than secondary text, a disabled button is grey with that text, and selected text takes the theme's readable green. A test checks the ratios with the colour contrast helper. --- ROADMAP.md | 1 + crates/ui-egui/src/theme.rs | 44 +++++++++++++++++++++++++++++++---- crates/ui-egui/src/widgets.rs | 20 ++++++++-------- docs/ui-parity.md | 3 ++- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..b1a69e46 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 777b04b1..611371ce 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -14,6 +14,8 @@ pub struct Tokens { pub border_strong: Color32, pub text: Color32, pub text_secondary: Color32, + /// Dim text: disabled controls, placeholders, hints. At least 3:1 against the panels, fields + /// and selected rows, yet dimmer than `text_secondary`. pub text_tertiary: Color32, pub icon: Color32, pub icon_secondary: Color32, @@ -58,11 +60,15 @@ const CANVAS_ACCENT: &str = "figcraft-canvas-accent"; /// Remember the page colour the canvas shows. Its overlays (outlines, handles, arrows) take /// whichever green contrasts more with it: the bright one on a dark page, the darker on a light one. pub fn set_canvas_page(ctx: &Context, page: Color32) { - let c = |x: Color32| figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0); let pick = if c(BRAND).contrast(c(page)) > c(ACCENT).contrast(c(page)) { BRAND } else { ACCENT }; ctx.data_mut(|d| d.insert_temp(egui::Id::new(CANVAS_ACCENT), pick)); } +/// `x` (opaque) as a document colour, for its contrast with another. +fn c(x: Color32) -> figcraft_color::Color { + figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0) +} + /// The green for overlays drawn on the page ([`set_canvas_page`]); [`ACCENT`] before a page shows. pub fn canvas_accent(ctx: &Context) -> Color32 { ctx.data(|d| d.get_temp(egui::Id::new(CANVAS_ACCENT))).unwrap_or(ACCENT) @@ -91,7 +97,7 @@ impl Tokens { text: Color32::from_rgb(0x1E, 0x1E, 0x1E), // 4.7:1 on fields and hovered rows (#F5F5F5), 4.6:1 on selected rows: WCAG AA. text_secondary: Color32::from_rgb(0x6E, 0x6E, 0x6E), - text_tertiary: Color32::from_rgb(0xB3, 0xB3, 0xB3), + text_tertiary: Color32::from_rgb(0x8A, 0x8A, 0x8A), icon: Color32::from_rgb(0x1E, 0x1E, 0x1E), icon_secondary: Color32::from_rgb(0x75, 0x75, 0x75), hover: Color32::from_rgb(0xF5, 0xF5, 0xF5), @@ -132,7 +138,7 @@ impl Tokens { border_strong: Color32::from_rgb(0x55, 0x55, 0x55), text: Color32::from_rgb(0xFF, 0xFF, 0xFF), text_secondary: Color32::from_rgb(0xB3, 0xB3, 0xB3), - text_tertiary: Color32::from_rgb(0x75, 0x75, 0x75), + text_tertiary: Color32::from_rgb(0x88, 0x88, 0x88), icon: Color32::from_rgb(0xF5, 0xF5, 0xF5), icon_secondary: Color32::from_rgb(0xB3, 0xB3, 0xB3), hover: Color32::from_rgb(0x38, 0x38, 0x38), @@ -216,7 +222,9 @@ fn visuals(t: Tokens) -> Visuals { v.window_corner_radius = egui::CornerRadius::same(13); v.menu_corner_radius = egui::CornerRadius::same(13); v.selection.bg_fill = t.accent.gamma_multiply(0.3); - v.selection.stroke = Stroke::new(1.0, ACCENT); + // Also the colour of selected text on that highlight (and a focused text box's outline): the + // theme's readable green, not the darker one that sinks into the dark theme's highlight. + v.selection.stroke = Stroke::new(1.0, t.accent_text); v.hyperlink_color = t.accent_text; for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open, &mut v.widgets.noninteractive] { w.corner_radius = egui::CornerRadius::same(5); @@ -462,6 +470,34 @@ mod tests { assert_eq!(under_white(COMPONENT), COMPONENT); } + /// `fill` (premultiplied, maybe translucent) painted over the opaque `bg`. + fn over(fill: Color32, bg: Color32) -> Color32 { + let k = 1.0 - f32::from(fill.a()) / 255.0; + let mix = |f: u8, b: u8| (f32::from(f) + f32::from(b) * k).round().min(255.0) as u8; + Color32::from_rgb(mix(fill.r(), bg.r()), mix(fill.g(), bg.g()), mix(fill.b(), bg.b())) + } + + #[test] + fn disabled_and_selected_text_stay_readable_in_both_themes() { + for t in [Tokens::light(), Tokens::dark()] { + let theme = if t.dark { "dark" } else { "light" }; + // Disabled text (a disabled button's label, placeholders) on panels, fields and + // selected rows, still dimmer than secondary text. + for bg in [t.panel, t.field, t.hover, t.selected_row] { + let ratio = c(t.text_tertiary).contrast(c(bg)); + assert!(ratio >= 3.0, "{theme}: dim text {:?} on {bg:?} is {ratio:.2}:1", t.text_tertiary); + assert!(ratio < c(t.text_secondary).contrast(c(bg)), "{theme}: dim text reads dimmer than secondary text"); + } + // Selected text (renaming a page or layer, any text box) on the selection highlight. + let v = visuals(t); + for bg in [t.panel, t.field] { + let highlight = over(v.selection.bg_fill, bg); + let ratio = c(v.selection.stroke.color).contrast(c(highlight)); + assert!(ratio >= 3.0, "{theme}: selected text on {highlight:?} is {ratio:.2}:1"); + } + } + } + #[test] fn ui_font_draws_every_mac_shortcut_glyph() { figcraft_text::scan_system_fonts(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ca7504ae..b695e918 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -999,26 +999,26 @@ pub fn radio(ui: &mut Ui, on: bool, label: &str) -> bool { } /// A text button: `primary` is filled with the accent colour, otherwise outlined. A disabled -/// button is dimmed and never reports clicks. +/// button is grey with dim text (still readable) and never reports clicks. pub fn button(ui: &mut Ui, label: &str, primary: bool, enabled: bool) -> Response { let t = Tokens::get(ui.ctx()); let galley = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), t.text); let sense = if enabled { Sense::click() } else { Sense::hover() }; let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 24.0, FIELD_H), sense); a11y::button(&resp, label); - let hot = enabled && resp.hovered(); - let (fill, text) = match (primary, hot) { - (true, false) => (t.accent, t.accent_on), - (true, true) => (t.accent_hover, t.accent_on), - (false, false) => (Color32::TRANSPARENT, t.text), - (false, true) => (t.hover, t.text), + let (fill, text) = match (enabled, primary, resp.hovered()) { + (false, true, _) => (t.field, t.text_tertiary), + (false, false, _) => (Color32::TRANSPARENT, t.text_tertiary), + (true, true, false) => (t.accent, t.accent_on), + (true, true, true) => (t.accent_hover, t.accent_on), + (true, false, false) => (Color32::TRANSPARENT, t.text), + (true, false, true) => (t.hover, t.text), }; - let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.4) }; - ui.painter().rect_filled(rect, CornerRadius::same(5), dim(fill)); + ui.painter().rect_filled(rect, CornerRadius::same(5), fill); if !primary { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); } - ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, regular(11.0), dim(text)); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, regular(11.0), text); resp } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 766284f1..2f55709e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -165,13 +165,14 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | | Reduced motion | done | system setting or preference | -| WCAG AA contrast of secondary and accent text | done | | +| WCAG AA contrast of secondary and accent text | done | disabled and dim text at least 3:1 against panels, fields and selected rows, selected text at least 3:1 on its highlight, in both themes (`theme::tests`, #890) | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |