diff --git a/ROADMAP.md b/ROADMAP.md index b70dcdec..fc7015a0 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-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). 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 2d986c64..845dca9d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1030,26 +1030,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 5b45eeb3..d87103f0 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-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_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 |