LR-BEHAV-ACCESS: AccessKit tree audit test; name fields, give sliders range

tests/accessibility.rs walks the AccessKit tree headless in every view,
Develop tool and 13 dialogs and fails on unnamed controls, sliders without
value/range and untitled dialogs. Fixes what it found: text fields are
named by caption or hint, develop sliders expose min/max/step/value text
and take Increment/Decrement, Tab-focused sliders take the arrow keys,
Auto Stack's slider is named. Also as_chunks in spots.rs for the newer
clippy lint. Docs: ui-parity audit section, checklist, gaps, ROADMAP.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrew FrahnandClaude Opus 5.5 committed 2026-10-12 03:18:48 +11:00
1 parent 76b5377868
commit 530320a6bd
12 files changed
+318 -30

No files matched your search

+3 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
@@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see
|---|---:|---:|---:|---|
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
@@ -161,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
+2 -2
View File
@@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh
let flat: Vec<f32> = tgt.iter().flatten().copied().collect();
let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default())
.unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole));
for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) {
*d = [v[0], v[1], v[2]];
for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) {
*d = *v;
}
}
+16 -15
View File
@@ -298,13 +298,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg {
Dialog::AutoStack { gap } => {
ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label));
ui.add(
let r = ui.add(
egui::Slider::new(gap, 0.0..=86400.0)
.logarithmic(true)
.smallest_positive(1.0)
.custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v))
.custom_parser(|s| s.trim().trim_end_matches('s').parse().ok()),
);
crate::widgets::named(r, crate::i18n::tr("Stack photos taken within this time of each other:"));
let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default();
let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" };
ui.label(
@@ -359,7 +360,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Dialog::AllMetadata { title, rows, search } => {
ui.label(egui::RichText::new(title.as_str()).color(t.text_label));
let r = ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY));
let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)), crate::i18n::tr("Filter fields"));
crate::widgets::register(ui.ctx(), "field:metadataSearch", r.rect);
let q = search.trim().to_lowercase();
let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q);
@@ -402,7 +403,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
}
Dialog::SmartRules { id, name, rules, .. } => {
let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY));
let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Name"));
crate::widgets::register(ui.ctx(), "field:smartName", r.rect);
ui.add_space(6.0);
// the folder an album made from a folder view carries is not in the editor, but it counts
@@ -508,12 +509,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
field(ui, &colour, |ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 6.0, crate::panels::grid::label_color(*l));
let te = ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY));
let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)), crate::i18n::tr(&colour));
crate::widgets::register(ui.ctx(), format!("field:labelName-{}", colour.to_lowercase()), te.rect);
});
}
field(ui, "Save as set", |ui| {
let te = ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY));
let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)), crate::i18n::tr("Save as set"));
crate::widgets::register(ui.ctx(), "field:labelSetName", te.rect);
});
ui.label(
@@ -528,7 +529,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let tags_open = field(ui, "Template", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = crate::import::tag_field_width(ui);
let r = ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w));
let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)), crate::i18n::tr("Template"));
crate::widgets::register(ui.ctx(), "field:renameTemplate", r.rect);
crate::import::tag_toggle(ui, "renameTemplate")
});
@@ -614,8 +615,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
Dialog::CreatePreset { name, group, groups } => {
ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY));
ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY));
crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)), crate::i18n::tr("Preset name"));
crate::widgets::named(ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)), crate::i18n::tr("Group"));
ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim));
group_checklist(ui, "presetInclude", groups);
}
@@ -823,7 +824,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
{
wm.image = f;
}
ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width()));
crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), crate::i18n::tr("Image"));
});
});
let mut width = wm.image_width as f64 * 100.0;
@@ -833,7 +834,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
} else {
choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical);
field(ui, "Text", |ui| {
ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY))
crate::widgets::named(ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Text"))
});
}
choices(
@@ -885,7 +886,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let tags_open = field(ui, "File name", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = crate::import::tag_field_width(ui);
ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w));
crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w)), crate::i18n::tr("File name"));
crate::import::tag_toggle(ui, "exportNaming")
});
if tags_open {
@@ -907,11 +908,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
{
*dir = d;
}
ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width()));
crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder"));
});
});
field(ui, "Subfolder", |ui| {
ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY))
crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Subfolder"))
});
use lightcraft_engine::export::Conflict as K;
choices(
@@ -928,7 +929,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
crate::widgets::register(ui.ctx(), "exportAddToPhotos", r.rect);
if opts.add_to_photos {
field(ui, "Album", |ui| {
ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY))
crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Album"))
});
ui.label(
egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos."))
@@ -956,7 +957,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Err(e) => app.toast(ui.ctx(), e),
}
}
ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width()));
crate::widgets::named(ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())), crate::i18n::tr("Preset name"));
});
});
}
+4 -1
View File
@@ -114,9 +114,11 @@ fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label(crate::i18n::tr("Custom"));
let a = ui.add(egui::TextEdit::singleline(&mut w).desired_width(34.0));
crate::widgets::screen_reader_name(ui.ctx(), a.id, crate::i18n::tr("Width"));
register(ui.ctx(), "field:cropCustomW", a.rect);
ui.label("×");
let b = ui.add(egui::TextEdit::singleline(&mut h).desired_width(34.0));
crate::widgets::screen_reader_name(ui.ctx(), b.id, crate::i18n::tr("Height"));
register(ui.ctx(), "field:cropCustomH", b.rect);
let go = text_button(ui, "cropCustomApply", "Apply", false);
apply = go.clicked() || ((a.lost_focus() || b.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)));
@@ -644,6 +646,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim));
let mut text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("latitude, longitude")).desired_width(f32::INFINITY));
crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS");
register(ui.ctx(), "field:gps", r.rect);
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(gid, text.clone()));
@@ -796,7 +799,7 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str
} else {
crate::text_field::TextField::singleline(&widget, &mut text)
};
let r = field.width(f32::INFINITY).show(ui);
let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui);
// the typed text outlives frames while it is edited (the photo's value is drawn otherwise)
if r.editing {
ui.data_mut(|d| d.insert_temp(id, text.clone()));
+1
View File
@@ -103,6 +103,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim);
}
let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search)
.label(crate::i18n::tr("Search Photos"))
.frame(false)
.width(sr.width() - 20.0)
.font(t.font(13.5))
+15
View File
@@ -51,6 +51,7 @@ pub struct TextField<'a> {
text: &'a mut String,
multiline: bool,
hint: Option<String>,
label: Option<String>,
width: Option<f32>,
select_on_focus: bool,
begin_edit: bool,
@@ -81,6 +82,7 @@ impl<'a> TextField<'a> {
text,
multiline: false,
hint: None,
label: None,
width: None,
select_on_focus: false,
begin_edit: false,
@@ -104,6 +106,13 @@ impl<'a> TextField<'a> {
self
}
/// The name a screen reader announces for the field (its visible caption); the hint
/// stands in when there is none.
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = Some(width);
self
@@ -182,6 +191,7 @@ impl<'a> TextField<'a> {
Action::Refocus => {}
}
}
let name = self.label.clone().or_else(|| self.hint.clone());
let mut edit = if self.multiline { egui::TextEdit::multiline(self.text) } else { egui::TextEdit::singleline(self.text) };
edit = edit.id(id);
if let Some(hint) = self.hint {
@@ -214,6 +224,11 @@ impl<'a> TextField<'a> {
let right = ui.input(|i| i.pointer.button_down(egui::PointerButton::Secondary) || i.pointer.button_released(egui::PointerButton::Secondary));
let selection = egui::text_edit::TextEditState::load(ui.ctx(), id).and_then(|s| s.cursor.char_range());
let mut response = ui.add(edit);
// screen readers: the field by its caption, even once it holds text (egui names it by
// nothing but its contents)
if let Some(name) = name.filter(|n| !n.trim().is_empty()) {
ui.ctx().accesskit_node_builder(response.id, |node| node.set_label(name));
}
// The menu may take focus on a primary-button release. egui collapses a
// TextEdit selection when focus leaves; retain the range our own menu acts on.
if right && response.contains_pointer() || memo.menu_open && !ui.memory(|m| m.has_focus(id)) {
+48 -1
View File
@@ -185,6 +185,10 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() });
// A click selects the slider for arrow/wheel adjustments. Keep scroll-to-adjust
// opt-in so hovering an unfocused slider still scrolls the containing panel.
// Tab reaching the slider selects it as a click does: the arrow keys then adjust it
if enabled && resp.gained_focus() {
ui.data_mut(|d| d.insert_temp(focused_slider_key(), id));
}
if enabled && (resp.clicked() || resp.drag_started()) {
resp.request_focus();
ui.data_mut(|d| d.insert_temp(focused_slider_key(), id));
@@ -192,6 +196,18 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
// screen readers: a slider named after its control, with its value
let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string();
resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone()));
// and its range, step and the value as shown ("+0.50", "5600")
let shown = shown_value(spec, value);
ui.ctx().accesskit_node_builder(resp.id, |node| {
node.set_min_numeric_value(spec.min);
node.set_max_numeric_value(spec.max);
node.set_numeric_value_step(spec.step);
node.set_value(shown);
if enabled {
node.add_action(egui::accesskit::Action::Increment);
node.add_action(egui::accesskit::Action::Decrement);
}
});
let label_resp = ui.interact(label_rect, id.with("label"), Sense::click());
register(ui.ctx(), format!("slider:{}", spec.id), track_rect);
// the value: click it to type an exact one (over the label, so it takes the click)
@@ -292,13 +308,23 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
if resp.drag_stopped() {
out.drag_stopped = true;
}
// a screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down)
let (inc, dec) = ui.input(|i| {
i.events.iter().fold((false, false), |(inc, dec), e| match e {
egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => {
(inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement)
}
_ => (inc, dec),
})
});
// Arrow keys work with either a hovered or a focused slider.
if enabled && out.value.is_none() && typing.is_none() && (ui.rect_contains_pointer(row) || resp.has_focus()) {
if enabled && out.value.is_none() && typing.is_none() && (ui.rect_contains_pointer(row) || resp.has_focus() || inc || dec) {
let (up, down, shift) = ui.input_mut(|i| {
let shift = i.modifiers.shift;
let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE };
(i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift)
});
let (up, down) = (up || inc, down || dec);
if up || down {
let nv = nudged(spec, value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 });
if (nv - value).abs() > 1e-12 {
@@ -665,6 +691,7 @@ mod access_tests {
ctx.enable_accesskit();
let spec = lightcraft_develop::controls::find("light.exposure").unwrap();
let mut found = Vec::new();
let mut ranges = Vec::new();
for _ in 0..3 {
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
let _ = slider(ui, spec, 0.5, true, None);
@@ -674,6 +701,12 @@ mod access_tests {
let mut out = out;
out.textures_delta.clear();
if let Some(update) = out.platform_output.accesskit_update.take() {
ranges = update
.nodes
.iter()
.filter(|(_, n)| n.role() == egui::accesskit::Role::Slider)
.map(|(_, n)| (n.min_numeric_value(), n.max_numeric_value()))
.collect();
found = update
.nodes
.iter()
@@ -684,11 +717,25 @@ mod access_tests {
let has = |role: &str, label: &str| found.iter().any(|(r, l, _)| r == role && l == label);
assert!(has("Slider", "Exposure"), "{found:?}");
assert!(found.iter().any(|(r, l, v)| r == "Slider" && l == "Exposure" && *v == Some(0.5)), "the slider's value: {found:?}");
assert!(ranges.contains(&(Some(spec.min), Some(spec.max))), "the slider's range: {ranges:?}");
assert!(has("Button", "Auto"), "{found:?}");
assert!(has("Button", "Delete mask"), "{found:?}");
}
}
/// Names a widget for screen readers (`name` replaces what egui gave it): for a plain egui field
/// whose caption is a separate label beside it.
pub(crate) fn screen_reader_name(ctx: &egui::Context, id: egui::Id, name: &str) {
ctx.accesskit_node_builder(id, |node| node.set_label(name.to_string()));
}
/// `r`, named `name` for screen readers: a plain egui field or slider whose caption is drawn
/// beside it, or whose hint is all it shows.
pub(crate) fn named(r: egui::Response, name: &str) -> egui::Response {
screen_reader_name(&r.ctx, r.id, name);
r
}
/// `text` laid out on one line, cut short with … when it is wider than `max` (the galley's
/// `elided` says whether it was): for text painted into a fixed box (a tile, a row before its
/// buttons), which a plain `Painter::text` would draw past.
+189
View File
@@ -0,0 +1,189 @@
//! Accessibility audit: walks the AccessKit tree egui exposes to screen readers (VoiceOver,
//! Narrator, AT-SPI) in each main view and Develop tool, headless, and fails on an interactive
//! node with no name, a slider without its value and range, or a canvas tool no key can reach.
use egui::accesskit::Role;
use egui::vec2;
use egui_kittest::{Harness, kittest::NodeT};
use lightcraft_ui_egui::{
LightcraftApp, Services,
state::{RightPanel, ViewMode},
};
fn harness(view: ViewMode, right: RightPanel) -> Harness<'static, LightcraftApp> {
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
app.ui.left_panel = true;
app.ui.view = view;
app.ui.right = right;
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
}
fn interactive(role: Role) -> bool {
matches!(
role,
Role::Button
| Role::CheckBox
| Role::Switch
| Role::RadioButton
| Role::Slider
| Role::SpinButton
| Role::TextInput
| Role::MultilineTextInput
| Role::SearchInput
| Role::ComboBox
| Role::EditableComboBox
| Role::Tab
| Role::Link
| Role::MenuItem
| Role::MenuItemCheckBox
| Role::MenuItemRadio
| Role::ListBoxOption
)
}
/// Every problem found in the harness's current tree, one line each.
fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec<String> {
let mut problems = Vec::new();
let mut count = 0;
for node in h.root().children_recursive() {
let n = node.accesskit_node();
let role = n.role();
if !interactive(role) {
continue;
}
count += 1;
let name = n.label().unwrap_or_default();
let name = if name.trim().is_empty() { n.value().unwrap_or_default() } else { name };
if name.trim().is_empty() {
problems.push(format!("{place}: unnamed {role:?} at {:?}", n.bounding_box()));
}
if role == Role::Slider && (n.numeric_value().is_none() || n.min_numeric_value().is_none() || n.max_numeric_value().is_none()) {
problems.push(format!("{place}: slider {name:?} lacks value/range"));
}
}
assert!(count > 5, "{place}: the tree has interactive nodes ({count})");
problems
}
#[test]
fn every_interactive_node_has_a_name_in_every_view_and_tool() {
let mut problems = Vec::new();
for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail, ViewMode::Compare, ViewMode::Survey, ViewMode::Reference] {
let h = harness(view, RightPanel::None);
problems.extend(audit(&h, &format!("{view:?}")));
}
for right in [
RightPanel::Edit,
RightPanel::Profiles,
RightPanel::Crop,
RightPanel::Remove,
RightPanel::Masking,
RightPanel::RedEye,
RightPanel::Versions,
RightPanel::Activity,
RightPanel::Keywords,
RightPanel::Info,
] {
let h = harness(ViewMode::Detail, right);
problems.extend(audit(&h, &format!("Detail/{right:?}")));
}
// the dialogs: each a window with a title, every control in it named
for dialog in [
"dialog.export",
"dialog.copySettings",
"dialog.pasteSettings",
"dialog.createPreset",
"dialog.rename",
"dialog.newAlbum",
"dialog.newSmartAlbum",
"dialog.captureTime",
"dialog.labelNames",
"dialog.allMetadata",
"dialog.saveMetadataPreset",
"dialog.autoStack",
"dialog.contactSheet",
] {
let mut h = harness(ViewMode::PhotoGrid, RightPanel::None);
if let Err(e) = h.state_mut().run(dialog, serde_json::json!({})) {
problems.push(format!("{dialog}: does not open ({e})"));
continue;
}
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 untitled = h
.root()
.children_recursive()
.map(|n| n.accesskit_node())
.filter(|n| n.role() == Role::Window && n.label().unwrap_or_default().trim().is_empty())
.count();
if untitled > 0 {
problems.push(format!("{dialog}: {untitled} of {windows} windows have no title"));
}
}
problems.sort();
problems.dedup();
assert!(problems.is_empty(), "{} accessibility problems:\n{}", problems.len(), problems.join("\n"));
}
fn slider_value(h: &Harness<'_, LightcraftApp>, name: &str) -> Option<f64> {
h.root()
.children_recursive()
.map(|n| n.accesskit_node())
.find(|n| n.role() == Role::Slider && n.label().as_deref() == Some(name))
.and_then(|n| n.numeric_value())
}
/// A screen reader's Increment on a develop slider moves it one step, as an arrow key does.
#[test]
fn screen_reader_increment_moves_a_slider() {
let mut h = harness(ViewMode::Detail, RightPanel::Edit);
let before = slider_value(&h, "Exposure").expect("Exposure is in the tree");
let target = h
.root()
.children_recursive()
.find(|n| n.accesskit_node().role() == Role::Slider && n.accesskit_node().label().as_deref() == Some("Exposure"))
.unwrap()
.accesskit_node()
.locate();
h.event(egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest {
action: egui::accesskit::Action::Increment,
target_node: target.0,
target_tree: target.1,
data: None,
}));
h.run_steps(4);
let after = slider_value(&h, "Exposure").unwrap();
assert!(after > before, "{before} -> {after}");
}
/// The canvas tools' settings (crop angle and offsets, spot size, mask amount) are reachable with
/// Tab alone, and the arrow keys then adjust them.
#[test]
fn canvas_tool_sliders_are_reachable_with_tab() {
for right in [RightPanel::Crop, RightPanel::Remove] {
let mut h = harness(ViewMode::Detail, right);
let mut reached = None;
for _ in 0..300 {
h.key_press(egui::Key::Tab);
h.run_steps(1);
let focused = h.root().children_recursive().map(|n| n.accesskit_node()).find(|n| n.is_focused() && n.role() == Role::Slider);
if let Some(n) = focused {
reached = Some((n.label().unwrap_or_default(), n.numeric_value()));
break;
}
}
let (name, before) = reached.unwrap_or_else(|| panic!("{right:?}: no slider takes the focus with Tab"));
h.key_press(egui::Key::ArrowUp);
h.run_steps(4);
let after = slider_value(&h, &name);
assert_ne!(before, after, "{right:?}: ArrowUp on the Tab-focused {name:?}");
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 23: AccessKit audit test landed, estimate 10–20 h → 4–8 h) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -37,7 +37,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) |
| 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) |
| 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) |
| 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) |
| 23 | **Accessibility**: AccessKit tree audited by a test (every control named, sliders with range); left: canvas handles, spot and mask placement by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 4–8 | [ui-parity](ui-parity.md) |
| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) |
| 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) |
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
@@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Gap 23 (accessibility): the AccessKit tree audit and its fixes landed; what is left is canvas placement by keyboard and a real screen-reader session; 10–20 h → 4–8 h; gap not closed |
| 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap |
| 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed |
| 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h |
+3 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-BEHAV-ACCESS: AccessKit audit test) · **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
@@ -472,7 +472,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
| LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu |
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab. Canvas handles, spot and mask placement are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. |
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
@@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | LR-BEHAV-ACCESS: AccessKit tree audit test; text fields named, sliders expose range and Increment / Decrement; status stays 🟡 |
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# Target-app parity: LightCraft vs Adobe Lightroom
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is
[parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas
@@ -48,7 +48,7 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|---|---:|---:|---:|---|
| Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) |
| ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) |
| UI/UX fidelity | ~85% | ~70% | 40–70 h | [ui-parity.md](ui-parity.md) |
| UI/UX fidelity | ~85% | ~70% | 34–58 h | [ui-parity.md](ui-parity.md) |
| File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) |
| Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) |
| Localization (12 key languages) | 7 of 12 present | ~52% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) |
@@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged |
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged |
| 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points |
| 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points |
+31 -3
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (zoom row: Compare and Reference sharp at 1:1) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier
keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in
@@ -32,11 +32,11 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla
| Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer |
| Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item |
| Text fields | native editing behaviour | custom text field (LR-BEHAV-TEXTFIELD) | 70% | IME and selection edge cases |
| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit: sliders, buttons, dropdowns, section headers announce | 30% | canvas tools pointer-only; not audited with a screen reader |
| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit tree audited by a test in every view, Develop tool and the main dialogs: every control named, sliders with value, range and Increment / Decrement, dialogs titled, tool sliders reachable with Tab | 55% | crop handles, spot and mask placement on the canvas still need the pointer; not tried with a real screen reader ([audit](#accessibility-audit)) |
| Feel and responsiveness | GPU-interactive sliders (15.3) | ≤ 16 ms slider budget on GPU; background renders | 75% | colour NR at half resolution, 100k-photo grid test |
| **UI/UX overall** | | | **~70%** | estimated, weighted by use (develop and library 60%, tools 25%, rest 15%) |
Remaining: **40–70 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility audit 10–20
Remaining: **34–58 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility 4–8
h, Classic keymap layer 4–8 h, context-menu audit 4–8 h, text-field and IME polish 4–8 h). Output
modules and AI tools are counted with their features in [target-app-parity.md](target-app-parity.md).
@@ -74,9 +74,37 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
- ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified).
## Accessibility audit
`crates/ui-egui/tests/accessibility.rs` walks the AccessKit tree egui hands to VoiceOver, Narrator and
AT-SPI, headless, in Photo Grid, Square Grid, Detail, Compare, Survey and Reference, in each right
panel (Edit, Profiles, Crop, Remove, Masking, Red Eye, Versions, Activity, Keywords, Info) and in 13
dialogs (Export, Copy / Paste Settings, Create Preset, Rename, New Album, New Smart Album, Capture
Time, Label Names, All Metadata, Save Metadata Preset, Auto Stack, Contact Sheet). It fails on a
button, check box, slider, text field, dropdown, tab, link or menu item without a name, on a slider
without its value and range, and on a dialog window without a title.
Found and fixed (2026-10-12): 47 problems in the views and tools, 11 more in the dialogs.
- Every text field announced only its contents: the top search box, the Info panel's metadata fields
and GPS, the keyword fields, Crop's custom aspect, and plain egui fields in the dialogs (export file
name, folder, subfolder, watermark, preset names, label names, rename template, metadata filter).
`TextField` now names itself by its caption (`.label`) or hint; plain egui fields are named with
`widgets::named`.
- Develop sliders announced a value but no range: they now carry min, max, step, the value as shown
("+0.50"), and answer a screen reader's Increment / Decrement (VoiceOver Ctrl-Option-Up / Down) by
one step, as the arrow keys do. A slider reached with Tab takes the arrow keys as a clicked one does.
- Auto Stack's time slider had no name.
Keyboard reach: Crop (angle, offsets, scale, aspect) and Remove (size, feather, opacity) settings
are reached with Tab and adjusted with the arrow keys (tested). Still pointer-only: dragging crop
handles, placing a spot or a brush stroke, and moving a mask pin on the canvas. Not done: a session
with a real screen reader on each platform, and focus order (egui follows drawing order: top bar,
left panel, canvas, right panel, which is sensible but not tuned).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h |
| 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing |
| 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist |