From 443bc5ae03410cfa6f376610cc6fc5ac43a1d3ed Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 28 Oct 2026 11:12:31 -0400 Subject: [PATCH 1/4] LR-BEHAV-ACCESS: every dialog is announced as a modal dialog (an alert dialog for the prompts) instead of a group with the title --- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/dialogs/updates_ui.rs | 5 +- crates/ui-egui/src/panels/library_problem.rs | 2 + crates/ui-egui/src/panels/notices.rs | 1 + crates/ui-egui/src/widgets.rs | 26 ++++++ crates/ui-egui/tests/it/accessibility.rs | 5 +- crates/ui-egui/tests/it/dialog_role.rs | 87 ++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/parity-checklist.md | 3 +- docs/whats-new.md | 7 ++ 10 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/it/dialog_role.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 646288220..4b79a9855 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -198,6 +198,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { } } if let Some(w) = shown { + crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Dialog); ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); if let Some(body_h) = body_h { let measured = (w.response.rect.height() - body_h).max(0.0); diff --git a/crates/ui-egui/src/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs index 5e159a0f4..31cecdd2b 100644 --- a/crates/ui-egui/src/dialogs/updates_ui.rs +++ b/crates/ui-egui/src/dialogs/updates_ui.rs @@ -121,7 +121,7 @@ pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if app.updates_rt.manual_window_open { let mut open = true; - egui::Window::new(crate::i18n::tr("LightCraft Updates")) + let shown = egui::Window::new(crate::i18n::tr("LightCraft Updates")) .open(&mut open) .collapsible(false) .resizable(false) @@ -136,6 +136,9 @@ pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) { app.updates_rt.manual_window_open = false; } }); + if let Some(w) = &shown { + crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Window); + } if !open { app.updates_rt.manual_window_open = false; } diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index 544e29449..83d55a914 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -1059,6 +1059,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { }); }); if let Some(w) = &shown { + crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Alert); // A press on the dim raises its area; keep this window (and its own buttons) above it. ctx.move_to_top(w.response.layer_id); } @@ -1186,6 +1187,7 @@ fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryP }); }); if let Some(w) = &shown { + crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Dialog); // A press on the dim raises its area; keep the launch list above it. ctx.move_to_top(w.response.layer_id); } diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 96e356bb4..e2ded68ee 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -512,6 +512,7 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s }); // A backdrop press can raise its area; keep the prompt buttons above it. if let Some(window) = shown { + crate::widgets::announce_dialog(ctx, &window.response, crate::widgets::Modal::Alert); ctx.move_to_top(window.response.layer_id); } input.button_ids = seen; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 89185d991..85fd6044d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1160,6 +1160,32 @@ pub(crate) fn announce(resp: &egui::Response, name: impl Into) { resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), name.clone())); } +/// What a window with its own title and buttons is, to a screen reader. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Modal { + /// A dialog with a task (Export, Settings, a name prompt) that stops the rest of the application. + Dialog, + /// One that interrupts to say something that needs an answer (the quit prompt, a library that + /// couldn't be opened) and stops the rest of the application: an alert dialog (AT-SPI and + /// Windows tell it from a dialog; macOS reads both alike). + Alert, + /// A dialog that leaves the rest of the application reachable (the update check's answer). + Window, +} + +/// Announces the window `response` drew as a dialog (an alert dialog for [`Modal::Alert`]), and for +/// [`Modal::Dialog`] and [`Modal::Alert`] as modal: VoiceOver says "dialog" and keeps its cursor +/// inside, instead of reading a group that carries the title while the rest of the application +/// stays reachable. +pub(crate) fn announce_dialog(ctx: &egui::Context, response: &Response, kind: Modal) { + ctx.accesskit_node_builder(response.id, |node| { + node.set_role(if kind == Modal::Alert { egui::accesskit::Role::AlertDialog } else { egui::accesskit::Role::Dialog }); + if kind != Modal::Window { + node.set_modal(); + } + }); +} + /// The same for one that is on or off (a toggle, a selected row or swatch). pub(crate) fn announce_selected(resp: &egui::Response, name: impl Into, selected: bool) { let name = name.into(); diff --git a/crates/ui-egui/tests/it/accessibility.rs b/crates/ui-egui/tests/it/accessibility.rs index 04d64f3e5..79a37403c 100644 --- a/crates/ui-egui/tests/it/accessibility.rs +++ b/crates/ui-egui/tests/it/accessibility.rs @@ -123,12 +123,13 @@ fn every_interactive_node_has_a_name_in_every_view_and_tool() { } h.run_steps(6); problems.extend(audit(&h, dialog)); - let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| n.role() == Role::Window).count(); + let is_window = |role| matches!(role, Role::Window | Role::Dialog | Role::AlertDialog); + let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| is_window(n.role())).count(); let untitled = h .root() .children_recursive() .map(|n| n.accesskit_node()) - .filter(|n| n.role() == Role::Window && n.label().unwrap_or_default().trim().is_empty()) + .filter(|n| is_window(n.role()) && n.label().unwrap_or_default().trim().is_empty()) .count(); if untitled > 0 { problems.push(format!("{dialog}: {untitled} of {windows} windows have no title")); diff --git a/crates/ui-egui/tests/it/dialog_role.rs b/crates/ui-egui/tests/it/dialog_role.rs new file mode 100644 index 000000000..508dcb0b3 --- /dev/null +++ b/crates/ui-egui/tests/it/dialog_role.rs @@ -0,0 +1,87 @@ +//! An open dialog is a dialog to a screen reader (VoiceOver announces "dialog" and keeps its cursor +//! inside), not a group that happens to carry the dialog's title. +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::{Harness, kittest::NodeT}; +use lightcraft_ui_egui::{LightcraftApp, Services}; + +fn harness() -> Harness<'static, LightcraftApp> { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state( + |ui, app: &mut LightcraftApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(10); + h +} + +/// `(role, title, modal)` of every window-like node in the tree. +fn windows(h: &Harness<'_, LightcraftApp>) -> Vec<(Role, String, bool)> { + h.root() + .children_recursive() + .map(|n| n.accesskit_node()) + .filter(|n| matches!(n.role(), Role::Window | Role::Dialog | Role::AlertDialog)) + .map(|n| (n.role(), n.label().unwrap_or_default(), n.data().is_modal())) + .collect() +} + +#[test] +fn every_dialog_is_a_modal_dialog_with_its_title() { + for command in ["dialog.export", "dialog.rename", "dialog.newAlbum", "dialog.createPreset", "dialog.contactSheet", "dialog.allMetadata"] { + let mut h = harness(); + h.state_mut().run(command, serde_json::json!({})).unwrap_or_else(|e| panic!("{command}: {e}")); + h.run_steps(6); + let found = windows(&h); + assert!(!found.is_empty(), "{command}: the dialog is in the tree"); + for (role, title, modal) in found { + assert_eq!(role, Role::Dialog, "{command}: {title:?} is announced as a dialog"); + assert!(!title.trim().is_empty(), "{command}: the dialog has its title"); + assert!(modal, "{command}: {title:?} is modal, so the rest of the window is not read"); + } + } +} + +#[test] +fn settings_is_a_dialog_too() { + let mut h = harness(); + h.state_mut().run("app.settings", serde_json::json!({})).expect("Settings opens"); + h.run_steps(6); + let found = windows(&h); + assert!(found.iter().any(|(role, title, _)| *role == Role::Dialog && title.as_str() == "Settings"), "{found:?}"); + assert!(found.iter().all(|(role, ..)| *role != Role::Window), "{found:?}"); +} + +#[test] +fn the_quit_prompt_and_a_settings_notice_are_modal_alert_dialogs() { + let mut h = harness(); + h.state_mut().quit_prompt = Some(lightcraft_ui_egui::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.run_steps(6); + let found = windows(&h); + assert!(!found.is_empty(), "the quit prompt is in the tree"); + for (role, title, modal) in found { + assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} interrupts with a question: a modal alert dialog"); + } + let mut h = harness(); + h.state_mut().notices.push("Settings could not be saved.".into()); + h.run_steps(6); + let found = windows(&h); + assert!(!found.is_empty(), "the notice is in the tree"); + for (role, title, modal) in found { + assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} is a modal alert dialog"); + } +} + +#[test] +fn a_library_that_could_not_be_opened_is_a_modal_alert_dialog() { + let mut h = harness(); + h.state_mut().library_problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem::new("/no/such/library", "No catalog here.")); + h.run_steps(6); + let found = windows(&h); + assert!(!found.is_empty(), "the library problem is in the tree"); + for (role, title, modal) in found { + assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} is a modal alert dialog"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 4b9fa7eb7..75ca8d1bc 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -2,4 +2,5 @@ //! `tests/shutdown.rs` stays a binary of its own: it closes the GPU for the whole process. mod accessibility; +mod dialog_role; mod docking_workflows; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..fb58e42d6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (LR-BEHAV-ACCESS: every dialog and prompt is announced as a dialog (a modal one, an alert dialog for the prompts); LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-28 | patch | LR-BEHAV-ACCESS: every dialog, the library chooser and the quit and library-problem prompts are announced as a modal dialog (an alert dialog for the prompts) instead of a group with the title; status stays 🟡 | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..84cb233d6 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-28 + +### A dialog is announced as a dialog by screen readers +- With VoiceOver, Export, Settings and the other dialogs, the library chooser and the prompts that stop the window (the quit + question, a library that could not be opened) are now exposed to screen readers as dialogs (a modal one, so the rest of the window is out of VoiceOver's way while + it is open), as is the answer to Check for Updates. Before, each was a plain group that only carried the dialog's title. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From c9aa1d3b8187ffe60ffd9070bfb2671f7e54d41a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 28 Oct 2026 11:54:20 -0400 Subject: [PATCH 2/4] LR-BEHAV-ACCESS: the dialog-role comments and docs say what is exposed, not what VoiceOver does --- crates/ui-egui/src/widgets.rs | 6 +++--- crates/ui-egui/tests/it/dialog_role.rs | 4 ++-- docs/parity-checklist.md | 4 ++-- docs/whats-new.md | 9 +++++---- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 85fd6044d..d9658b097 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1174,9 +1174,9 @@ pub(crate) enum Modal { } /// Announces the window `response` drew as a dialog (an alert dialog for [`Modal::Alert`]), and for -/// [`Modal::Dialog`] and [`Modal::Alert`] as modal: VoiceOver says "dialog" and keeps its cursor -/// inside, instead of reading a group that carries the title while the rest of the application -/// stays reachable. +/// [`Modal::Dialog`] and [`Modal::Alert`] as modal, instead of as a group that carries the title: +/// the platform's accessibility layer then sees a dialog and that the rest of the application is +/// blocked while it is open. pub(crate) fn announce_dialog(ctx: &egui::Context, response: &Response, kind: Modal) { ctx.accesskit_node_builder(response.id, |node| { node.set_role(if kind == Modal::Alert { egui::accesskit::Role::AlertDialog } else { egui::accesskit::Role::Dialog }); diff --git a/crates/ui-egui/tests/it/dialog_role.rs b/crates/ui-egui/tests/it/dialog_role.rs index 508dcb0b3..48b1f0751 100644 --- a/crates/ui-egui/tests/it/dialog_role.rs +++ b/crates/ui-egui/tests/it/dialog_role.rs @@ -1,5 +1,5 @@ -//! An open dialog is a dialog to a screen reader (VoiceOver announces "dialog" and keeps its cursor -//! inside), not a group that happens to carry the dialog's title. +//! An open dialog is exposed to a screen reader as a dialog (a modal one, an alert dialog for the +//! prompts), not as a group that happens to carry the dialog's title. use egui::accesskit::Role; use egui::vec2; use egui_kittest::{Harness, kittest::NodeT}; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index fb58e42d6..5b9de7ae9 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (LR-BEHAV-ACCESS: every dialog and prompt is announced as a dialog (a modal one, an alert dialog for the prompts); LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (LR-BEHAV-ACCESS: every dialog and prompt is exposed as a dialog (a modal one, an alert dialog for the prompts); LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -959,4 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | -| 2026-10-28 | patch | LR-BEHAV-ACCESS: every dialog, the library chooser and the quit and library-problem prompts are announced as a modal dialog (an alert dialog for the prompts) instead of a group with the title; status stays 🟡 | +| 2026-10-28 | patch | LR-BEHAV-ACCESS: every dialog, the library chooser and the quit, library-problem and settings-file prompts are exposed as a modal dialog (an alert dialog for the prompts), and the update check's answer as a non-modal one, instead of a group with the title (`tests/it/dialog_role.rs`); not tried with a real screen reader; status stays 🟡 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 84cb233d6..2e05e59e2 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,10 +2,11 @@ ## 2026-10-28 -### A dialog is announced as a dialog by screen readers -- With VoiceOver, Export, Settings and the other dialogs, the library chooser and the prompts that stop the window (the quit - question, a library that could not be opened) are now exposed to screen readers as dialogs (a modal one, so the rest of the window is out of VoiceOver's way while - it is open), as is the answer to Check for Updates. Before, each was a plain group that only carried the dialog's title. +### Dialogs are exposed to screen readers as dialogs +- Export, Settings and the other dialogs, the library chooser and the prompts that stop the window (the quit question, a + library that could not be opened, a settings file problem) are now exposed to screen readers as dialogs: modal ones, so the + rest of the window is marked as blocked while one is open, and an alert dialog for the prompts. The answer to Check for + Updates is a dialog too, but not a modal one. Before, each was a plain group that only carried the dialog's title. ## 2026-10-24 From 83aef8d48b8b3a494ef4f0b16201fe38791d25d5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 28 Oct 2026 15:37:24 -0400 Subject: [PATCH 3/4] LR-BEHAV-ACCESS: an alert dialog carries what it says as its accessible description --- crates/ui-egui/src/panels/library_problem.rs | 20 +++++++++------ crates/ui-egui/src/panels/notices.rs | 1 + crates/ui-egui/src/widgets.rs | 7 +++++ crates/ui-egui/tests/it/dialog_role.rs | 27 ++++++++++++++++++++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index 83d55a914..f5b9c25ba 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -28,6 +28,10 @@ use crate::widgets::register; /// windows and their dim. const DIM_ID: &str = "library-problem-dim"; const ERROR_WINDOW_ID: &str = "library-problem"; +/// What the error window says about the library's safety and the way on (drawn as it is, not translated). +const EXPLANATION: &str = "Your photos and edits in it are untouched. You can try again (e.g. after closing another program that has it open \ + or reconnecting its drive), open a different library or create a new one, or continue without a library — \ + then nothing you do is saved."; const CHOOSER_WINDOW_ID: &str = "library-chooser"; /// The named `Foreground` layer used by the dim and the two blocking library windows. @@ -1016,6 +1020,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { dim(ctx); let mut choice = None; let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); + // what the window says, in one piece, for a screen reader: the labels below are separate nodes + let description = if problem.path.is_empty() { + format!("{} {} {EXPLANATION}", crate::i18n::tr("LightCraft couldn't find where to keep your library."), problem.error) + } else { + format!("{} {}. {} {EXPLANATION}", crate::i18n::tr("LightCraft couldn't open the library at"), problem.path, problem.error) + }; let shown = egui::Window::new(crate::i18n::tr("Your library couldn't be opened")) .id(egui::Id::unique(ERROR_WINDOW_ID)) .order(egui::Order::Foreground) @@ -1035,14 +1045,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { ui.label(RichText::new(&problem.path).font(t.semibold(12.5)).color(t.text)); } ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap()); - ui.label( - RichText::new( - "Your photos and edits in it are untouched. You can try again (e.g. after closing another program that has it open \ - or reconnecting its drive), open a different library or create a new one, or continue without a library — \ - then nothing you do is saved.", - ) - .color(t.text_dim), - ); + ui.label(RichText::new(EXPLANATION).color(t.text_dim)); ui.add_space(4.0); ui.horizontal(|ui| { let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| { @@ -1060,6 +1063,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { }); if let Some(w) = &shown { crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Alert); + crate::widgets::describe_dialog(ctx, &w.response, &description); // A press on the dim raises its area; keep this window (and its own buttons) above it. ctx.move_to_top(w.response.layer_id); } diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index e2ded68ee..1d6067f0a 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -513,6 +513,7 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s // A backdrop press can raise its area; keep the prompt buttons above it. if let Some(window) = shown { crate::widgets::announce_dialog(ctx, &window.response, crate::widgets::Modal::Alert); + crate::widgets::describe_dialog(ctx, &window.response, text); ctx.move_to_top(window.response.layer_id); } input.button_ids = seen; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d9658b097..bf3f914c9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1186,6 +1186,13 @@ pub(crate) fn announce_dialog(ctx: &egui::Context, response: &Response, kind: Mo }); } +/// Gives the dialog `response` drew the words it says as its accessible description, for an alert +/// whose content (the task names, the error) is drawn as separate labels: a screen reader then +/// has what the alert is about when it announces the alert, not only its title. +pub(crate) fn describe_dialog(ctx: &egui::Context, response: &Response, text: &str) { + ctx.accesskit_node_builder(response.id, |node| node.set_description(text)); +} + /// The same for one that is on or off (a toggle, a selected row or swatch). pub(crate) fn announce_selected(resp: &egui::Response, name: impl Into, selected: bool) { let name = name.into(); diff --git a/crates/ui-egui/tests/it/dialog_role.rs b/crates/ui-egui/tests/it/dialog_role.rs index 48b1f0751..a36cc96fe 100644 --- a/crates/ui-egui/tests/it/dialog_role.rs +++ b/crates/ui-egui/tests/it/dialog_role.rs @@ -85,3 +85,30 @@ fn a_library_that_could_not_be_opened_is_a_modal_alert_dialog() { assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} is a modal alert dialog"); } } + +/// The description of every alert dialog in the tree. +fn alert_descriptions(h: &Harness<'_, LightcraftApp>) -> Vec { + h.root() + .children_recursive() + .map(|n| n.accesskit_node()) + .filter(|n| n.role() == Role::AlertDialog) + .map(|n| n.data().description().unwrap_or_default().to_string()) + .collect() +} + +#[test] +fn an_alert_carries_what_it_says_as_its_description() { + let mut h = harness(); + h.state_mut().quit_prompt = Some(lightcraft_ui_egui::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into())); + h.run_steps(6); + let said = alert_descriptions(&h); + assert!(said.iter().any(|d| d.contains("1 task is still running: Import")), "the quit prompt names the task: {said:?}"); + let mut h = harness(); + h.state_mut().library_problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem::new("/no/such/library", "No catalog here.")); + h.run_steps(6); + let said = alert_descriptions(&h); + assert!( + said.iter().any(|d| d.contains("/no/such/library") && d.contains("No catalog here.") && d.contains("untouched")), + "the library alert has its path, error and explanation: {said:?}" + ); +} From 08a70fa7c3a800b25bbba527e803cefc1a2b6306 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 29 Oct 2026 14:02:02 -0400 Subject: [PATCH 4/4] LR-BEHAV-ACCESS: the What's New entry and the revision row name the alert dialog's description --- docs/parity-checklist.md | 2 +- docs/whats-new.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5b9de7ae9..8ca982127 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -959,4 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | -| 2026-10-28 | patch | LR-BEHAV-ACCESS: every dialog, the library chooser and the quit, library-problem and settings-file prompts are exposed as a modal dialog (an alert dialog for the prompts), and the update check's answer as a non-modal one, instead of a group with the title (`tests/it/dialog_role.rs`); not tried with a real screen reader; status stays 🟡 | +| 2026-10-28 | patch | LR-BEHAV-ACCESS: every dialog, the library chooser and the quit, library-problem and settings-file prompts are exposed as a modal dialog (an alert dialog for the prompts), and the update check's answer as a non-modal one, instead of a group with the title, and an alert dialog carries what it says (the library problem's path, error and explanation; a prompt's text) as its description (`tests/it/dialog_role.rs`); not tried with a real screen reader; status stays 🟡 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 2e05e59e2..21a51a3df 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,6 +7,8 @@ library that could not be opened, a settings file problem) are now exposed to screen readers as dialogs: modal ones, so the rest of the window is marked as blocked while one is open, and an alert dialog for the prompts. The answer to Check for Updates is a dialog too, but not a modal one. Before, each was a plain group that only carried the dialog's title. +- An alert dialog also carries what it says as its description: a screen reader can read the library problem's path, error and + explanation, or a prompt's text, together with the dialog. ## 2026-10-24