From 382cca190cf76713393ca554f56769ea11973771 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:50:20 -0400 Subject: [PATCH 1/3] =?UTF-8?q?M16.1:=20shortcuts=20=E2=80=94=20Lightroom-?= =?UTF-8?q?desktop=20secondary=20bindings,=20no=20double-firing=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds secondary bindings for existing commands (Cmd+D select none, Shift+E export dialog, Space toggle zoom, Shift+M create version, Shift+X / Shift+U reject / unflag and advance), listed in Help > Keyboard Shortcuts. Shift+Y no longer also toggles the History panel; a UI command now shadows the engine command it wraps on the same key (W, Cmd+Shift+I). New tests reject conflicting bindings. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 12 ++++ crates/ui-egui/src/shortcuts.rs | 84 ++++++++++++++++++++++++++-- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1dd7101f..4c1b22a7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,7 +39,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("panel.info", "Info", Some("I"), "View"), ("panel.keywords", "Keywords", Some("K"), "View"), ("panel.versions", "Versions", Some("Shift+V"), "View"), - ("panel.activity", "History", Some("Shift+Y"), "View"), + ("panel.activity", "History", None, "View"), ("panel.close", "Close Panel", None, "View"), ("section.light", "Light", Some("Cmd+Alt+1"), "View"), ("section.color", "Color", Some("Cmd+Alt+2"), "View"), diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 0269aad2..9a086292 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -180,6 +180,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.end_row(); } } + for (sc, id, _) in crate::shortcuts::ALIASES { + let label = crate::menus::UI_COMMANDS + .iter() + .find(|c| c.0 == *id) + .map(|c| c.1) + .or_else(|| lightcraft_engine::find_command(id).map(|c| c.label)) + .unwrap_or(id); + ui.label(label); + ui.label(*sc); + ui.label(egui::RichText::new(*id).color(t.text_dim)); + ui.end_row(); + } }); }); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index af53a4bd..1690be12 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -5,6 +5,18 @@ use serde_json::json; use crate::LightcraftApp; +/// Secondary key bindings for commands that already exist: `(shortcut, command id, params JSON)`. +/// They complement the primary shortcut declared on the command (Lightroom-desktop keys that our +/// primary keymap assigns elsewhere, see docs/parity.md → Shortcuts). Shown in Help → Keyboard Shortcuts. +pub const ALIASES: &[(&str, &str, &str)] = &[ + ("Cmd+D", "library.selectNone", "{}"), + ("Shift+E", "dialog.export", "{}"), + ("Space", "view.zoomToggle", "{}"), + ("Shift+M", "version.create", "{}"), + ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), + ("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#), +]; + pub fn parse(s: &str) -> Option<(Modifiers, Key)> { let mut m = Modifiers::NONE; let mut key = None; @@ -50,21 +62,34 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { return; } let mut fire: Vec = Vec::new(); + let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); ctx.input(|i| { + let mut ui_keys = Vec::new(); for (id, _, sc, _) in crate::menus::UI_COMMANDS { - if let Some((m, k)) = sc.and_then(parse) - && matches(i, m, k) - { - fire.push(id.to_string()); + if let Some((m, k)) = sc.and_then(parse) { + ui_keys.push((m, k)); + if matches(i, m, k) { + fire.push(id.to_string()); + } } } for c in lightcraft_engine::command_specs() { + // A UI command bound to the same key wraps the engine command (e.g. `W` opens the + // White Balance Selector tool rather than sampling without a point): the UI one wins. if let Some((m, k)) = c.shortcut.and_then(parse) + && !ui_keys.contains(&(m, k)) && matches(i, m, k) { fire.push(c.id.to_string()); } } + for (sc, id, params) in ALIASES { + if let Some((m, k)) = parse(sc) + && matches(i, m, k) + { + aliased.push((id, serde_json::from_str(params).unwrap_or_default())); + } + } // rating 0-5, colour labels 6-9 (with Shift: and advance) for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { if matches(i, Modifiers::NONE, *key) { @@ -80,6 +105,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } } }); + for (id, params) in aliased { + let _ = app.run(id, params); + } for f in fire { if let Some(rest) = f.strip_prefix("rate:") { let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); @@ -133,4 +161,52 @@ mod tests { } } } + + #[test] + fn aliases_parse_and_target_existing_commands() { + let ui: Vec<&str> = crate::menus::UI_COMMANDS.iter().map(|c| c.0).collect(); + for (sc, id, params) in ALIASES { + assert!(parse(sc).is_some(), "{id}: {sc}"); + assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}"); + assert!(serde_json::from_str::(params).is_ok(), "alias {sc}: bad params"); + } + } + + /// Engine commands that intentionally share a key and are disambiguated by context in [`handle`]. + const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")]; + + /// No key fires two different actions (a UI command may shadow the engine command it wraps). + #[test] + fn no_conflicting_bindings() { + let mut ui: Vec<((Modifiers, Key), String)> = Vec::new(); + for (id, _, sc, _) in crate::menus::UI_COMMANDS { + if let Some(k) = sc.and_then(parse) { + ui.push((k, id.to_string())); + } + } + for (sc, id, _) in ALIASES { + ui.push((parse(sc).unwrap(), format!("alias {id}"))); + } + let mut engine: Vec<((Modifiers, Key), &str)> = Vec::new(); + for c in lightcraft_engine::command_specs() { + if let Some(k) = c.shortcut.and_then(parse) { + engine.push((k, c.id)); + } + } + for (sc, id, _) in ALIASES { + let k = parse(sc).unwrap(); + assert!(!engine.iter().any(|(k2, _)| *k2 == k), "alias {sc} ({id}) shadows an engine shortcut"); + } + for (i, (k, a)) in ui.iter().enumerate() { + for (k2, b) in &ui[i + 1..] { + assert!(k != k2, "{a} and {b} share a key"); + } + } + for (i, (k, a)) in engine.iter().enumerate() { + for (k2, b) in &engine[i + 1..] { + let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); + assert!(k != k2 || contextual, "{a} and {b} share a key"); + } + } + } } From 62a2a341e6928c02ea290876eb103bc5a115e421 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:51:10 -0400 Subject: [PATCH 2/3] docs: Lightroom parity tracker (docs/parity.md) One row per catalog feature, menu item, desktop shortcut and Classic extra with tier, status, evidence (command/control ids, files) and what is missing; per-section summary, ordered Top gaps, and a shortcut conflict review. Linked from README (Feature status) and AGENTS.md (pick work from Top gaps). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- README.md | 4 +- docs/parity.md | 814 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 820 insertions(+), 3 deletions(-) create mode 100644 docs/parity.md diff --git a/AGENTS.md b/AGENTS.md index 24e9f144..30e5faed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). 2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots). -3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. +3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary. +4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. `plan/` is gitignored (local-only). @@ -67,4 +68,4 @@ and often so a crash loses minutes, not hours. ## Map of the code `geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo -photos. `xtask` = tooling (`ci`, `layers`, `assets`, `wasm`, `corpus`, `stats`). +photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). diff --git a/README.md b/README.md index c9ca6adb..6ad1a430 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,9 @@ lightcraft --control 7980 ~/Pictures/trip ## Feature status -LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estimates. +LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estimates. The detailed, row-by-row +Lightroom parity tracker — every feature, menu item and shortcut with its status, evidence and the top gaps — is +[docs/parity.md](docs/parity.md). | Area | Status | |---|---| diff --git a/docs/parity.md b/docs/parity.md new file mode 100644 index 00000000..7b29a093 --- /dev/null +++ b/docs/parity.md @@ -0,0 +1,814 @@ +# Lightroom parity tracker + +A living checklist of every Lightroom feature LightCraft aims to match, what we have, and what is missing. Agents and +humans pick work from **[Top gaps](#top-gaps)**; whoever lands a feature updates its row in the same commit. + +- **Ids** come from the (local, gitignored) reference in `plan/lightroom/`: `LR-…` = feature catalog (03), + `MENU-…` = menu items (04), `KEY-…` / `KEYC-…` = keyboard shortcuts (06, desktop / Classic), `LRC-…` = Classic + extras (08). Ids are stable; never renumber. +- **Tier** as in the catalog: **P0** core, **P1** important parity, **P2** later / AI / niche, **OOS** out of scope. +- **Status**: ✅ done (usable end to end; polish may be noted) · 🟡 partial (note says what is missing, or the status + is unverified) · ⬜ missing · 🚫 out of scope / not applicable. +- **Evidence**: `cmd:` = command id (engine `command_specs()` or UI `UI_COMMANDS`), `ctl:` = develop control + id (`lightcraft-cli controls`), plus source files. A trailing `*` matches a prefix (`ctl:mixer.*`). +- Feature names and notes are our own words. No Adobe text, screenshots or assets belong here. + +`cargo xtask parity` checks that every `cmd:`/`ctl:` id and file path below still exists (part of `cargo xtask ci`) +and prints the summary; `cargo xtask parity --write` refreshes the summary table below. + +## Summary + + +| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | +|---|---:|---:|---:|---:|---:|---:| +| A. Import (IMP) | 2 | 4 | 7 | 1 | 1/4 (25%) | 1/4 (25%) | +| B. Library management (LIB) | 9 | 4 | 12 | 2 | 7/9 (78%) | 1/9 (11%) | +| C. Views & navigation (VIEW) | 7 | 2 | 8 | 0 | 7/9 (78%) | 0/4 (0%) | +| D. Search & filter (FILT) | 2 | 7 | 4 | 0 | 2/4 (50%) | 0/4 (0%) | +| E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | +| F. Edit panel — global adjustments (EDIT) | 35 | 3 | 10 | 1 | 27/28 (96%) | 8/14 (57%) | +| G. Profiles (PROF) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) | +| H. Crop & rotate (CROP) | 5 | 4 | 0 | 1 | 5/6 (83%) | 0/3 (0%) | +| I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | +| J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | +| K. Masking (MASK) | 6 | 10 | 7 | 0 | 4/8 (50%) | 2/5 (40%) | +| L. Presets (PRE) | 1 | 4 | 2 | 1 | 0/2 (0%) | 1/2 (50%) | +| M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) | +| N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) | +| O. Merge (MERGE) | 0 | 0 | 4 | 0 | — | — | +| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | +| Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — | +| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | +| S. Export (EXP) | 3 | 6 | 9 | 0 | 1/7 (14%) | 2/7 (29%) | +| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | +| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | +| V. Preferences (PREF) | 0 | 3 | 5 | 3 | 0/1 (0%) | 0/4 (0%) | +| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | +| X. Cross-cutting behaviours (BEHAV) | 10 | 2 | 5 | 1 | 7/8 (88%) | 3/5 (60%) | +| Y. Menus | 38 | 21 | 26 | 8 | 33/47 (70%) | 3/23 (13%) | +| Z. Keyboard shortcuts (desktop) | 44 | 14 | 22 | 1 | 39/52 (75%) | 5/23 (22%) | +| Lightroom Classic extras | 1 | 17 | 61 | 9 | — | 1/21 (5%) | +| **Total** | 178 | 110 | 212 | 37 | 143/198 (72%) | 32/144 (22%) | + + +## Top gaps + +Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on. + +1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible / + Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort. +2. **LR-FILT-RATING / LR-FILT-FLAG / LR-FILT-LABEL / LR-FILT-TYPE** (P0/P1) — the engine filters already exist + (`cmd:library.filter`); there is no filter-bar UI (stars ≥/=/≤, flags, labels, kind, edited). High value, low effort. +3. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous") + and offer built-in export presets (small JPEG, full JPEG, TIFF). Medium value, low effort. +4. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort. +5. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is + rendered but has no UI. Low effort. +6. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort. +7. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask + kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort. +8. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local + Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort. +9. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, + feather/opacity sliders, `[`/`]` size keys. Medium effort. +10. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. +11. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort. +12. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; + live preview while hovering presets/profiles/versions. Medium effort. +13. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). +14. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. +15. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), + XMP prefs, cache, GPU. Medium effort. +16. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination + album; browse folders before adding. Medium effort. +17. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in + `crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi. +18. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no + permissive pure-Rust decoder yet). High value, high effort (clean sources needed). +19. **MENU-BAR** (P1) — render the existing menu model (`ui.menu.list`) as a native macOS menu / in-window menu bar. + High value, medium effort. +20. **LR-LIB-SMARTALBUM** (P1) — smart albums from the existing filter + fielded search tokens. High value, medium effort. +21. **LR-LIB-STACK + LR-LIB-VIRTUALCOPY** (P1) — stacks (⌘G) and virtual copies. Medium effort. +22. **LR-VIEW-FULLSCREEN + LR-VIEW-COMPARE + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. +23. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +24. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render). +25. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) — + missing develop controls (calibration matters for XMP interop). +26. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. +27. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). + +## Shortcuts: conflicts and missing bindings + +Compared `plan/lightroom/06-shortcuts.md` (desktop part) with our bindings: command specs in `crates/engine/src/cmd/`, +`UI_COMMANDS` in `crates/ui-egui/src/menus.rs` and secondary bindings (`ALIASES`) in `crates/ui-egui/src/shortcuts.rs`. +`no_conflicting_bindings` (same file) fails when one key fires two actions. + +**Fixed (M16.1):** added Lightroom-desktop keys as secondary bindings for existing commands — ⌘D Select None, ⇧E +Export dialog, Space Toggle zoom, ⇧M Create Version, ⇧X Reject + advance, ⇧U Unflag + advance. `⇧Y` fired both +Before/After Split and the History panel; History lost the binding. `W` and `⇧⌘I` also fired the engine command +under the UI command that wraps it (a no-op error); the UI command now wins. + +**Deliberate differences (our key → Lightroom desktop key)** — each is a conflict with another binding we have: + +| Action | Ours | Lightroom desktop | Why | +|---|---|---|---| +| Pick flag | P | Z | Z = toggle zoom (Classic convention); P is the Classic pick key | +| Photos panel (left) | ⌘⇧L | P | P = pick | +| Expand/collapse edit sections | ⌘⌥1–5 | ⌘1–6 | ⌘0 = Zoom to Fit, ⌘1 = Zoom 100 %; Geometry lives in the Crop panel | +| Histogram | ⌘⇧H | ⌘0 | ⌘0 = Zoom to Fit | +| Square Grid | ⇧G | (none; ⇧G = Guided Upright) | Guided Upright is a button in the Crop panel | +| Export dialog | ⌘⇧E (+ ⇧E) | ⇧E | ⌘⇧E is "Edit in Photoshop" there; no external-editor command yet | +| Crop overlay cycle | ⇧O | O | O = mask overlay; ⇧O (mask colour / overlay orientation) unused otherwise | +| Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | +| Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | + +**Still missing / broken:** +- No command yet: F full-screen preview, ⇧⌘F window full screen, ⌘E export with previous, ⇧⌘V paste selected, + ⌘, settings, ⌘F focus search, ⌘G / ⇧⌘G stacks, ⌥Y top/bottom before/after, A visualize spots, `[` `]` / ⇧`[` ⇧`]` + brush size/feather, ⌃H / ⌃M merges, F1 help, ⇧6–9 label + advance. +- `⇧⌘N` is bound to `album.create`, which needs a name: the key does nothing (needs a "New Folder…" dialog command). +- `⌫` always deletes the photo, even while a mask or spot is selected (Lightroom deletes the selected pin). +- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — no pin toggle yet. +- ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). + + + +## A. Import (IMP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | 🟡 | `cmd:file.addPhotos`, `cmd:library.import` (`mode`, `album`) | file picker + import report; no review grid with per-photo checkboxes / destination album UI | +| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | +| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | +| LR-IMP-DEVICE | Import from camera/card | P1 | ⬜ | | no device detection | +| LR-IMP-AUTO | Watched-folder auto import | P2 | ⬜ | | | +| LR-IMP-PRESET | Preset on import | P2 | ⬜ | | | +| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ⬜ | `crates/engine/src/import.rs` | raws get embedded lens corrections on import; no user raw-default setting | +| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | +| LR-IMP-PROFILES | Import profiles & presets | P1 | 🟡 | `cmd:file.importPresets`, `cmd:preset.import` | presets (.lcpreset, XMP `crs:`) only; no profile import; Adobe profile formats are deliberately unsupported | +| LR-IMP-LOCAL | Work on files in place | P0 | 🟡 | `cmd:library.import` (mode add), `crates/engine/src/sidecar.rs`, `cmd:library.toggleAutoWriteXmp` | files referenced in place with XMP sidecars; no browse-a-folder-without-adding view | +| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG, CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode | +| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | ⬜ | | | +| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ⬜ | `crates/raw/src/dngwrite.rs` | DNG writer exists, not wired to import | + +## B. Library management (LIB) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | +| LR-LIB-RECENT-ADDED | Recently added | P1 | 🟡 | `cmd:library.source` (`recentlyAdded`) | not grouped by import session | +| LR-LIB-BYDATE | Browse by date | P1 | 🟡 | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` | years only; no month/day tree | +| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort | +| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) | +| LR-LIB-SMARTALBUM | Smart albums | P1 | ⬜ | `crates/catalog/src/query.rs` | building blocks exist (filter + fielded search tokens) | +| LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | +| LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | +| LR-LIB-TARGET | Target album | P2 | ⬜ | | | +| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `crates/ui-egui/src/shortcuts.rs` | | +| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | +| LR-LIB-LABEL | Colour labels | P1 | 🟡 | `cmd:photo.label`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | no label menu/buttons, no purple key, label names not editable | +| LR-LIB-KEYWORD | Keywords | P0 | 🟡 | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`) | no library-wide rename/delete, no keyword browser in the left panel | +| LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | +| LR-LIB-STACK | Stacks | P1 | ⬜ | | | +| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" | +| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | +| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ⬜ | | | +| LR-LIB-RENAME | Batch rename | P1 | ⬜ | | | +| LR-LIB-CAPTURETIME | Edit capture time | P1 | ⬜ | | `photo.setMeta` has no capture-time field | +| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ⬜ | | | +| LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | +| LR-LIB-CULL | Assisted culling | P2 | ⬜ | | | +| LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | +| LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ⬜ | | | +| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ⬜ | | | + +## C. Views & navigation (VIEW) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | 🟡 | `cmd:view.photoGrid`, `crates/ui-egui/src/panels/grid.rs` | no grouping by date | +| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | | +| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | | +| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | +| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ⬜ | | | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip` | | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar | +| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ⬜ | | | +| LR-VIEW-BEFOREAFTER | Before / after | P0 | 🟡 | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit` | no top/bottom layouts; "before" is always the original | +| LR-VIEW-COMPARE | Compare two photos | P1 | ⬜ | | | +| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ⬜ | | | +| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ⬜ | | | +| LR-VIEW-SLIDESHOW | Slideshow | P2 | ⬜ | | | +| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ⬜ | | | +| LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | +| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs` | no drag-to-adjust on the histogram | +| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | | + +## D. Search & filter (FILT) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `camera:…`); no suggestions dropdown | +| LR-FILT-SEARCH-AI | Natural-language search | P2 | ⬜ | | | +| LR-FILT-RATING | Rating filter | P0 | 🟡 | `cmd:library.filter` (`rating`, `ratingOp`) | engine + search token only; no filter-bar UI | +| LR-FILT-FLAG | Flag filter | P0 | 🟡 | `cmd:library.filter` (`flag`), `cmd:library.source` (`picks`) | no filter-bar UI (Picks source only) | +| LR-FILT-LABEL | Colour-label filter | P1 | 🟡 | `cmd:library.filter` (`label`) | no UI | +| LR-FILT-TYPE | Type / edited filter | P1 | 🟡 | `cmd:library.filter` (`kind`, `edited`) | no UI; no HDR/panorama/depth kinds | +| LR-FILT-KEYWORD | Keyword filter | P1 | 🟡 | `cmd:library.filter` (`keyword`) | no UI | +| LR-FILT-CAMERA | Camera / lens filter | P1 | 🟡 | `cmd:library.filter` (`camera`) | no UI; lens via text search only | +| LR-FILT-LOCATION | Location filter | P2 | 🟡 | `cmd:library.filter` (`text`) | free-text match on the location field only | +| LR-FILT-PEOPLE | People filter | P2 | ⬜ | | | +| LR-FILT-CULL | Culling-score filters | P2 | ⬜ | | | +| LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order | +| LR-FILT-SAVED | Filter presets [Classic] | P2 | ⬜ | | | + +## E. Metadata (META) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` | no flash, creator field, map snippet or people | +| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ⬜ | | | +| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`) | text field + GPS read; no map, no geocoding | +| LR-META-COPYPASTE | Copy / paste metadata | P2 | ⬜ | | | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | | +| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | 🟡 | `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; panel shows a subset; no metadata presets | + +## F. Edit panel — global adjustments (EDIT) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-EDIT-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | | +| LR-EDIT-BW | Black & white | P0 | ✅ | `cmd:develop.treatment` | | +| LR-EDIT-HDR-MODE | HDR editing | P2 | ⬜ | | | +| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | | +| LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | `ctl:light.contrast` | | +| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights` | | +| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | | +| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | +| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | +| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | | +| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `crates/ui-egui/src/panels/edit.rs` | no curve presets (linear / medium / strong) | +| LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | | +| LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ⬜ | | | +| LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ⬜ | | | +| LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | +| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick` | no magnified loupe while picking | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp` | relative scale for non-raw in the UI | +| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint` | | +| LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | +| LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | +| LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode | +| LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | 🟡 | `ctl:bw.*` | no auto mix | +| LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ⬜ | | | +| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*` | | +| LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | +| LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | +| LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | +| LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | 🟡 | `ctl:vignette.*`, `crates/pipeline/src/finish.rs` | style (highlight / colour priority / paint) rendered but not selectable in the UI | +| LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | +| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview | +| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | | +| LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | | +| LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | ⬜ | | settings field reserved, not rendered | +| LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | | | +| LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | +| LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | +| LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs` | uses corrections embedded in DNG/raw files; no lens-profile database | +| LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | +| LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | +| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | +| LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | +| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | +| LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ⬜ | | | +| LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | +| LR-EDIT-CALIB | Calibration [Classic] | P1 | ⬜ | | needed for XMP interop | +| LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled` | | +| LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" | +| LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | | + +## G. Profiles (PROF) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-PROF-DROPDOWN | Profile menu | P0 | 🟡 | `cmd:develop.profile`, `cmd:profiles.list`, `crates/ui-egui/src/panels/edit.rs` | no favourites / recent / browse entry | +| LR-PROF-BROWSER | Profile browser | P1 | ⬜ | | | +| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | +| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | +| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | +| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ⬜ | | | +| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | +| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | +| LR-PROF-AMOUNT | Profile amount | P1 | ✅ | `ctl:profile.amount` | | +| LR-PROF-IMPORT | Import profiles | P1 | ⬜ | | | + +## H. Crop & rotate (CROP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `crates/ui-egui/src/panels/detail.rs` | | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect` | no "As Shot"; custom ratio via command params only | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | 🟡 | `cmd:crop.straighten` | rotate by dragging outside the crop; no draw-along-horizon tool / double-click auto | +| LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:geometry.upright` (`level`) | via Upright Level; no Auto button in the crop panel | +| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle` | | +| LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | +| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | +| LR-CROP-OVERLAY | Crop overlays | P1 | 🟡 | `cmd:view.cropOverlay` | thirds, grid, golden ratio, diagonal; no triangle / spiral / aspect overlays or orientation cycle | +| LR-CROP-ZOOM | Zoom while cropping | P1 | 🟡 | | unverified | +| LR-CROP-GENEXPAND | Generative expand | OOS | 🚫 | | | + +## I. Remove / healing (REM) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-REM-CONTENTAWARE | Content-aware remove | P1 | 🟡 | `cmd:spot.add` (`mode: remove`), `crates/pipeline/src/spots.rs` | heal with automatic source; no patch synthesis (M8.4) | +| LR-REM-HEAL | Heal | P0 | ✅ | `cmd:spot.add` (`mode: heal`) | | +| LR-REM-CLONE | Clone | P0 | ✅ | `cmd:spot.add` (`mode: clone`) | | +| LR-REM-GEN | Generative remove | OOS | 🚫 | | | +| LR-REM-DETECT | Object detection for remove | P2 | ⬜ | | | +| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | 🟡 | `cmd:spot.add` (`size`, `feather`, `opacity`), `crates/ui-egui/src/panels/right.rs` | UI has size only; no `[` `]` keys | +| LR-REM-SPOT-EDIT | Edit existing spots | P0 | 🟡 | `cmd:spot.delete` | no pin selection, no moving target/source, ⌫ deletes the photo instead | +| LR-REM-VISUALIZE | Visualize spots | P1 | ⬜ | | | +| LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | | +| LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | | +| LR-REM-DUST | Dust detection | P2 | ⬜ | | | +| LR-REM-SYNC | Sync spots | P1 | ✅ | `cmd:develop.copy` (`groups`), `crates/develop/src/presets.rs` | | + +## J. Red eye (EYE) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-EYE-RED | Red-eye correction | P1 | ⬜ | `cmd:panel.redeye` | placeholder panel; settings field not rendered | +| LR-EYE-PET | Pet eye | P2 | ⬜ | | | + +## K. Masking (MASK) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-MASK-PANEL | Masks panel | P0 | 🟡 | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate`, `cmd:mask.visible`, `cmd:mask.delete` | no reorder, no one-step duplicate-and-invert | +| LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model | +| LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`) | heuristic | +| LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject heuristic | +| LR-MASK-OBJECTS | Object selection | P2 | ⬜ | | shape exists (falls back to the subject heuristic); no UI | +| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | | +| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | +| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` | size/feather/flow/density/erase; Auto Mask flag not applied; no A/B brushes, no pressure | +| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | +| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | | +| LR-MASK-COLORRANGE | Colour range | P1 | 🟡 | `cmd:mask.add` (`colorRange`) | renders; sampling UX unverified | +| LR-MASK-LUMRANGE | Luminance range | P1 | 🟡 | `cmd:mask.add` (`luminanceRange`) | no luminance-map display | +| LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data | +| LR-MASK-COMBINE | Add / subtract / intersect | P0 | ✅ | `cmd:mask.addComponent` | | +| LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | +| LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | +| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ⬜ | | | +| LR-MASK-SLIDERS | Local adjustment sliders | P0 | 🟡 | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | noise / moiré / defringe stored but not rendered; no local curve or effect presets | +| LR-MASK-OVERLAY | Mask overlay | P0 | 🟡 | `cmd:view.maskOverlay`, `crates/ui-egui/src/panels/detail.rs` | brush dabs + shape outlines only; no rendered alpha, colour or mode options | +| LR-MASK-PINS | Pins | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | handles drawn; no show/hide-pins option | +| LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | +| LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | +| LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | + +## L. Presets (PRE) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-PRE-PANEL | Presets panel | P0 | 🟡 | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; no hover preview | +| LR-PRE-CREATE | Create preset | P0 | 🟡 | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | dialog has no per-group checkboxes (fixed subset) | +| LR-PRE-MANAGE | Manage presets | P1 | 🟡 | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export` | no rename, update-with-current, move group, hide groups | +| LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | | +| LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | +| LR-PRE-PREMIUM | Built-in presets (own) | P2 | 🟡 | `crates/engine/src/presets.rs` | 18 own-authored presets | +| LR-PRE-RECOMMENDED | Community recommendations | OOS | 🚫 | | | +| LR-PRE-ONIMPORT | Apply during import | P2 | ⬜ | | | + +## M. Versions & history (VER) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-VER-CREATE | Create version | P1 | ✅ | `cmd:version.create` | | +| LR-VER-PANEL | Versions panel | P1 | 🟡 | `cmd:panel.versions`, `cmd:version.restore`, `cmd:version.delete` | no rename, update, hover preview, named/auto tabs | +| LR-VER-AUTO | Automatic versions | P2 | ⬜ | | `auto` flag reserved in the model | +| LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore` | | +| LR-VER-UNDO | Undo / redo | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | | + +## N. Copy / paste / sync (SYNC) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-SYNC-COPY | Copy edit settings | P0 | ✅ | `cmd:develop.copy` | | +| LR-SYNC-CHOOSE | Choose settings to copy | P0 | ✅ | `cmd:dialog.copySettings` | groups are coarser than per-slider | +| LR-SYNC-PASTE | Paste to selection | P0 | ✅ | `cmd:develop.paste` | no separate "paste selected" (choose at copy time instead) | +| LR-SYNC-SYNCBTN | Sync active → selected | P1 | ✅ | `cmd:develop.sync` | | +| LR-SYNC-PREVIOUS | Paste from previous / auto sync [Classic] | P2 | ⬜ | | | + +## O. Merge (MERGE) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-MERGE-HDR | HDR merge | P2 | ⬜ | | | +| LR-MERGE-PANO | Panorama | P2 | ⬜ | | | +| LR-MERGE-HDRPANO | HDR panorama | P2 | ⬜ | | | +| LR-MERGE-HEADLESS | Merge with last settings | P2 | ⬜ | | | + +## P. Enhance (ENH) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-ENH-DIALOG | Enhance dialog | P2 | ⬜ | | | +| LR-ENH-INPLACE | In-place enhance | P2 | ⬜ | | | + +## Q. HDR (HDR) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-HDR-EDIT | HDR editing | P2 | ⬜ | | | +| LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | ⬜ | | | +| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ⬜ | | | +| LR-HDR-LIMIT | HDR headroom limit | P2 | ⬜ | | | +| LR-HDR-EXPORT | HDR export | P2 | ⬜ | | | + +## R. Video (VID) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-VID-PLAY | Video playback | P1 | ⬜ | `crates/catalog/src/model.rs` | videos can be catalogued (kind, duration); no player | +| LR-VID-TRIM | Trim | P1 | ⬜ | | | +| LR-VID-EDIT | Edits on video | P2 | ⬜ | | | +| LR-VID-COVER | Cover frame | P2 | ⬜ | | | +| LR-VID-EXPORT | Video export | P2 | ⬜ | | | +| LR-VID-PHOTO2VIDEO | Photo to video (generative) | OOS | 🚫 | | | + +## S. Export (EXP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-EXP-DIALOG | Export dialog | P0 | 🟡 | `cmd:dialog.export`, `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export works; no built-in or saved export presets | +| LR-EXP-TYPE | File types | P0 | 🟡 | `crates/engine/src/export.rs` (`ExportFormat`) | JPEG, PNG, TIFF, WebP, AVIF; no DNG, JXL or original (+XMP) | +| LR-EXP-DIM | Output size | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (long edge / full size) | no short edge / width / height / megapixels, ppi, don't-enlarge | +| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | +| LR-EXP-BITDEPTH | Bit depth | P1 | ⬜ | | 8-bit only | +| LR-EXP-COMPRESSION | TIFF compression | P1 | ⬜ | | always Deflate | +| LR-EXP-COLORSPACE | Output colour space | P0 | ⬜ | `crates/engine/src/export.rs` | sRGB only | +| LR-EXP-HDR | HDR output | P2 | ⬜ | | | +| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | +| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | +| LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `crates/engine/src/export.rs` (`Watermark`) | text only; no graphic watermark | +| LR-EXP-NAMING | File naming | P1 | 🟡 | `cmd:app.export` (`naming`: `{name}`, `{seq}`) | no date tokens, custom start number | +| LR-EXP-LOCATION | Destination folder | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (folder field) | no folder picker, subfolder or name-conflict policy | +| LR-EXP-PREVIOUS | Export with previous settings | P0 | ⬜ | | | +| LR-EXP-DNGOPT | DNG options | P2 | ⬜ | | | +| LR-EXP-ORIGINAL | Original + XMP | P1 | ⬜ | | | +| LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | | +| LR-EXP-PSD | Round trip to an external editor | P2 | ⬜ | | | + +## T. Share (SHARE) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-SHARE-LINK | Web share link | OOS | 🚫 | | | +| LR-SHARE-INVITE | Invite collaborators | OOS | 🚫 | | | +| LR-SHARE-WEBGALLERY | Web galleries | OOS | 🚫 | | | +| LR-SHARE-COMMUNITY | Community edits | OOS | 🚫 | | | + +## U. Map & location (MAP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-MAP-INFO | Location in the info panel | P1 | 🟡 | `cmd:photo.setMeta` (`location`), `crates/ui-egui/src/panels/right.rs` | shown as text; not editable in the panel; no map | +| LR-MAP-MODULE | Map module [Classic] | P2 | ⬜ | | | + +## V. Preferences (PREF) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-PREF-GENERAL | General settings | P0 | ⬜ | `cmd:library.xmpPreferences` | no settings dialog; only XMP preferences (by command) | +| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | 🟡 | `cmd:library.clearPreviews`, `cmd:library.compact` | bounded thumbnail cache; no UI for size/location | +| LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | +| LR-PREF-INTERFACE | Interface options | P1 | ⬜ | | | +| LR-PREF-PERFORMANCE | GPU / performance | P1 | 🟡 | `cmd:app.gpu` | by command only | +| LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | +| LR-PREF-WATERMARK | Watermark settings | P1 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` | per export in the dialog; not saved as a preference | +| LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra | +| LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | | +| LR-PREF-NOTIFICATIONS | Notifications | OOS | 🚫 | | | +| LR-PREF-DEVICE | Device settings | P2 | ⬜ | | | + +## W. Cloud & AI infrastructure (CLOUD / AI) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-CLOUD-SYNC | Cloud sync | OOS | 🚫 | | | +| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | 🟡 | `crates/engine/src/media.rs` | preview-size proxies drive the loupe; editing needs the original | +| LR-AI-UPDATE-INDICATOR | AI-settings update indicator | P2 | ⬜ | | | +| LR-AI-CREDITS | Generative credits | OOS | 🚫 | | | + +## X. Cross-cutting behaviours (BEHAV) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | `crates/catalog/src/journal.rs`, `crates/engine/src/library.rs` | | +| LR-BEHAV-UNDO | Global undo | P0 | ✅ | `cmd:edit.undo`, `crates/engine/src/tests.rs` (`rating_flag_undo_redo`) | covers ratings, albums, deletes, edits | +| LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll` | | +| LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | +| LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ⬜ | | presets, profiles, versions | +| LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | 🟡 | `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; no progress popover for import/export | +| LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | +| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback | +| LR-BEHAV-DRAGDROP | Drag and drop | P1 | 🟡 | `crates/ui-egui/src/lib.rs` | files → app only; no photos → albums | +| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | +| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | +| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | +| LR-BEHAV-ACCESS | Accessibility | P2 | ⬜ | | unverified (screen-reader labels not audited) | +| LR-BEHAV-LOCALIZE | Localisation | P2 | ⬜ | | | +| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | +| LR-BEHAV-WHATSNEW | What's new | P2 | ⬜ | | | +| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | + +## Y. Menus + +From `04-menu-tree.md`. ✅ = a command exists and is reachable from the UI (button, panel or shortcut). There is no +visible menu bar yet: the menu model is only exposed through the control channel (`ui.menu.list`). + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| MENU-BAR | Menu bar rendering | P1 | 🟡 | `crates/ui-egui/src/menus.rs` (`menu_entries`) | model only; no native macOS menu or in-window menu bar | +| MENU-APP-ABOUT | About | P2 | ✅ | `cmd:app.about` | | +| MENU-APP-SETTINGS | Settings… | P0 | ⬜ | | see LR-PREF-GENERAL | +| MENU-APP-UPDATES | Check for updates | P2 | ⬜ | | | +| MENU-APP-SYNC | Sync status / pause | OOS | 🚫 | | | +| MENU-APP-SIGNOUT | Sign out | OOS | 🚫 | | | +| MENU-APP-HIDE | Hide / hide others / show all | P1 | 🟡 | | platform window defaults (unverified) | +| MENU-APP-QUIT | Quit | P0 | 🟡 | | platform window defaults (unverified) | +| MENU-FILE-ADDPHOTOS | Add Photos… | P0 | ✅ | `cmd:file.addPhotos` | | +| MENU-FILE-ADDFOLDER | Add Folder… | P0 | 🟡 | `cmd:library.import` (folders, recursive) | no folder picker entry | +| MENU-FILE-MIGRATE | Migrate photos | OOS | 🚫 | | | +| MENU-FILE-NEWALBUM | New Album… | P0 | ✅ | `cmd:dialog.newAlbum` | | +| MENU-FILE-NEWFOLDER | New Folder… | P0 | 🟡 | `crates/ui-egui/src/panels/left.rs` ("Create Folder…") | no menu command; ⇧⌘N is bound to `album.create` which needs a name | +| MENU-FILE-NEWSMART | New Smart Album… | P1 | ⬜ | | | +| MENU-FILE-IMPORTPROFILES | Import Profiles & Presets… | P1 | 🟡 | `cmd:file.importPresets` | presets only | +| MENU-FILE-EXPORT | Export… | P0 | ✅ | `cmd:dialog.export` | | +| MENU-FILE-EXPORTPREV | Export with Previous | P0 | ⬜ | | | +| MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ⬜ | | | +| MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | | | +| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ⬜ | | | +| MENU-FILE-SHOWFINDER | Show in Finder | P0 | ⬜ | | | +| MENU-FILE-OFFLINE | Store album locally | P2 | 🚫 | | not applicable: local-first | +| MENU-FILE-CLOSE | Close Window | P1 | 🟡 | | platform window defaults (unverified) | +| MENU-EDIT-UNDO | Undo | P0 | ✅ | `cmd:edit.undo` | label does not name the step | +| MENU-EDIT-REDO | Redo | P0 | ✅ | `cmd:edit.redo` | | +| MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | | +| MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | | +| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ⬜ | | | +| MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | | +| MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | | +| MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ⬜ | | | +| MENU-EDIT-FIND | Find… | P0 | 🟡 | `crates/ui-egui/src/panels/topbar.rs` | search field; no command to focus it | +| MENU-VIEW-PHOTOGRID | Photo Grid | P0 | ✅ | `cmd:view.photoGrid` | | +| MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | `cmd:view.squareGrid` | | +| MENU-VIEW-DETAIL | Detail | P0 | ✅ | `cmd:view.detail` | | +| MENU-VIEW-EDIT | Edit | P0 | ✅ | `cmd:panel.edit` | | +| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ⬜ | | | +| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ⬜ | | | +| MENU-VIEW-PHOTOSPANEL | Show/Hide photos panel | P0 | ✅ | `cmd:view.leftPanel` | | +| MENU-VIEW-FILMSTRIP | Show/Hide filmstrip | P0 | ✅ | `cmd:view.filmstrip` | | +| MENU-VIEW-INFO | Show/Hide info | P0 | ✅ | `cmd:panel.info` | | +| MENU-VIEW-KEYWORDS | Show/Hide keywords | P0 | ✅ | `cmd:panel.keywords` | | +| MENU-VIEW-ACTIVITY | Show/Hide activity (comments) | OOS | 🚫 | | our History panel is `panel.activity` | +| MENU-VIEW-VERSIONS | Show/Hide versions | P1 | ✅ | `cmd:panel.versions` | | +| MENU-VIEW-HISTOGRAM | Show/Hide histogram | P0 | ✅ | `cmd:view.histogram` | | +| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ⬜ | | | +| MENU-VIEW-SHOWORIGINAL | Show Original | P0 | ✅ | `cmd:view.showOriginal` | | +| MENU-VIEW-BEFOREAFTER | Before/After submenu | P0 | 🟡 | `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit` | left/right and split left/right only | +| MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | | +| MENU-VIEW-CLIPPING | Show Clipping | P0 | ✅ | `cmd:view.clipping` | | +| MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | 🟡 | `cmd:view.maskOverlay` | no colour cycle | +| MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | 🟡 | `cmd:library.import` | folder import is always recursive | +| MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort` | no colour-label key | +| MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ⬜ | | | +| MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | 🟡 | `crates/ui-egui/src/panels/left.rs` | counts always shown; no toggle | +| MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | | +| MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | | +| MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | +| MENU-PHOTO-RATE | Rate submenu | P0 | ✅ | `cmd:photo.rate` | | +| MENU-PHOTO-FLAG | Flag submenu | P0 | ✅ | `cmd:photo.flag` | | +| MENU-PHOTO-LABEL | Colour label submenu | P1 | 🟡 | `cmd:photo.label` | keys only; no menu, no label editing | +| MENU-PHOTO-ROTATE | Rotate left / right | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | +| MENU-PHOTO-FLIP | Flip horizontal / vertical | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | +| MENU-PHOTO-CREATEVERSION | Create Version… | P1 | ✅ | `cmd:version.create` | no name prompt | +| MENU-PHOTO-STACK | Stack submenu | P1 | ⬜ | | | +| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ⬜ | | | +| MENU-PHOTO-ENHANCE | Enhance… | P2 | ⬜ | | | +| MENU-PHOTO-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto` | | +| MENU-PHOTO-BW | Convert to B&W | P0 | ✅ | `cmd:develop.treatment` | | +| MENU-PHOTO-RESET | Reset edits / crop | P0 | ✅ | `cmd:develop.reset`, `cmd:crop.reset` | | +| MENU-PHOTO-UPDATEAI | Update AI settings | P2 | ⬜ | | | +| MENU-PHOTO-RENAME | Rename N photos… | P1 | ⬜ | | | +| MENU-PHOTO-CAPTURETIME | Edit capture time… | P1 | ⬜ | | | +| MENU-PHOTO-COVER | Set as album cover | P2 | ✅ | `cmd:album.setCover` | | +| MENU-PHOTO-DELETE | Delete N photos… | P0 | ✅ | `cmd:photo.delete` | no confirmation; static label | +| MENU-PHOTO-MOVETOCLOUD | Move/copy to cloud | OOS | 🚫 | | | +| MENU-WINDOW-MINIMIZE | Minimize / zoom | P1 | 🟡 | | platform window defaults (unverified) | +| MENU-WINDOW-PANELS | Panel switches | P0 | ✅ | `cmd:panel.edit`, `cmd:panel.crop`, `cmd:panel.remove`, `cmd:panel.masking`, `cmd:panel.presets`, `cmd:panel.versions` | | +| MENU-WINDOW-BRINGFRONT | Bring all to front | P2 | 🟡 | | platform window defaults (unverified) | +| MENU-HELP-HELP | Help | P2 | ⬜ | | | +| MENU-HELP-TUTORIALS | Tutorials | OOS | 🚫 | | | +| MENU-HELP-WHATSNEW | What's new | P2 | ⬜ | | | +| MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | | +| MENU-HELP-FEEDBACK | Send feedback | P2 | ⬜ | | | +| MENU-HELP-SYSINFO | System info | P2 | ⬜ | `cmd:library.info` | library info only | +| MENU-CTX-GRID | Photo context menu | P0 | 🟡 | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, add to album, copy/paste, reset, rotate, delete; no label, remove from album, export, reveal, version, cover | +| MENU-CTX-DETAIL | Loupe context menu | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | same as grid; no zoom submenu | +| MENU-CTX-ALBUM | Album / folder row menu | P0 | 🟡 | `crates/ui-egui/src/panels/left.rs` | add selected, rename, delete; no move-to, export album | +| MENU-CTX-MASK | Mask / component menu | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` | add/subtract component; rename/duplicate by command only | +| MENU-CTX-PRESET | Preset menu | P1 | 🟡 | `crates/ui-egui/src/panels/presets.rs` | favourite, delete, export group; no rename/update/move | +| MENU-CTX-PROFILE | Profile favourites | P2 | ⬜ | | | +| MENU-CTX-VERSION | Version menu | P1 | ⬜ | | delete button only | +| MENU-CTX-KEYWORD | Keyword chip menu | P1 | 🟡 | `crates/ui-egui/src/panels/right.rs` | click removes; no rename/delete keyword | + +## Z. Keyboard shortcuts (desktop) + +From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained in +[Shortcuts: conflicts and missing bindings](#shortcuts-conflicts-and-missing-bindings). + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| KEY-CROP | Crop & rotate — C | P0 | ✅ | `cmd:panel.crop` | | +| KEY-DETAIL | Detail — D | P0 | ✅ | `cmd:view.detail` | | +| KEY-EDIT | Edit — E | P0 | ✅ | `cmd:panel.edit` | | +| KEY-FULLSCREEN | Full-screen preview — F | P1 | ⬜ | | no command | +| KEY-GRID | Grid — G | P0 | ✅ | `cmd:view.photoGrid` | | +| KEY-INFO | Info — I | P0 | ✅ | `cmd:panel.info` | | +| KEY-KEYWORDS | Keywords — K | P0 | ✅ | `cmd:panel.keywords` | | +| KEY-CLIPBOARD | Copy / paste edit settings — ⌘C / ⌘V | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | ⌘X has nothing to cut outside text fields | +| KEY-UNDOREDO | Undo / redo — ⌘Z / ⇧⌘Z | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | | +| KEY-MINIMIZE | Minimize — ⌘M | P1 | 🟡 | | platform default (unverified) | +| KEY-AUTO | Auto — ⇧A | P0 | ✅ | `cmd:develop.auto` | | +| KEY-PHOTOSHOP | External editor — ⇧⌘E | P2 | ⬜ | | key used by our export dialog | +| KEY-ROTATE | Rotate — ⌘[ / ⌘] | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | +| KEY-ZOOM | Zoom in / out — ⌘= / ⌘− | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut` | | +| KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | | +| KEY-SELECTNONE | Select none — ⌘D | P0 | ✅ | `cmd:library.selectNone` | secondary binding (primary ⌘⇧A) | +| KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ⬜ | | no command | +| KEY-PREFS | Settings — ⌘, | P0 | ⬜ | | no command | +| KEY-SEARCH | Search — ⌘F | P0 | ⬜ | | no focus-search command | +| KEY-VISUALIZESPOTS | Visualize spots — A | P1 | ⬜ | | | +| KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | 🟡 | `cmd:view.maskOverlay`, `cmd:view.cropOverlay` | O toggles the mask overlay; crop overlays cycle on ⇧O | +| KEY-PHOTOSPANEL | Photos panel — P | P0 | 🟡 | `cmd:view.leftPanel` | bound to ⌘⇧L; P = pick | +| KEY-LINEAR | Linear gradient — L | P0 | ✅ | `cmd:tool.linear` | | +| KEY-RADIAL | Radial gradient — R | P0 | ✅ | `cmd:tool.radial` | | +| KEY-CLIPPING | Clipping — J | P0 | ✅ | `cmd:view.clipping` | | +| KEY-WB | White-balance selector — W | P0 | ✅ | `cmd:tool.wbPicker` | | +| KEY-FILMSTRIP | Filmstrip — / | P0 | ✅ | `cmd:view.filmstrip` | | +| KEY-SHOWORIGINAL | Show original — \ | P0 | ✅ | `cmd:view.showOriginal` | | +| KEY-TOGGLEZOOM | Toggle zoom — Space | P0 | ✅ | `cmd:view.zoomToggle` | Space is a secondary binding (primary Z) | +| KEY-MASKCOLOR | Cycle mask colour — ⇧O | P1 | ⬜ | | ⇧O cycles crop overlays | +| KEY-EXPORTPREV | Export with previous — ⌘E | P0 | ⬜ | | no command | +| KEY-EXPORTDIALOG | Export dialog — ⇧E | P0 | ✅ | `cmd:dialog.export` | secondary binding (primary ⌘⇧E) | +| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ⬜ | | | +| KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ⬜ | | | +| KEY-GUIDEDUPRIGHT | Guided Upright — ⇧G | P1 | 🟡 | `cmd:geometry.upright` | button in the crop panel; ⇧G = Square Grid | +| KEY-HIDE | Hide / hide others — ⌘H / ⌥⌘H | P1 | 🟡 | | platform default (unverified) | +| KEY-QUIT | Quit — ⌘Q | P0 | 🟡 | | platform default (unverified) | +| KEY-CREATEVERSION | Create version — ⇧M | P1 | ✅ | `cmd:version.create` | secondary binding (primary ⌘⇧S) | +| KEY-CLOSEWINDOW | Close window — ⌘W | P1 | 🟡 | | platform default (unverified) | +| KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete` | | +| KEY-ADDPHOTOS | Add photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | +| KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | | +| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | 🟡 | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | bound to ⌘⌥1–5 | +| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | | +| KEY-HISTOGRAM | Histogram — ⌘0 | P0 | 🟡 | `cmd:view.histogram` | bound to ⌘⇧H; ⌘0 = zoom to fit | +| KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ⬜ | | brush size is UI state, no command | +| KEY-BRUSHFEATHER | Brush feather — ⇧`[` / ⇧`]` | P0 | ⬜ | | | +| KEY-BRUSH | Brush — B | P0 | ✅ | `cmd:tool.brush` | | +| KEY-HEAL | Remove / heal — H | P0 | ✅ | `cmd:panel.remove` | | +| KEY-MERGE | HDR / panorama merges — ⌃H ⇧⌃H ⌃M ⇧⌃M | P2 | ⬜ | | | +| KEY-PICK | Pick — Z | P0 | 🟡 | `cmd:photo.pick` | bound to P; Z toggles zoom | +| KEY-UNFLAG | Unflag — U | P0 | ✅ | `cmd:photo.unflag` | | +| KEY-REJECT | Reject — X | P0 | ✅ | `cmd:photo.reject` | swaps crop aspect while cropping | +| KEY-RATING | Ratings — 0…5 | P0 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` | | +| KEY-LABELS | Labels — 6…9 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.label` | | +| KEY-MASKING | Masking — M | P0 | ✅ | `cmd:panel.masking` | | +| KEY-ERASE | Erase while held — ⌥ | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` | Add/Erase toggle buttons; hold-to-erase unverified | +| KEY-RATEADVANCE | Rate and advance — ⇧0…5 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` (`advance`) | | +| KEY-FLAGADVANCE | Flag and advance — ⇧Z / ⇧X / ⇧U | P1 | 🟡 | `cmd:photo.flag` (`advance`) | ⇧X and ⇧U bound; pick-and-advance has no key (⇧P = presets) | +| KEY-NEXTPREV | Next / previous — → / ← | P0 | ✅ | `cmd:library.next`, `cmd:library.previous` | | +| KEY-BA-CYCLE | Before/after — Y | P0 | ✅ | `cmd:view.beforeAfter` | toggles side by side (no cycling) | +| KEY-BA-TOPBOTTOM | Before/after top/bottom — ⌥Y | P1 | ⬜ | | | +| KEY-BA-SPLIT | Split before/after — ⇧Y | P0 | ✅ | `cmd:view.beforeAfterSplit` | | +| KEY-CHOOSECOPY | Choose settings to copy — ⇧⌘C | P0 | ✅ | `cmd:dialog.copySettings` | | +| KEY-RESETALL | Reset all — ⇧⌘R | P0 | ✅ | `cmd:develop.reset` | | +| KEY-GENAI | Generative toggles / variations — ⌥⇧G, ⌥←/→ | OOS | 🚫 | | | +| KEY-DETECT | Detect objects — ⌥⇧O | P2 | ⬜ | | | +| KEY-CROP-CONSTRAIN | Lock crop aspect — A | P1 | ⬜ | | | +| KEY-CROP-SWAP | Swap crop orientation — X | P0 | ✅ | `cmd:crop.rotateAspect` | | +| KEY-CROP-OVERLAYORIENT | Crop overlay orientation — ⇧O | P1 | ⬜ | | | +| KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | `cmd:crop.reset` | | +| KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ⬜ | | | +| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | +| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | 🟡 | `cmd:develop.adjust` | command exists; keyboard nudging unverified | +| KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | +| KEY-HELP | Help — F1 | P2 | ⬜ | | | +| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | | +| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back` | | +| KEY-COMMIT | Commit tool — Return | P1 | 🟡 | | edits apply live; no explicit commit step | +| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | ⬜ | | ⌫ deletes the photo | +| KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | | H = Remove panel | + +## Lightroom Classic extras + +From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-shortcuts.md` (Classic keys, grouped). + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LRC-LIB-IMPORT | Full import dialog | P1 | 🟡 | `cmd:library.import` (`mode: copy`) | copy/add + duplicate skip; no file renaming, apply-during-import, destination organising, import presets | +| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ⬜ | | | +| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | +| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.detail` | no compare, survey, people; no grid cell styles beyond filenames | +| LRC-LIB-COMPARE | Compare view | P1 | ⬜ | | | +| LRC-LIB-SURVEY | Survey view | P2 | ⬜ | | | +| LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | | +| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | 🟡 | `cmd:library.source` | all, recently added, picks, recently deleted; no missing/problem sets | +| LRC-LIB-FOLDERS | Disk folder tree | P1 | ⬜ | | | +| LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ⬜ | `crates/catalog/src/query.rs` | fielded search tokens are a start | +| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | +| LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:library.filter` | engine filter + text tokens; no filter bar, presets, lock | +| LRC-LIB-STACKS | Stacks (full) | P1 | ⬜ | | | +| LRC-LIB-VC | Virtual copies | P1 | ⬜ | | | +| LRC-LIB-LABELS | Colour-label sets | P1 | ⬜ | | | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ⬜ | | flat keywords only (LR-LIB-KEYWORD) | +| LRC-LIB-METADATA | Metadata panel & presets | P1 | 🟡 | `cmd:photo.setMeta`, `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile` | no metadata presets, capture-time edit, copyright status | +| LRC-LIB-QUICKDEV | Quick develop | P2 | ⬜ | | | +| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | | +| LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | +| LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ⬜ | | | +| LRC-LIB-MISSING | Missing files & relink | P1 | ⬜ | | | +| LRC-LIB-CONVERT | Convert to DNG | P2 | ⬜ | `crates/raw/src/dngwrite.rs` | writer exists, not exposed | +| LRC-LIB-PREVIEWS | Build / discard previews | P1 | 🟡 | `cmd:library.clearPreviews`, `crates/preview/src/lib.rs` | disk thumbnail cache; no build-1:1 / smart previews | +| LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ⬜ | | | +| LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | +| LRC-DEV-HISTORY | Full history panel | P1 | 🟡 | `cmd:history.list`, `cmd:history.restore` | no clear, no snapshot-from-step | +| LRC-DEV-SOFTPROOF | Soft proofing | P2 | ⬜ | | | +| LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | 🟡 | `cmd:develop.sync` | no auto sync or paste-from-previous | +| LRC-DEV-MATCHEXP | Match total exposures | P2 | ⬜ | | | +| LRC-DEV-CALIB | Calibration panel | P1 | ⬜ | | | +| LRC-DEV-TAT | Targeted adjustment tools | P1 | ⬜ | | | +| LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ⬜ | | | +| LRC-DEV-VIEWOPTIONS | Develop view options | P2 | ⬜ | | | +| LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | +| LRC-MAP-VIEW | Map view | P2 | ⬜ | | | +| LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | | +| LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | | +| LRC-MAP-TRACKLOG | GPS track logs | P2 | ⬜ | | | +| LRC-MAP-FILTER | Location filter bar | P2 | ⬜ | | | +| LRC-MAP-REVGEO | Reverse geocoding | OOS | 🚫 | | | +| LRC-BOOK-SETTINGS | Book settings | P2 | ⬜ | | | +| LRC-BOOK-AUTOLAYOUT | Book auto layout | P2 | ⬜ | | | +| LRC-BOOK-PAGE | Book pages & templates | P2 | ⬜ | | | +| LRC-BOOK-GUIDES | Book guides | P2 | ⬜ | | | +| LRC-BOOK-CELL | Book cell padding | P2 | ⬜ | | | +| LRC-BOOK-TEXT | Book photo/page text | P2 | ⬜ | | | +| LRC-BOOK-TYPE | Book typography | P2 | ⬜ | | | +| LRC-BOOK-BG | Book backgrounds | P2 | ⬜ | | | +| LRC-BOOK-VIEWS | Book views | P2 | ⬜ | | | +| LRC-BOOK-EXPORT | Book export (PDF/JPEG) | P2 | ⬜ | | | +| LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | | +| LRC-SS-OPTIONS | Slideshow options | P2 | ⬜ | | | +| LRC-SS-LAYOUT | Slideshow layout | P2 | ⬜ | | | +| LRC-SS-OVERLAYS | Slideshow overlays | P2 | ⬜ | | | +| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ⬜ | | | +| LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | | +| LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | +| LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | +| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ⬜ | | | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | +| LRC-PRINT-LAYOUT | Print layout | P2 | ⬜ | | | +| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | | +| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | | | +| LRC-PRINT-PAGE | Print page options | P2 | ⬜ | | | +| LRC-PRINT-JOB | Print job & colour management | P2 | ⬜ | | | +| LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | | +| LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | +| LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | +| LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | | +| LRC-WEB-APPEARANCE | Web gallery appearance | OOS | 🚫 | | | +| LRC-WEB-IMAGEINFO | Web gallery image info | OOS | 🚫 | | | +| LRC-WEB-OUTPUT | Web gallery output | OOS | 🚫 | | | +| LRC-WEB-UPLOAD | Web gallery upload | OOS | 🚫 | | | +| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | ⬜ | | | +| KEYC-MODULES | Classic module switching (⌘⌥1–7) | P2 | 🚫 | | no modules in LightCraft | +| KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | `cmd:view.photoGrid`, `cmd:view.zoom100` | G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes | +| KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | +| KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | 🟡 | `cmd:library.import`, `cmd:photo.delete`, `cmd:dialog.export` | import, delete, export work; no virtual copy, reveal, rename | +| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle` | Z toggles zoom; no compare, stacks, thumbnail-size keys | +| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag` | most work; no ⇧P / ⇧6–9 advance, rating `[` `]`, flag cycle, filter-bar keys | +| KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | +| KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | +| KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | +| KEYC-MODULE-OUTPUT | Book / slideshow / print / map / web keys | P2 | ⬜ | | modules not implemented | +| KEYC-HELP | Classic help keys (⌘/, F1) | P2 | 🟡 | `cmd:app.shortcuts` | ⌘/ only | From 90f5ebac57c8d7ad1a9c4219caae14effb0a9e36 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:53:30 -0400 Subject: [PATCH 3/3] =?UTF-8?q?xtask:=20parity=20=E2=80=94=20docs/parity.m?= =?UTF-8?q?d=20reference=20check=20(in=20ci)=20and=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo xtask parity verifies every cmd:/ctl: id and repository path cited in docs/parity.md exists (engine commands and controls via lightcraft-cli, UI commands from UI_COMMANDS), that row ids are unique and statuses valid, and prints the per-section summary; --write refreshes the summary table. ci runs the reference check after the tests. Parser, checker and summary are unit-tested. Co-Authored-By: Claude Opus 5.5 --- xtask/src/main.rs | 8 +- xtask/src/parity.rs | 411 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 xtask/src/parity.rs diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c44460cf..fb63d8d7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,7 @@ mod assets; mod layers; +mod parity; mod stats; mod web; @@ -17,11 +18,14 @@ usage: cargo xtask commands: assets every image/icon/font/media file is attributed in assets/ATTRIBUTION.md; no Adobe assets layers enforce the crate dependency layering (plan/architecture.md §3) + parity [--write] + check docs/parity.md (every cmd:/ctl: id and path it cites exists) and print the + Lightroom parity summary; --write refreshes the summary table in the document wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app) web [--serve [port]] [--dev] build the browser app (apps/lightcraft-web) into /web/; --serve serves it on http://127.0.0.1: (default 8080) - ci fmt --check, clippy -D warnings, test, layers, assets, wasm (stops at first failure) + ci fmt --check, clippy -D warnings, test, parity refs, layers, assets, wasm (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) @@ -33,6 +37,7 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), Some("assets") => assets::run(&root()), + Some("parity") => parity::run(&root(), rest.contains(&"--write")), Some("wasm") => cmd_wasm(), Some("web") => web::run(&rest), Some("ci") => cmd_ci(), @@ -172,6 +177,7 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo test --workspace") }), ), + ("parity", Box::new(|| parity::run(&root(), false))), ("layers", Box::new(cmd_layers)), ("assets", Box::new(|| assets::run(&root()))), ("wasm", Box::new(cmd_wasm)), diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs new file mode 100644 index 00000000..651f3372 --- /dev/null +++ b/xtask/src/parity.rs @@ -0,0 +1,411 @@ +//! `cargo xtask parity`: the Lightroom parity tracker (`docs/parity.md`). +//! +//! - **Reference check** (in `ci`): every `` `cmd:` `` must be a registered command (engine command specs, as +//! listed by `lightcraft-cli commands --json`, or a UI command from `UI_COMMANDS`), every `` `ctl:` `` a develop +//! control (`lightcraft-cli controls --json`), and every repository path in backticks must exist (with `:line` +//! inside the file). A trailing `*` matches an id prefix. Row ids must be unique and statuses valid. +//! - **Summary**: per section done / partial / missing / out of scope and the share of P0 and P1 rows done; +//! `--write` refreshes the table between the `parity:summary` markers in the document. + +use std::collections::{BTreeSet, HashSet}; +use std::path::Path; + +pub const DOC: &str = "docs/parity.md"; +const BEGIN: &str = ""; +const END: &str = ""; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Done, + Partial, + Missing, + OutOfScope, +} + +impl Status { + fn parse(cell: &str) -> Option { + let c = cell.trim_start(); + [("✅", Status::Done), ("🟡", Status::Partial), ("⬜", Status::Missing), ("🚫", Status::OutOfScope)] + .into_iter() + .find(|(e, _)| c.starts_with(e)) + .map(|(_, s)| s) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Ref { + Cmd(String), + Ctl(String), + /// Repository-relative path, optional 1-based line. + Path(String, Option), +} + +#[derive(Clone, Debug)] +pub struct Row { + pub id: String, + pub tier: String, + pub status: Status, + pub section: String, +} + +#[derive(Debug, Default)] +pub struct Doc { + pub rows: Vec, + /// (line, reference) for every reference anywhere in the document. + pub refs: Vec<(usize, Ref)>, + pub errors: Vec, +} + +fn is_row_id(s: &str) -> bool { + let Some((prefix, rest)) = s.split_once('-') else { return false }; + matches!(prefix, "LR" | "LRC" | "MENU" | "KEY" | "KEYC") + && !rest.is_empty() + && rest.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-') +} + +/// Directories whose paths are checked (others, e.g. the local-only `plan/`, are ignored). +const CHECKED_DIRS: &[&str] = &["crates/", "apps/", "docs/", "xtask/", "assets/"]; + +/// A concrete id (not a placeholder such as `cmd:` in prose). +fn concrete(id: &str) -> bool { + !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '*')) +} + +fn classify(span: &str) -> Option { + if let Some(id) = span.strip_prefix("cmd:").filter(|id| concrete(id)) { + return Some(Ref::Cmd(id.to_string())); + } + if let Some(id) = span.strip_prefix("ctl:").filter(|id| concrete(id)) { + return Some(Ref::Ctl(id.to_string())); + } + if CHECKED_DIRS.iter().any(|d| span.starts_with(d)) && !span.contains(' ') { + return Some(match span.rsplit_once(':') { + Some((p, l)) if l.parse::().is_ok() => Ref::Path(p.to_string(), l.parse().ok()), + _ => Ref::Path(span.to_string(), None), + }); + } + None +} + +/// Inline-code spans of one line (single backticks). +fn code_spans(line: &str) -> Vec<&str> { + line.split('`').skip(1).step_by(2).collect() +} + +pub fn parse(md: &str) -> Doc { + let mut doc = Doc::default(); + let mut section = String::new(); + let mut seen = HashSet::new(); + let mut in_comment = false; + for (i, line) in md.lines().enumerate() { + let n = i + 1; + let t = line.trim(); + if t.starts_with("") { + in_comment = true; + } + if in_comment { + in_comment = !t.contains("-->"); + continue; + } + if let Some(h) = t.strip_prefix("## ") { + section = h.trim().to_string(); + continue; + } + for span in code_spans(line) { + if let Some(r) = classify(span) { + doc.refs.push((n, r)); + } + } + if !t.starts_with('|') { + continue; + } + let cells: Vec<&str> = t.trim_matches('|').split('|').map(str::trim).collect(); + let Some(id) = cells.first().copied().filter(|c| is_row_id(c)) else { continue }; + if cells.len() < 6 { + doc.errors.push(format!("{DOC}:{n}: {id}: expected 6 columns (Id | Feature | Tier | Status | Evidence | Notes)")); + continue; + } + let Some(status) = Status::parse(cells[3]) else { + doc.errors.push(format!("{DOC}:{n}: {id}: status `{}` is not one of ✅ 🟡 ⬜ 🚫", cells[3])); + continue; + }; + if !seen.insert(id.to_string()) { + doc.errors.push(format!("{DOC}:{n}: duplicate id {id}")); + } + doc.rows.push(Row { id: id.to_string(), tier: cells[2].to_string(), status, section: section.clone() }); + } + doc +} + +/// Ids in `UI_COMMANDS` (`("view.detail", "Detail", Some("D"), "View"),` lines). +pub fn ui_command_ids(menus_rs: &str) -> Vec { + let Some(start) = menus_rs.find("pub const UI_COMMANDS") else { return Vec::new() }; + let body = &menus_rs[start..]; + let body = &body[..body.find("];").unwrap_or(body.len())]; + body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())).collect() +} + +fn known(set: &BTreeSet, id: &str) -> bool { + match id.strip_suffix('*') { + Some(prefix) => set.iter().any(|s| s.starts_with(prefix)), + None => set.contains(id), + } +} + +/// Reference problems (empty = OK). `file_lines` returns a repository file's line count (None = missing). +pub fn check(doc: &Doc, commands: &BTreeSet, controls: &BTreeSet, file_lines: impl Fn(&str) -> Option) -> Vec { + let mut out = doc.errors.clone(); + for (n, r) in &doc.refs { + match r { + Ref::Cmd(id) if !known(commands, id) => out.push(format!("{DOC}:{n}: unknown command `cmd:{id}`")), + Ref::Ctl(id) if !known(controls, id) => out.push(format!("{DOC}:{n}: unknown develop control `ctl:{id}`")), + Ref::Path(p, line) => match (file_lines(p), line) { + (None, _) => out.push(format!("{DOC}:{n}: missing path `{p}`")), + (Some(len), Some(l)) if *l == 0 || *l > len => out.push(format!("{DOC}:{n}: `{p}:{l}` is past the end ({len} lines)")), + _ => {} + }, + _ => {} + } + } + out +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Counts { + pub done: usize, + pub partial: usize, + pub missing: usize, + pub oos: usize, + pub p0: (usize, usize), + pub p1: (usize, usize), +} + +impl Counts { + fn add(&mut self, r: &Row) { + match r.status { + Status::Done => self.done += 1, + Status::Partial => self.partial += 1, + Status::Missing => self.missing += 1, + Status::OutOfScope => self.oos += 1, + } + let done = usize::from(r.status == Status::Done); + match r.tier.as_str() { + "P0" => self.p0 = (self.p0.0 + done, self.p0.1 + 1), + "P1" => self.p1 = (self.p1.0 + done, self.p1.1 + 1), + _ => {} + } + } +} + +/// Per-section counts in document order, plus the total. +pub fn summarize(doc: &Doc) -> (Vec<(String, Counts)>, Counts) { + let mut sections: Vec<(String, Counts)> = Vec::new(); + let mut total = Counts::default(); + for r in &doc.rows { + if sections.last().is_none_or(|(s, _)| *s != r.section) { + sections.push((r.section.clone(), Counts::default())); + } + sections.last_mut().expect("pushed").1.add(r); + total.add(r); + } + (sections, total) +} + +fn pct((done, n): (usize, usize)) -> String { + match (done * 100 + n / 2).checked_div(n) { + Some(p) => format!("{done}/{n} ({p}%)"), + None => "—".into(), + } +} + +pub fn summary_table(doc: &Doc) -> String { + let (sections, total) = summarize(doc); + let mut s = String::from("| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |\n|---|---:|---:|---:|---:|---:|---:|\n"); + let row = + |name: &str, c: &Counts| format!("| {name} | {} | {} | {} | {} | {} | {} |\n", c.done, c.partial, c.missing, c.oos, pct(c.p0), pct(c.p1)); + for (name, c) in §ions { + s += &row(name, c); + } + s += &row("**Total**", &total); + s +} + +/// Replace the summary between the markers. +pub fn with_summary(md: &str, table: &str) -> Option { + let b = md.find(BEGIN)? + BEGIN.len(); + let e = md[b..].find(END)? + b; + Some(format!("{}\n{}{}", &md[..b], table, &md[e..])) +} + +fn registry(what: &str) -> Result, String> { + let out = crate::cargo() + .args(["run", "-q", "-p", "lightcraft-cli", "--", what, "--json"]) + .output() + .map_err(|e| format!("lightcraft-cli {what}: {e}"))?; + if !out.status.success() { + return Err(format!("lightcraft-cli {what} --json failed:\n{}", String::from_utf8_lossy(&out.stderr))); + } + let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("lightcraft-cli {what}: bad JSON: {e}"))?; + Ok(v.as_array().into_iter().flatten().filter_map(|c| c["id"].as_str().map(str::to_string)).collect()) +} + +pub fn run(root: &Path, write: bool) -> Result<(), String> { + let path = root.join(DOC); + let md = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let doc = parse(&md); + let menus = std::fs::read_to_string(root.join("crates/ui-egui/src/menus.rs")).map_err(|e| format!("menus.rs: {e}"))?; + let mut commands = registry("commands")?; + commands.extend(ui_command_ids(&menus)); + let controls = registry("controls")?; + let problems = check(&doc, &commands, &controls, |p| { + let full = root.join(p); + if full.is_dir() { Some(0) } else { std::fs::read_to_string(full).ok().map(|s| s.lines().count()) } + }); + + let table = summary_table(&doc); + println!("Lightroom parity ({DOC}): {} rows, {} references\n", doc.rows.len(), doc.refs.len()); + print!("{table}"); + if write { + let new = with_summary(&md, &table).ok_or_else(|| format!("{DOC}: summary markers `{BEGIN}` … `{END}` not found"))?; + if new != md { + std::fs::write(&path, new).map_err(|e| format!("{}: {e}", path.display()))?; + println!("\nupdated the summary in {DOC}"); + } + } else if with_summary(&md, &table).is_some_and(|new| new != md) { + println!("\nnote: the summary in {DOC} is stale — run `cargo xtask parity --write`"); + } + // Ids from the local Lightroom reference that have no row yet (plan/ is local-only, so only a warning). + let catalog = root.join("plan/lightroom/03-feature-catalog.md"); + if let Ok(cat) = std::fs::read_to_string(&catalog) { + let have: HashSet<&str> = doc.rows.iter().map(|r| r.id.as_str()).collect(); + let missing: Vec<&str> = cat + .lines() + .filter_map(|l| l.trim().strip_prefix("| ")?.split(['|', ' ']).next()) + .filter(|id| id.starts_with("LR-") && !have.contains(id)) + .collect(); + if !missing.is_empty() { + println!("\nwarning: catalog ids without a row: {}", missing.join(", ")); + } + } + if problems.is_empty() { + println!("\nOK: every command, control and path referenced in {DOC} exists."); + Ok(()) + } else { + println!(); + for p in &problems { + println!(" - {p}"); + } + Err(format!("{} stale reference(s) in {DOC}", problems.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +# Tracker + +Prose mentioning `cmd:edit.undo`, placeholders `cmd:` / `ctl:` and `plan/lightroom/03.md` (not checked) and `rating:3`. + + +old + + + + +## A. Import (IMP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-IMP-ONE | One | P0 | ✅ | `cmd:library.import`, `crates/engine/src/import.rs:12` | | +| LR-IMP-TWO | Two | P1 | 🟡 | `ctl:light.*` | partial | +| LR-IMP-THREE | Three | OOS | 🚫 | | | + +| Action | Ours | Theirs | +|---|---|---| +| Pick flag | P | Z | + +## B. Library (LIB) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-LIB-ONE | One | P0 | ⬜ | `cmd:album.nope` | | +| LR-LIB-ONE | Dup | P1 | done | | | +| KEY-SHORT | too | few | +"; + + fn set(ids: &[&str]) -> BTreeSet { + ids.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn parses_rows_sections_and_statuses() { + let d = parse(SAMPLE); + let ids: Vec<&str> = d.rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, ["LR-IMP-ONE", "LR-IMP-TWO", "LR-IMP-THREE", "LR-LIB-ONE"]); + assert_eq!(d.rows[0].section, "A. Import (IMP)"); + assert_eq!(d.rows[3].section, "B. Library (LIB)"); + assert_eq!(d.rows[1].status, Status::Partial); + assert_eq!(d.rows[2].status, Status::OutOfScope); + assert_eq!(d.rows[1].tier, "P1"); + // the bad-status duplicate and the short row are errors; the comment row is ignored + assert_eq!(d.errors.len(), 2, "{:?}", d.errors); + assert!(d.errors.iter().any(|e| e.contains("status `done`"))); + assert!(d.errors.iter().any(|e| e.contains("KEY-SHORT"))); + } + + #[test] + fn collects_references_everywhere() { + let d = parse(SAMPLE); + let refs: Vec<&Ref> = d.refs.iter().map(|(_, r)| r).collect(); + assert!(refs.contains(&&Ref::Cmd("edit.undo".into()))); + assert!(refs.contains(&&Ref::Path("crates/engine/src/import.rs".into(), Some(12)))); + assert!(refs.contains(&&Ref::Ctl("light.*".into()))); + assert!(!refs.iter().any(|r| matches!(r, Ref::Cmd(id) if id == "nope")), "comment rows are skipped"); + assert!(!refs.iter().any(|r| matches!(r, Ref::Path(p, _) if p.starts_with("plan/")))); + assert_eq!(refs.len(), 5); + } + + #[test] + fn check_reports_unknown_ids_and_paths() { + let d = parse(SAMPLE); + let cmds = set(&["edit.undo", "library.import"]); + let ctls = set(&["light.exposure"]); + let problems = check(&d, &cmds, &ctls, |p| (p == "crates/engine/src/import.rs").then_some(100)); + let unknown: Vec<&String> = problems.iter().filter(|p| p.contains("unknown")).collect(); + assert_eq!(unknown.len(), 1, "{problems:?}"); + assert!(unknown[0].contains("cmd:album.nope")); + // a line past the end of the file is stale too + let short = check(&d, &cmds, &ctls, |_| Some(5)); + assert!(short.iter().any(|p| p.contains("import.rs:12"))); + // missing files and unknown controls + let none = check(&d, &cmds, &set(&[]), |_| None); + assert!(none.iter().any(|p| p.contains("missing path"))); + assert!(none.iter().any(|p| p.contains("ctl:light.*"))); + } + + #[test] + fn summary_counts_and_rewrites_between_markers() { + let d = parse(SAMPLE); + let (sections, total) = summarize(&d); + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].1, Counts { done: 1, partial: 1, missing: 0, oos: 1, p0: (1, 1), p1: (0, 1) }); + assert_eq!(total.p0, (1, 2)); + let table = summary_table(&d); + assert!(table.contains("| A. Import (IMP) | 1 | 1 | 0 | 1 | 1/1 (100%) | 0/1 (0%) |"), "{table}"); + assert!(table.contains("| **Total** | 1 | 1 | 1 | 1 | 1/2 (50%) | 0/1 (0%) |"), "{table}"); + let new = with_summary(SAMPLE, &table).unwrap(); + assert!(!new.contains("\nold\n")); + assert!(new.contains(&format!("{BEGIN}\n{table}{END}"))); + assert_eq!(with_summary(&new, &table).unwrap(), new, "idempotent"); + } + + #[test] + fn reads_ui_command_ids() { + let src = "pub const UI_COMMANDS: &[UiCommand] = &[\n (\"view.detail\", \"Detail\", Some(\"D\"), \"View\"),\n (\"app.about\", \"About\", None, \"\"),\n];\nfn x() { (\"not.this\", 1); }"; + assert_eq!(ui_command_ids(src), ["view.detail", "app.about"]); + } +}