M7: a file tab's menu and the board bar's shape menu read in the light theme (#1890)

* M7: a file tab's menu and the board bar's shape menu read in the light theme

Both open on the theme's popup, white in the light theme, but painted what
is in them with `menu_text`, which is white in both themes because it
belongs to the dark in-app menus (#972 was the same mistake in the panel
dropdowns):

- A file tab's right-click menu drew Open in new window with the menu
  bar's row (`menus::item`), so it showed only while hovered. It now uses
  `right::context_popup` and `right::menu_item`, like the tab strip's
  overflow menus: the theme's text, white on the green highlight.
- The board bar's shape menu outlined every shape but the picked one in
  white. They now take the theme's icon colour, white only on the green
  highlight.

The in-app menus (main menu, right-click on the canvas) stay dark in both
themes, as decided on #843.

Tests (menu_contrast_tests.rs): a file tab's menu opened with a right
click and the board bar's shape menu opened from its button, headless in
both themes: the label at 4.5:1 and every shape's outline at 3:1 against
the popup. Without the fix both fail at 1.00:1 in the light theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: contributor credits: a real name for aleksei-suholita

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Oleksii Sukholita <oleksii.sukholita@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-11-03 00:13:27 -05:00
1 parent c69eb55f31
commit a7a0d12a85
4 files changed
+118 -7

No files matched your search

+3
View File
@@ -98,3 +98,6 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.aleksei-suholita]
real_name = "Oleksii Sukholita"
+5 -2
View File
@@ -299,10 +299,13 @@ fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &To
let (r, cell) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click());
let cell = cell.on_hover_text(tr(b.label()));
a11y::menu_item(&cell, tr(b.label()), "", Some(b == shape));
if b == shape || cell.hovered() {
let lit = b == shape || cell.hovered();
if lit {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
paint_choice(ui.painter(), r.shrink(6.0), &Mark::Shape(b), t.menu_text);
// The menu is the theme's popup (white in the light theme): the theme's icon
// colour, white only on the green highlight.
paint_choice(ui.painter(), r.shrink(6.0), &Mark::Shape(b), if lit { t.menu_text } else { t.icon });
if cell.clicked() {
app.run("board.setStyle", json!({"shape": b.id()}));
ui.close();
+106 -2
View File
@@ -1,8 +1,15 @@
//! Panel dropdowns (the prototype device picker, Preferences, blend modes…) draw on the theme's
//! popup fill, so their rows' text must read on it in both themes (#972): drawn headless.
//! Panel dropdowns (the prototype device picker, Preferences, blend modes…), a file tab's menu and
//! the board bar's shape menu draw on the theme's popup fill, so what is in them must read on it
//! in both themes (#972): drawn headless.
use egui::accesskit::Node;
use egui::epaint::ColorMode;
use egui::{Color32, Event, Order, PointerButton, Pos2, RawInput, Rect, Shape, pos2, vec2};
use figcraft_doc::BoardShape;
use figcraft_engine::Session;
use serde_json::json;
use crate::present_tests::Ui;
use crate::right::{menu_check, menu_item, menu_popup};
use crate::theme::{self, Tokens};
@@ -82,3 +89,100 @@ fn dropdown_rows_read_on_the_popup_in_both_themes() {
}
}
}
/// The whole app headless on `s` in the `dark` or light theme, with windows of their own allowed
/// (a tab offers Open in new window only then); the theme's tokens.
fn app(mut s: Session, dark: bool) -> (Ui, Tokens) {
s.execute(if dark { "view.themeDark" } else { "view.themeLight" }, &json!({})).unwrap();
let mut u = Ui::new(s);
u.ctx.set_embed_viewports(false);
u.frame(vec![]);
let t = Tokens::get(&u.ctx);
assert_eq!(t.dark, dark, "the app took the theme");
(u, t)
}
fn click(u: &mut Ui, at: Pos2, button: PointerButton) {
for pressed in [true, false] {
u.frame(vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }]);
}
}
/// The pointer away from the open menu until its fade-in is over: that frame's output.
fn settle(u: &mut Ui) -> egui::FullOutput {
for _ in 0..30 {
u.frame(vec![Event::PointerMoved(pos2(1430.0, 600.0))]);
}
u.frame_output(vec![])
}
/// The AccessKit nodes on screen.
fn nodes(u: &mut Ui) -> Vec<Node> {
u.ctx.enable_accesskit();
u.frame(vec![]);
u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
}
fn bounds(n: &Node) -> Rect {
let b = n.bounds().unwrap();
Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))
}
/// The colours of the lines painted inside `r`.
fn lines_in(out: &egui::FullOutput, r: Rect) -> Vec<Color32> {
fn walk(s: &Shape, r: Rect, out: &mut Vec<Color32>) {
match s {
Shape::Vec(v) => v.iter().for_each(|s| walk(s, r, out)),
Shape::Path(p) if r.contains_rect(s.visual_bounding_rect()) => {
if let ColorMode::Solid(c) = p.stroke.color {
out.push(c);
}
}
_ => {}
}
}
let mut lines = Vec::new();
for c in &out.shapes {
walk(&c.shape, r, &mut lines);
}
lines
}
#[test]
fn a_file_tabs_menu_reads_on_its_popup_in_both_themes() {
for dark in [false, true] {
let (mut u, t) = app(Session::new(), dark);
click(&mut u, pos2(100.0, 20.0), PointerButton::Secondary);
let texts = menu_texts(&settle(&mut u));
let Some((_, col)) = texts.iter().find(|(s, _)| s == "Open in new window") else { panic!("the tab's menu opened (dark {dark}): {texts:?}") };
let ratio = color(*col).contrast(color(t.panel));
assert!(ratio >= 4.5, "Open in new window at {ratio:.2}:1 on the popup (dark {dark})");
}
}
#[test]
fn the_board_bars_shape_menu_shows_every_shape_in_both_themes() {
for dark in [false, true] {
let mut s = Session::new();
s.execute("file.newBoard", &json!({})).unwrap();
s.execute("board.shape", &json!({"shape": "rectangle", "x": 0, "y": 0})).unwrap();
let (mut u, t) = app(s, dark);
let all = nodes(&mut u);
let button = all.iter().find(|n| n.label() == Some("Shape")).expect("the board bar's Shape button");
click(&mut u, bounds(button).center(), PointerButton::Primary);
let all = nodes(&mut u);
let out = settle(&mut u);
// Every shape but the picked one (white on the green highlight) on the popup itself.
for b in BoardShape::ALL.into_iter().filter(|b| *b != BoardShape::Rectangle) {
let label = crate::i18n::tr(b.label());
let cell = all.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("{label} in the menu (dark {dark})"));
let lines = lines_in(&out, bounds(cell));
assert!(!lines.is_empty(), "{label}'s outline painted (dark {dark})");
for c in lines {
// Non-text contrast (WCAG 1.4.11): at least 3:1.
let ratio = color(c).contrast(color(t.panel));
assert!(ratio >= 3.0, "{label}'s outline at {ratio:.2}:1 on the popup (dark {dark})");
}
}
}
}
+4 -3
View File
@@ -162,9 +162,10 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
None => {}
}
if several {
resp.context_menu(|ui| {
ui.set_width(200.0);
if crate::menus::item(ui, tr("Open in new window"), "", true, false, &t) {
// The tab strip's own menu takes the theme like its overflow menus (the in-app
// menus stay dark, #843): its rows read on the white popup of the light theme.
crate::right::context_popup(&resp).show(|ui| {
if crate::right::menu_item(ui, tr("Open in new window"), "") {
detach = Some((i, None));
ui.close();
}