diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 386e16f4e..c7e3c05ae 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -385,6 +385,56 @@ mod tests { h.settle(SETTLE); } + /// Presets column: resting on a preset previews it in the loupe without a history entry; + /// thumbnails are variant renders; Create Preset includes only the checked groups. + #[test] + fn preset_hover_previews_and_create_preset_groups() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.settle(SETTLE); + let id = h.app.session.active().unwrap(); + let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); + let before = photo(&h); + h.request("ui.hoverWidget", json!({"id": "preset:lc.bw-high-contrast"}), t); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); + assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); + let after = photo(&h); + assert_eq!(after.develop, before.develop); + assert_eq!(after.history.len(), before.history.len(), "no history entry while hovering"); + assert!(h.app.session.undo.is_empty()); + // thumbnails: variant textures for the visible presets + h.app.ui.preset_thumbs = true; + h.request("ui.move", json!({"x": 5, "y": 500}), t); + h.settle(SETTLE); + h.step(); + assert!(h.app.hover_preview.is_none()); + assert!(h.app.renderer.variant_textures() >= 5, "{}", h.app.renderer.variant_textures()); + // Create Preset with a group checklist: untick Light, keep Effects + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "effects.clarity", "value": 25}}), t); + h.app.ui.dialog = Some(crate::state::Dialog::create_preset()); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "presetInclude:light"}), t); + assert_eq!(r["ok"], true, "{r}"); + match &h.app.ui.dialog { + Some(crate::state::Dialog::CreatePreset { groups, .. }) => { + assert!(!groups.iter().any(|g| g == "light") && groups.iter().any(|g| g == "effects"), "{groups:?}"); + assert!(!groups.iter().any(|g| g == "crop" || g == "masks"), "crop and masks off by default"); + } + d => panic!("dialog: {d:?}"), + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let p = h.app.session.presets.iter().find(|p| !p.builtin).expect("created").clone(); + assert!(p.settings.get("light").is_none() && p.settings["effects"]["clarity"] == 25.0, "{}", p.settings); + h.settle(SETTLE); + } + /// The filter bar drives `library.filter` and saves the view as a smart album. #[test] fn filter_bar_filters_and_saves_a_smart_album() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 76d447c7a..c3c979cf6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -339,7 +339,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() }); + app.ui.dialog = Some(Dialog::create_preset()); Ok(Value::Null) } "dialog.copySettings" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 0ac333088..3f08b0bc0 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -77,26 +77,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } } - Dialog::CreatePreset { name, group } => { + Dialog::CreatePreset { name, group, groups } => { ui.add(egui::TextEdit::singleline(name).hint_text("Preset name").desired_width(f32::INFINITY)); ui.add(egui::TextEdit::singleline(group).hint_text("Group").desired_width(f32::INFINITY)); - ui.label(egui::RichText::new("Includes the current settings except crop, masks and remove.").color(t.text_dim)); - } - Dialog::CopySettings { groups } => { - ui.columns(2, |cols| { - for (i, g) in SettingsGroup::ALL.iter().enumerate() { - let key = serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); - let mut on = groups.contains(&key); - if cols[i % 2].checkbox(&mut on, g.label()).changed() { - if on { - groups.push(key); - } else { - groups.retain(|x| *x != key); - } - } - } - }); + ui.label(egui::RichText::new("Settings to include").color(t.text_dim)); + group_checklist(ui, "presetInclude", groups); } + Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), Dialog::Export { opts, long_edge, limit_kb, dir } => { use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; let n = app.session.selection.ids.len().max(1); @@ -282,9 +269,14 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.rename", json!({"id": id, "name": name})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})), - Dialog::CreatePreset { name, group } => { - app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group})) - } + Dialog::CreatePreset { name, group, groups } => app.run( + "preset.create", + json!({ + "name": if name.trim().is_empty() { "My Preset" } else { name }, + "group": if group.trim().is_empty() { "User Presets" } else { group }, + "groups": groups, + }), + ), Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), Dialog::Export { opts, long_edge, limit_kb, dir } => app.run( "app.export", @@ -301,6 +293,38 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result) { + let key_of = |g: &SettingsGroup| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + ui.columns(2, |cols| { + for (i, g) in SettingsGroup::ALL.iter().enumerate() { + let key = key_of(g); + let mut on = groups.contains(&key); + let r = cols[i % 2].checkbox(&mut on, g.label()); + crate::widgets::register(&r.ctx, format!("{tag}:{key}"), r.rect); + if r.changed() { + if on { + groups.push(key); + } else { + groups.retain(|x| *x != key); + } + } + } + }); + ui.horizontal(|ui| { + let all = ui.small_button("All"); + crate::widgets::register(ui.ctx(), format!("{tag}:all"), all.rect); + if all.clicked() { + *groups = SettingsGroup::ALL.iter().map(key_of).collect(); + } + let none = ui.small_button("None"); + crate::widgets::register(ui.ctx(), format!("{tag}:none"), none.rect); + if none.clicked() { + groups.clear(); + } + }); +} + /// Width of the label column in dialogs. const LABEL_W: f32 = 78.0; diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 7e1abd36a..035e5574a 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -1,16 +1,21 @@ -//! The Presets column (opens to the left of the Edit panel): grouped presets with hover preview, -//! amount slider, create/favourite, import/export of preset files. +//! The Presets column (opens to the left of the Edit panel): grouped presets with hover preview +//! (the loupe shows the photo with the preset while the pointer rests on it; nothing is +//! committed), optional live thumbnails, amount slider, create/favourite, import/export of preset +//! files. use std::collections::BTreeMap; -use egui::{Align2, Rect, Sense, pos2, vec2}; -use lightcraft_develop::{ControlSpec, Section, Track}; +use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; +use lightcraft_develop::{ControlSpec, Preset, Section, Track}; use serde_json::json; -use crate::LightcraftApp; use crate::icons::{Icon, paint}; use crate::theme::Tokens; use crate::widgets::{divider, icon_button, register, slider}; +use crate::{HoverPreview, LightcraftApp}; + +/// Long edge of preset thumbnails (px). +const THUMB_EDGE: usize = 128; pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -29,10 +34,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked() { - app.ui.dialog = Some(crate::state::Dialog::CreatePreset { name: String::new(), group: "User Presets".into() }); + app.ui.dialog = Some(crate::state::Dialog::create_preset()); } let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options"); egui::Popup::menu(&more).show(|ui| { + let r = ui.checkbox(&mut app.ui.preset_thumbs, "Show Thumbnails"); + register(ui.ctx(), "presetMenu:thumbnails", r.rect); + ui.separator(); if ui.button("Import Presets…").clicked() { let _ = app.run("file.importPresets", json!({})); } @@ -66,10 +74,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } divider(ui); } + let active = app.session.active(); + let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone()); + let thumbs = app.ui.preset_thumbs && active.is_some(); + let row_h = if thumbs { 54.0 } else { 26.0 }; egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| { - let mut groups: BTreeMap> = BTreeMap::new(); + let mut groups: BTreeMap> = BTreeMap::new(); for p in &app.session.presets { - groups.entry(p.group.clone()).or_default().push((p.id.clone(), p.name.clone(), p.favorite)); + groups.entry(p.group.clone()).or_default().push(p.clone()); } for (g, items) in groups { let open_id = egui::Id::new(("preset-group", &g)); @@ -94,16 +106,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !open { continue; } - for (pid, name, fav) in items { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + for pr in items { + let (pid, name, fav) = (pr.id.clone(), pr.name.clone(), pr.favorite); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); register(ui.ctx(), format!("preset:{pid}"), r); - let active = last.as_ref().is_some_and(|(l, _)| *l == pid); - if active { + let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid); + if is_last { ui.painter().rect_filled(r, 0.0, t.tool_active); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7)); } - ui.painter().text(pos2(r.left() + 40.0, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); + let look = if thumbs || resp.hovered() { current.as_ref().map(|d| pr.apply(d, 1.0)) } else { None }; + let mut text_x = r.left() + 40.0; + if thumbs && let (Some(id), Some(s)) = (active, look.as_ref()) { + let tr = Rect::from_min_size(pos2(r.left() + 36.0, r.top() + 5.0), vec2(66.0, row_h - 10.0)); + ui.painter().rect_filled(tr, 2.0, t.canvas); + if let Some(job) = app.session.variant_job(id, s, THUMB_EDGE) + && let Some(tex) = app.renderer.variant(job) + { + ui.painter().image(tex.tex.id(), tr, cover_uv(tr, tex.size), Color32::WHITE); + } + text_x = tr.right() + 10.0; + } + ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); if fav { paint( ui.painter(), @@ -112,6 +137,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { t.star, ); } + // resting on a preset previews it in the loupe (no history entry) + if resp.hovered() + && let Some(s) = look + { + app.hover_preview = Some(HoverPreview { label: format!("Preset: {name}"), settings: s }); + } if resp.clicked() { let _ = app.run("preset.apply", json!({"id": pid, "amount": 100})); ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0))); @@ -131,3 +162,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); }); } + +/// The part of a `size` image that covers `r` (centre crop), as texture coordinates. +pub fn cover_uv(r: Rect, size: [usize; 2]) -> Rect { + let a = size[0] as f32 / size[1].max(1) as f32; + let ra = r.width() / r.height().max(1.0); + if a > ra { + let f = ra / a; + Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0)) + } else { + let f = a / ra; + Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0)) + } +} diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index bdf1e851c..9e32c52ad 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -8,6 +8,7 @@ use lightcraft_develop::DevelopSettings; use lightcraft_engine::presets::{PROFILES, ProfileInfo, profile, profile_groups}; use serde_json::json; +use super::presets::cover_uv; use crate::icons::{Icon, paint}; use crate::state::RightPanel; use crate::theme::Tokens; @@ -92,19 +93,6 @@ fn with_profile(d: &DevelopSettings, p: &ProfileInfo) -> DevelopSettings { s } -/// The part of a `size` image that covers `r` (centre crop), as texture coordinates. -fn cover_uv(r: Rect, size: [usize; 2]) -> Rect { - let a = size[0] as f32 / size[1].max(1) as f32; - let ra = r.width() / r.height().max(1.0); - if a > ra { - let f = ra / a; - Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0)) - } else { - let f = a / ra; - Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0)) - } -} - #[allow(clippy::too_many_arguments)] fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: PhotoId, tag: &str, p: &'static ProfileInfo, cell: Rect, th: f32) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3c6d557fb..c968c9a4c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -85,6 +85,8 @@ pub struct UiState { pub right: RightPanel, /// Presets column open (opens to the left of the Edit panel). pub presets: bool, + /// Presets column: show a live thumbnail of the photo with each preset. + pub preset_thumbs: bool, pub filmstrip: bool, pub zoom: Zoom, /// Pan offset of the loupe when zoomed (image-normalized centre). @@ -142,6 +144,19 @@ pub struct UiState { pub dialog: Option, } +/// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything +/// but crop, masks, spots and red eye. +pub fn default_preset_groups() -> Vec { + lightcraft_develop::SettingsGroup::default_copy().iter().filter_map(|g| serde_json::to_value(g).ok()?.as_str().map(str::to_string)).collect() +} + +impl Dialog { + /// A fresh Create Preset dialog. + pub fn create_preset() -> Dialog { + Dialog::CreatePreset { name: String::new(), group: "User Presets".into(), groups: default_preset_groups() } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Dialog { @@ -161,9 +176,12 @@ pub enum Dialog { NewSmartAlbum { name: String, }, + /// `groups`: the settings groups the preset includes (`SettingsGroup` ids). CreatePreset { name: String, group: String, + #[serde(default = "default_preset_groups")] + groups: Vec, }, CopySettings { groups: Vec, @@ -190,6 +208,7 @@ impl Default for UiState { left_panel: false, right: RightPanel::Edit, presets: false, + preset_thumbs: false, filmstrip: true, zoom: Zoom::Fit, pan: (0.5, 0.5), diff --git a/docs/parity.md b/docs/parity.md index 013077b10..32ca31030 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -64,21 +64,19 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 4. **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. 5. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. -6. **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. -7. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. -8. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), +6. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. +7. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), XMP prefs, cache, GPU. Medium effort. -9. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination +8. **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. -10. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in +9. **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. -11. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no +10. **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). -12. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. -13. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. -14. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. -15. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). +11. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. +12. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +13. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. +14. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). ## Shortcuts: conflicts and missing bindings @@ -357,8 +355,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) | +| LR-PRE-CREATE | Create preset | P0 | ✅ | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) | | 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 | ⬜ | | | @@ -495,7 +493,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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-PREVIEW-HOVER | Hover previews | P1 | 🟡 | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/detail.rs` | presets, profile menu and profile browser preview in the loupe; versions do not | | 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 |