Browse / github.com/storytold/lightcraft / patch
LR-PREF-INTERFACE: Settings › Interface size, Auto or 50–300%
LR-PREF-INTERFACE: Settings › Interface size, Auto or 50–300% ## Problem LightCraft has no interface size of its own: the interface follows the display scale, and there is no way to make it bigger or smaller, such as 150% on a 1080p laptop. The other Craft apps let you set one in their settings. ## Change - Settings › Interface gets an **Interface size** row: *Auto* follows the display scale, or a fixed 50–300% (150%, 157%…). It is saved in `ui.json` with the other app settings, and is a size in physical pixels per point, so it stays the same when the monitor's scale changes (`crates/ui-egui/src/{state,lib}.rs`, `panels/settings.rs`). - `engine.execut
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- abdallah-shehawey (imported from pull request #3034, open)
- Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0- Size
- +161 −4 in 31 files
- Review
- pending
- Shared
- 2026-10-28
- Id
5e5e6675169913175d216478
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md contributors/people.toml crates/ui-egui/locales/cs.json crates/ui-egui/locales/de.json crates/ui-egui/locales/es.json crates/ui-egui/locales/fi.json crates/ui-egui/locales/fr.json crates/ui-egui/locales/id.json crates/ui-egui/locales/it.json crates/ui-egui/locales/ja.json crates/ui-egui/locales/ka.json crates/ui-egui/locales/pl.json crates/ui-egui/locales/pt-br.json crates/ui-egui/locales/ru.json crates/ui-egui/locales/sv.json crates/ui-egui/locales/uk.json crates/ui-egui/locales/vi.json crates/ui-egui/locales/zh-hans.json crates/ui-egui/locales/zh-hant.json crates/ui-egui/src/app.rs crates/ui-egui/src/control.rs crates/ui-egui/src/dialogs/settings/display.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus/run.rs crates/ui-egui/src/menus/ui_commands.rs crates/ui-egui/src/state.rs crates/ui-egui/src/state/settings.rs crates/ui-egui/src/tests/dialog_settings.rs docs/control-protocol.md docs/parity-checklist.md docs/ui-parity.md
@@ -1,8 +1,12 @@ # LightCraft roadmap +> **Review follow-up (2026-10-27):** `ui.set` accepts interface size zero (Auto) or integer percentages from 50 through 300, matching `app.interfaceSize`. Invalid control patches leave every preference unchanged. Scores and estimates unchanged.++> **Review follow-up (2026-10-26):** Restored nonzero interface sizes are normalized to 50-300 percent before the next save; Auto remains zero. Regression coverage added; parity scores and estimates unchanged.+ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Print: Japanese photo and postcard paper sizes — L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺; Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** trivial (control interface-size validation; progress log: Settings › Interface size, Auto or 50–300%) · **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@@ -114,6 +118,11 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-26: Restored nonzero interface sizes are normalized to 50-300 percent before the next save; Auto remains zero. Regression coverage added; scores and estimates unchanged.++- 2026-10-26: Settings › Interface sets an interface size that persists: Auto (follow the display+ scale) or 50–300%, such as 150% or 157%, independent of the monitor's scale; `app.interfaceSize`+ sets it from the control channel. - 2026-10-22: Print page setup and PDF output gain the Japanese photo and postcard papers — L判 (89 × 127 mm), 2L判 (127 × 178), KG判 (102 × 152), はがき (100 × 148), 往復はがき (148 × 200), 六切 (203 × 254), 四切 (254 × 305) and 名刺 (55 × 91) — at their exact metric sizes. The Print dialog@@ -191,6 +200,9 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---|+| 2026-10-27 | trivial | Validate `ui.set` interface-size input atomically with the same integer range as `app.interfaceSize`; estimates unchanged |+| 2026-10-26 | trivial | Restored nonzero interface sizes are normalized to 50-300 percent before the next save; Auto remains zero. Scores and estimates unchanged. |+| 2026-10-26 | trivial | Progress log: Settings › Interface size, Auto or 50–300% (`app.interfaceSize`) | | 2026-10-22 | minor | Print: Japanese photo and postcard paper sizes (L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺) in the page setup, Print dialog and PDF output; headline numbers unchanged | | 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present | | 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |@@ -74,3 +74,6 @@ sync_github_name = true [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true++[people.abdallah-shehawey]+sync_github_name = true@@ -947,6 +947,7 @@ "Intent:": "Záměr:", "Interface": "Rozhraní", "Interface language": "Jazyk rozhraní",+ "Interface size": "Velikost rozhraní", "Intersect": "Průnik", "Intersect with": "Průnik s", "Into one folder": "Do jedné složky",@@ -743,6 +743,7 @@ "Instant ’70s": "Sofortbild der 70er", "Interface": "Oberfläche", "Interface language": "Sprache der Oberfläche",+ "Interface size": "Größe der Oberfläche", "Intersect": "Schnittmenge", "Intersect with": "Schnittmenge mit", "Into one folder": "In einen Ordner",@@ -745,6 +745,7 @@ "Instant ’70s": "Instantánea de los 70", "Interface": "Interfaz", "Interface language": "Idioma de la interfaz",+ "Interface size": "Tamaño de la interfaz", "Intersect": "Intersecar", "Intersect with": "Intersecar con", "Into one folder": "En una sola carpeta",@@ -958,6 +958,7 @@ "Intent:": "Tarkoitus:", "Interface": "Käyttöliittymä", "Interface language": "Käyttöliittymän kieli",+ "Interface size": "Käyttöliittymän koko", "Intersect": "Leikkaus", "Intersect with": "Leikkaa kanssa", "Into one folder": "Yhteen kansioon",@@ -703,6 +703,7 @@ "Instant ’70s": "Instantané années 70", "Interface": "Interface", "Interface language": "Langue de l'interface",+ "Interface size": "Taille de l'interface", "Intersect": "Intersecter", "Intersect with": "Intersecter avec", "Into one folder": "Dans un seul dossier",@@ -962,6 +962,7 @@ "Intent:": "Metode rendering:", "Interface": "Antarmuka", "Interface language": "Bahasa antarmuka",+ "Interface size": "Ukuran antarmuka", "Intersect": "Iriskan dengan", "Intersect with": "Iriskan dengan", "Into one folder": "Ke satu folder",@@ -705,6 +705,7 @@ "Instant ’70s": "Istantanea anni '70", "Interface": "Interfaccia", "Interface language": "Lingua dell'interfaccia",+ "Interface size": "Dimensione dell'interfaccia", "Intersect": "Intersezione", "Intersect with": "Interseca con", "Into one folder": "In un'unica cartella",@@ -1596,6 +1596,7 @@ "Preferences": "環境設定", "Preferences…": "環境設定…", "Interface language": "表示言語",+ "Interface size": "表示サイズ", "Open…": "開く…", "New blank PDF": "空白の PDF を作成", "Create PDF from file…": "ファイルから PDF を作成…",@@ -1028,6 +1028,7 @@ "Intent:": "განზრახვა:", "Interface": "ინტერფეისი", "Interface language": "ინტერფეისის ენა",+ "Interface size": "ინტერფეისის ზომა", "Intersect": "გადაკვეთა", "Intersect with": "გადაკვეთა:", "Into one folder": "ერთ საქაღალდეში",@@ -686,6 +686,7 @@ "Instant ’70s": "Lata 70. od ręki", "Interface": "Interfejs", "Interface language": "Język interfejsu",+ "Interface size": "Rozmiar interfejsu", "Intersect": "Część wspólna", "Intersect with": "Przetnij z", "Into one folder": "Do jednego folderu",@@ -712,6 +712,7 @@ "Instant ’70s": "Instantâneo anos 70", "Interface": "Interface", "Interface language": "Idioma da interface",+ "Interface size": "Tamanho da interface", "Intersect": "Interseccionar", "Intersect with": "Interseccionar com", "Into one folder": "Em uma única pasta",@@ -592,6 +592,7 @@ "Instant ’70s": "Мгновенный стиль 70-х", "Interface": "Интерфейс", "Interface language": "Язык интерфейса",+ "Interface size": "Размер интерфейса", "Intersect": "Пересечение", "Intersect with": "Пересечь с", "Into one folder": "В одну папку",@@ -1031,6 +1031,7 @@ "Intent:": "Återgivningsmetod:", "Interface": "Gränssnitt", "Interface language": "Gränssnittsspråk",+ "Interface size": "Gränssnittsstorlek", "Intersect": "Skärning", "Intersect with": "Skär med", "Into one folder": "I en mapp",@@ -1595,6 +1595,7 @@ "not available here": "недоступно тут", "of the following rules:": "таких правил:", "Interface language": "Мова інтерфейсу",+ "Interface size": "Розмір інтерфейсу", "New blank PDF": "Новий порожній PDF", "Create PDF from file…": "Створити PDF з файлу…", "Create PDF from images…": "Створити PDF із зображень…",@@ -882,6 +882,7 @@ "Intent:": "Ý định:", "Interface": "Giao diện", "Interface language": "Ngôn ngữ giao diện",+ "Interface size": "Kích thước giao diện", "Intersect": "Giao", "Intersect with": "Giao với", "Into one folder": "Vào một thư mục",@@ -820,6 +820,7 @@ "Instant ’70s": "70 年代即时成像", "Interface": "界面", "Interface language": "界面语言",+ "Interface size": "界面大小", "Intersect": "相交", "Intersect with": "相交于", "Into one folder": "合并到一个文件夹",@@ -821,6 +821,7 @@ "Instant ’70s": "七〇年代拍立得", "Interface": "介面", "Interface language": "介面語言",+ "Interface size": "介面大小", "Intersect": "交集", "Intersect with": "與下列項目取交集", "Into one folder": "放入同一資料夾",@@ -21,6 +21,7 @@ impl LightcraftApp { // AI mask requests run on the model's worker; frames apply their results (never wait) session.segmenter.background = true; Self {+ applied_interface_size: None, session, ui: UiState::default(), services,@@ -541,6 +542,17 @@ impl LightcraftApp { /// hidden, from `Context::run_logic`. It may read the refreshed viewport fields only: never /// `i.time`, events, keys or dropped files. pub fn logic(&mut self, ctx: &egui::Context) {+ let percent = self.ui.settings.interface_size;+ let native = ctx.native_pixels_per_point().filter(|s| s.is_finite() && *s > 0.0).unwrap_or(1.0);+ let zoom = if percent == 0 { 1.0 } else { f32::from(percent.clamp(50, 300)) / (100.0 * native) };+ if self.applied_interface_size != Some((percent, native)) {+ // Auto keeps the host's transient zoom (including headless/control views). Only an+ // explicit preference, or returning from one to Auto, changes that zoom here.+ if percent != 0 || self.applied_interface_size.is_some_and(|(previous, _)| previous != 0) {+ ctx.set_zoom_factor(zoom);+ }+ self.applied_interface_size = Some((percent, native));+ } i18n::set_language(self.ui.language); // The fork's AccessKit root language and bidi runs follow the locale; presentation only. // Compare against the cached string: `Context::language()` allocates on every call, and@@ -375,6 +375,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest Ok(s) => s, Err(e) => return err(e), };+ // Check the raw patch before lenient preference decoding can turn malformed values+ // into Auto. Control requests use the same range as app.interfaceSize.+ if let Some(size) = p.pointer("/settings/interfaceSize")+ && !size.as_u64().is_some_and(|size| size == 0 || (50..=300).contains(&size))+ {+ return err("ui.set: `settings.interfaceSize` must be 0 (Auto) or an integer from 50 to 300");+ } let mut v = serde_json::to_value(&app.ui).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, p); match serde_json::from_value::<crate::UiState>(v) {@@ -76,6 +76,21 @@ pub(super) fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens } pub(super) fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {+ row(ui, t, crate::i18n::tr("Interface size"), |ui| {+ let percent = &mut app.ui.settings.interface_size;+ let mut automatic = *percent == 0;+ let auto = ui.checkbox(&mut automatic, crate::i18n::tr("Auto"));+ register(ui, "check:settings.interfaceSizeAuto", auto.rect);+ if auto.changed() {+ *percent = if automatic { 0 } else { 100 };+ }+ let mut value = if automatic { 100 } else { (*percent).clamp(50, 300) };+ let response = ui.add_enabled(!automatic, egui::DragValue::new(&mut value).range(50..=300).suffix("%"));+ register(ui, "value:settings.interfaceSize", response.rect);+ if response.changed() {+ *percent = value;+ }+ }); let system = app.system_theme(ui.ctx()); appearance(ui, t, &mut app.ui.settings, system); heading(ui, t, crate::i18n::tr("Filmstrip"));@@ -129,6 +129,7 @@ impl QuitPrompt { } pub struct LightcraftApp {+ applied_interface_size: Option<(u16, f32)>, pub(crate) model_setup: model_setup::Pending, /// Per-catalog-revision caches of library-wide results the panels show every frame /// (expensive on big libraries).@@ -620,3 +621,43 @@ pub struct Caches { /// What the grid did on its frames (benchmarks and tests check unchanged frames stay cheap). pub grid_stats: panels::grid::GridStats, }++#[cfg(test)]+mod interface_size_tests {+ use super::*;++ #[test]+ fn interface_size_survives_restart_and_display_scale_changes() {+ let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());+ let ctx = egui::Context::default();+ for native in [1.0, 1.5, 2.0] {+ for percent in [0, 100, 150, 157, 200] {+ app.run("app.interfaceSize", serde_json::json!({"percent": percent})).unwrap();+ let saved = serde_json::to_string(&app.ui).unwrap();+ app.ui = serde_json::from_str(&saved).unwrap();+ let mut input = egui::RawInput::default();+ input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native);+ for _ in 0..2 {+ let mut output = ctx.run_ui(input.clone(), |ui| app.logic(ui.ctx()));+ output.textures_delta.clear();+ }+ let expected = if percent == 0 { native } else { percent as f32 / 100.0 };+ assert!((ctx.pixels_per_point() - expected).abs() < 0.001);+ assert_eq!(app.ui.settings.interface_size, percent);+ }+ }+ for invalid in [serde_json::json!(-1), serde_json::json!(301), serde_json::json!(1.5), serde_json::json!("150"), serde_json::Value::Null] {+ let before = app.ui.settings.interface_size;+ assert!(app.run("app.interfaceSize", serde_json::json!({"percent": invalid})).is_err());+ assert_eq!(app.ui.settings.interface_size, before);+ }+ for (persisted, expected) in [(0, 0), (1, 50), (49, 50), (50, 50), (300, 300), (301, 300), (u16::MAX, 300)] {+ app.ui.settings.interface_size = persisted;+ let saved = serde_json::to_string(&app.ui).unwrap();+ app.ui = serde_json::from_str::<UiState>(&saved).unwrap().sanitized();+ assert_eq!(app.ui.settings.interface_size, expected);+ let again = serde_json::to_string(&app.ui).unwrap();+ assert_eq!(serde_json::from_str::<UiState>(&again).unwrap().settings.interface_size, expected);+ }+ }+}@@ -575,6 +575,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re "app.recentLibraries" => crate::panels::settings::recent_libraries(app, p), "app.clearRecentLibraries" => crate::panels::settings::recent_libraries(app, &json!({"clear": true})), "app.displayProfile" => crate::panels::settings::display_profile(app, p),+ "app.interfaceSize" => {+ let percent = p.get("percent").and_then(Value::as_u64).filter(|p| *p == 0 || (50..=300).contains(p));+ match percent {+ Some(percent) => {+ app.ui.settings.interface_size = percent as u16;+ Ok(json!({"percent": percent}))+ }+ None => Err("percent must be 0 (Auto) or an integer from 50 to 300".into()),+ }+ } "file.backupLibrary" | "file.restoreLibrary" => { let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; match action {@@ -251,6 +251,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), // Settings ▸ Display ("app.displayProfile", "Display Profile", None, ""),+ ("app.interfaceSize", "Interface size", None, ""), ("app.about", "About LightCraft", None, "Help"), ("app.checkForUpdates", "Check for Updates…", None, "Help"), ("app.systemInfo", "System Info…", None, "Help"),@@ -572,6 +572,9 @@ impl UiState { self.left_width = LEFT_WIDTH.clamp(self.left_width); self.right_width = RIGHT_WIDTH.clamp(self.right_width); self.brush_size = self.brush_size.clamp(0.002, 0.5);+ if self.settings.interface_size != 0 {+ self.settings.interface_size = self.settings.interface_size.clamp(50, 300);+ } self.sidebar.adopt_collapsed(&std::mem::take(&mut self.collapsed_sidebar)); self.dialog = None; self.fullscreen = false;@@ -107,6 +107,9 @@ impl LightTheme { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct AppSettings {+ /// Interface size in percent of a physical pixel, or zero to follow the display scale.+ #[serde(deserialize_with = "lenient")]+ pub interface_size: u16, /// Library opened at launch when no `--library` is given (empty = the default location). pub library_path: String, /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most@@ -210,6 +213,7 @@ pub enum LightroomImport { impl Default for AppSettings { fn default() -> Self { AppSettings {+ interface_size: 0, library_path: String::new(), recent_libraries: Vec::new(), recent_opened: Default::default(),@@ -30,6 +30,27 @@ fn demo(size: [f32; 2]) -> Headless { headless(lightcraft_engine::Session::with_demo(), size) } +#[test]+fn ui_set_rejects_invalid_interface_sizes_without_changing_preferences() {+ let mut h = headless(lightcraft_engine::Session::new(), [900.0, 560.0]);+ for invalid in [json!(1), json!(49), json!(301), json!(65536), json!(-1), json!(1.5), json!("150"), json!(null)] {+ let before = h.app.ui.settings.clone();+ let response = h.request("ui.set", json!({"settings": {"interfaceSize": invalid, "askBeforeDelete": !before.confirm_delete}}), T);+ assert_eq!(response["ok"], false, "{invalid}: {response}");+ assert_eq!(h.app.ui.settings, before, "an invalid patch is atomic: {invalid}");+ }+ for percent in [0, 50, 157, 300] {+ let response = h.request("ui.set", json!({"settings": {"interfaceSize": percent, "askBeforeDelete": false}}), T);+ assert_eq!(response["ok"], true, "{percent}: {response}");+ assert_eq!(h.app.ui.settings.interface_size, percent);+ assert!(!h.app.ui.settings.confirm_delete);+ }+ let response = h.request("ui.set", json!({"settings": {"askBeforeDelete": true}}), T);+ assert_eq!(response["ok"], true, "{response}");+ assert_eq!(h.app.ui.settings.interface_size, 300);+ assert!(h.app.ui.settings.confirm_delete);+}+ /// Given Settings open on `tab`, drawn until its layout has settled. fn show(h: &mut Headless, tab: &str) { let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": tab}}), T);@@ -95,6 +95,7 @@ filmstrip is visible. | `ui.dropFiles` | `{paths: [..], hover?}` | Files dragged from the file manager. `hover: true` makes every following frame report `paths` as hovered over the window (the drop card appears, `ui.widgets` lists `dropOverlay`) until another `ui.dropFiles` replaces them; `{paths: [], hover: true}` ends the hover. Without `hover` the files are dropped: the hover ends and the next frame receives them as dropped files, exactly as an OS drop arrives (photos and folders open the import review, or import at once with Settings ▸ Import ▸ Import dropped files without review). An empty path stands for a file whose path the platform doesn't tell | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. A `path` that did not exist when the request was admitted is written with create-new semantics and is never replaced if a file (including a newly imported original) appears there while the capture is queued; an existing ordinary file may be refreshed. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) |+| `engine.execute {command: "app.interfaceSize", params}` | `{percent: 0}` for Auto, or an integer from 50 to 300 | Saves the interface size (for example, 150 or 157 percent), independent of monitor DPI; malformed or out-of-range values return an error without changing the setting | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |@@ -149,6 +150,8 @@ Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) a Both reply `{mode, darkTheme, lightTheme}`; the same values are `settings.appearanceMode`, `settings.darkTheme` and `settings.lightTheme` in `ui.set`. Auto follows the system (on Linux the XDG desktop portal, watched for changes rather than polled) and is dark when the system says nothing.+Interface size is `app.interfaceSize {percent}` through `engine.execute`, or `ui.set {"settings": {"interfaceSize": 157}}`. Both accept integer zero (Auto) or 50 through 300. A malformed or out-of-range `ui.set` value rejects the entire patch and leaves preferences unchanged; omitting the field preserves its current value.+ Mask brushing uses the UI command `tool.brush` through `engine.execute`. With `{}` it activates painting on the current mask. `{"new": true}` creates a separate brush mask; `{"op": "add"}` or `{"op": "subtract"}` appends a brush component to the selected mask. Creating either starts in@@ -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-27 · **Change:** trivial (LR-PREF-INTERFACE: interface sizing retained across the settings and command module split; `ui.set` validates the same range atomically; previous: 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 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@@ -432,7 +432,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/dialogs/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; At launch: open the last library or ask (see LRC-LIB-CATALOG-SWITCH); a damaged settings file is kept as `.corrupt-<time>` and reported, an unreadable one is not overwritten that session (`library.info` → `settingsWarnings`); quitting with unsaved changes retries, then asks | | LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | |-| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings`, `cmd:view.appearance`, `cmd:view.theme` | Appearance Mode (Sync with System / Dark / Light; the top bar button cycles Auto, Light, Dark) with separate dark (Charcoal, Midnight) and light (Silver, Paper) themes previewed in cards; hover stars persist favorite themes, the appearance button context menu opens Interface settings and lists light favorites before dark favorites; filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay |+| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings`, `cmd:view.appearance`, `cmd:view.theme`, `cmd:app.interfaceSize`, `crates/ui-egui/src/control.rs` | Interface size (Auto follows the display scale, or 50–300%, kept across restarts); Appearance Mode (Sync with System / Dark / Light; the top bar button cycles Auto, Light, Dark) with separate dark (Charcoal, Midnight) and light (Silver, Paper) themes previewed in cards; hover stars persist favorite themes, the appearance button context menu opens Interface settings and lists light favorites before dark favorites; filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay; `ui.set` rejects malformed or out-of-range interface sizes atomically | | LR-PREF-DISPLAY | Monitor (display) colour management | P1 | 🟡 | `cmd:app.displayProfile`, `crates/codecs/src/display.rs`, `crates/engine/src/display.rs`, `crates/ui-egui/src/dialogs/settings.rs`, `apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787), on Linux the profile the desktop assigns in colord (read off the UI thread every 3 s; used with one display, or several sharing one profile); the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: Windows' WCS profile (needs FFI, so `unsafe`), X11 `_ICC_PROFILE`, per-monitor profiles on Linux (eframe doesn't say which monitor the window is on) | | LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off (a crash while the GPU device or its kernels are built, issue #250, turns it off for the next launch), preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | 🟡 | `cmd:faces.enable`, `cmd:faces.models.list`, `cmd:faces.models.inspect`, `cmd:faces.models.install`, `cmd:faces.models.download`, `cmd:faces.models.downloads`, `cmd:faces.models.downloadCancel`, `cmd:faces.models.remove`, `cmd:faces.models.select`, `cmd:dialog.faceModel`, `crates/faces/src/lib.rs`, `crates/ui-egui/src/dialogs/faces.rs` | Settings ▸ Faces: an off-by-default toggle and the face model list. Models are opt-in and never bundled, the 232 KB YuNet detector included. Press Download (terms first; then a pinned address, size and SHA-256 checked, fetched over pure-Rust https with `lightcraft-fetch`), or add any `.onnx` by picking or dropping it: it is recognised by hash or described from its shape, shows its licence terms and training-data note, runs a self-test, and installs only after an "I accept" box; an installed model is chosen and recognition switched on at once (the newest wins, each model keeps its own embedding cache). Non-commercial models are add-by-file only, never offered for download. Suggestions of names run on the shared pure-Rust CPU interpreter (`crates/faces/src/net.rs`, `crates/faces/src/runtime.rs`; bounded im2col/GEMM, checked ONNX shapes/operators; no external inference runtime; 40 public crops agree within 2.1e-6, one-thread SFace 34–36 ms / AuraFace 233–252 ms, see docs/faces.md) in a background worker and are never applied automatically. Thresholds are starting points calibrated on busts, not yet on live faces; the Recognise faces switch is off until a recognition model is chosen and installed (`faces.enable {enabled: true}` refuses otherwise; removing the last recogniser switches it off), issue #678 |@@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---|+| 2026-10-27 | trivial | LR-PREF-INTERFACE: retain interface sizing across the upstream settings, command and state module split | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |@@ -1,6 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (KEYC-METADATA: ⌘⌥⇧C / ⌘⌥⇧V Copy / Paste Metadata; KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named; KEYC-METADATA: ⌥0 / ⌥⇧0 next / previous keyword set; KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named; KEYC-METADATA: ⌘K Add Keywords; KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Review follow-up (2026-10-27):** `ui.set` accepts interface size zero (Auto) or integer percentages from 50 through 300, matching `app.interfaceSize`. Invalid control patches leave every preference unchanged. Scores and estimates unchanged.++> **Review follow-up (2026-10-26):** Restored nonzero interface sizes are normalized to 50-300 percent before the next save; Auto remains zero. Regression coverage added; parity scores and estimates unchanged.++> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** trivial (control interface-size validation; Settings › Interface size, Auto or 50–300%) · **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@@ -152,6 +156,9 @@ establish OS keyboard delivery or cross-platform acceptance. | Date | Change | Summary | |---|---|---|+| 2026-10-27 | trivial | Validate `ui.set` interface-size input atomically with the same integer range as `app.interfaceSize`; estimates unchanged |+| 2026-10-26 | trivial | Restored nonzero interface sizes are normalized to 50-300 percent before the next save; Auto remains zero. Scores and estimates unchanged. |+| 2026-10-26 | trivial | Settings › Interface: a persistent interface size, Auto or 50–300% (`app.interfaceSize`) | | 2026-10-24 | patch | KEYC-METADATA: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata | | 2026-10-24 | minor | KEYC-METADATA: ⌥0 / ⌥⇧0 (Alt+0 / Alt+Shift+0) go to the next / previous keyword set, Recent Keywords first, wrapping around at either end | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (Add Keywords) shows the Keywords panel with the cursor in its Add keyword box |