diff --git a/crates/catalog/src/folders.rs b/crates/catalog/src/folders.rs
--- a/crates/catalog/src/folders.rs
+++ b/crates/catalog/src/folders.rs
@@ -2,8 +2,9 @@
 //!
 //! A *folder* here is a folder holding at least one photo of the library (imported, not deleted,
 //! not merely browsed in Local; demo scenes have no folder). The tree lists those folders and the
-//! ones above them, never a folder that holds nothing of the library, with how many photos each
-//! holds — its subfolders included, like a year in By Date counts its months.
+//! ones above them, with how many photos each holds — its subfolders included, like a year in By
+//! Date counts its months. The folders made through LightCraft are listed too, empty or not, with
+//! the folders above them (see below); no other folder that holds nothing of the library is.
 //!
 //! Every folder is a row of its own, the ones above the imported photos included
 //! (`/Users` → `me` → `Pictures`), so a row is exactly the folder it names. A folder spelled two
@@ -24,10 +25,17 @@
 //! two rows. Each row is still consistent: choosing it shows exactly the photos it counts.
 //!
 //! A folder is otherwise only where photos are, but the library can also keep a
-//! [`FolderRecord`] about it (its colour label), stored under the folder's identity
-//! ([`crate::query::folder_key`]). A record follows its folder when the folder is renamed or moved
-//! here ([`Catalog::folder_records_follow`]); one about a folder that no longer holds a photo is
-//! kept (undo can bring the photos back) and shows again when the folder does.
+//! [`FolderRecord`] about it (its colour label, and whether it was made through LightCraft),
+//! stored under the folder's identity ([`crate::query::folder_key`]). A folder made through
+//! LightCraft has a row (count 0) from the start and for good: it is one row before and after it
+//! gains photos, and it stays when they leave. If it is renamed or deleted outside LightCraft its
+//! row stays, like any folder of the library whose files are missing: choosing it shows nothing.
+//! Undoing its creation removes it, and so does Remove from Library ([`Catalog::folders_unmade_ops`];
+//! undo brings it back). The row spells the folder as it was made, or as its move or rename put
+//! it (the record's key alone would be lower case on Windows). A record follows its folder
+//! when the folder is renamed or moved here ([`Catalog::folder_records_follow`]); one about a
+//! folder that no longer holds a photo is kept (undo can bring the photos back) and shows again
+//! when the folder does.
 //!
 //! This is the library's own view of its photos; browsing any folder on disk without importing
 //! is Local's job (see [`crate::local`]).
@@ -77,12 +85,18 @@ pub struct FolderRecord {
     /// A colour label, as a photo has one.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub label: Option<ColorLabel>,
+    /// Where the folder is, as it was spelled when it was made through LightCraft
+    /// (`folder.create`), if it was: it has a row in the tree before it holds a photo, and keeps
+    /// it after the photos are moved elsewhere. Kept as spelled so the row's path has the folder's
+    /// own letter case (the record's key is lower case on Windows).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub made: Option<String>,
 }
 
 impl FolderRecord {
     /// Whether the record says nothing (and so is not kept).
     pub fn is_empty(&self) -> bool {
-        self.label.is_none()
+        self.label.is_none() && self.made.is_none()
     }
 }
 
@@ -201,6 +215,46 @@ impl Catalog {
         Op::SetFolderRecord { folder: path.to_string(), record: Some(record) }
     }
 
+    /// The made folders (see [`FolderRecord::made`]) at or inside folder `path`: on the disk
+    /// `path` is on, or on any disk with `all_disks` (a folder above disks, `/Volumes`, holds
+    /// none of their made folders, as it holds none of their photos).
+    fn made_within<'a>(&'a self, path: &str, all_disks: bool) -> impl Iterator<Item = (&'a String, &'a FolderRecord)> {
+        let root = record_key(path);
+        let mount = place(path).map(|p| folder_key(&p.mount));
+        self.folder_records.iter().filter(move |(k, r)| {
+            let Some(spelled) = &r.made else { return false };
+            root.as_deref().is_some_and(|root| key_within(k, root)) && (all_disks || place(spelled).map(|p| folder_key(&p.mount)) == mount)
+        })
+    }
+
+    /// Whether folder `path` or a folder inside it was made through LightCraft: the tree lists
+    /// `path` as a row then, with or without photos.
+    pub fn holds_made_folder(&self, path: &str) -> bool {
+        self.made_within(path, false).next().is_some()
+    }
+
+    /// The ops that make the library forget that folder `path` and the folders inside it were
+    /// made through LightCraft (their rows then show only while they hold photos, like any other
+    /// folder); a label stays. `all_disks`: also those on other disks (a whole folder of disks
+    /// removed from the library). Empty when none was made.
+    pub fn folders_unmade_ops(&self, path: &str, all_disks: bool) -> Vec<Op> {
+        self.made_within(path, all_disks)
+            .map(|(k, r)| {
+                let record = FolderRecord { made: None, ..r.clone() };
+                Op::SetFolderRecord { folder: k.clone(), record: Some(record) }
+            })
+            .collect()
+    }
+
+    /// The op that records folder `path` as made through LightCraft, so the tree lists it before
+    /// it holds a photo, spelled as `path` is. Its inverse (undo) forgets it again. Applying it
+    /// checks the path.
+    pub fn folder_made_op(&self, path: &str) -> Op {
+        let mut record = self.folder_record(path).cloned().unwrap_or_default();
+        record.made = Some(path.to_string());
+        Op::SetFolderRecord { folder: path.to_string(), record: Some(record) }
+    }
+
     /// The ops that carry the records of folder `from` and the folders inside it over to `to`
     /// (the same place below it), for a folder renamed or moved on disk: commit them with the
     /// photos' relinks so undo puts both back. Records already at or below `to` are left from a
@@ -220,7 +274,9 @@ impl Catalog {
 
     fn folder_records_carried(&self, from: &str, to: &str, clear_destination: bool) -> Vec<Op> {
         let (Some(root), Some(dest)) = (record_key(from), record_key(to)) else { return Vec::new() };
-        if root == dest {
+        // (the same identity under another spelling, a rename of case only on Windows, still
+        // rewrites the spelling a made folder is listed under)
+        if root == dest && (from == to || !self.holds_made_folder(from)) {
             return Vec::new();
         }
         // a folder located below itself: what is already at or below `to` belongs to the folders
@@ -236,7 +292,15 @@ impl Catalog {
                 let rest = folder_rest(k, &root)?;
                 let base = dest.trim_end_matches('/');
                 let path = if rest.is_empty() { dest.clone() } else { format!("{base}/{}", rest.join("/")) };
-                Some((folder_key(&path), r.clone()))
+                let mut record = r.clone();
+                // the spelling a made folder is listed under follows too, as `to` spells its place
+                if let Some(spelled) = &r.made
+                    && let Some(rest) = folder_rest(spelled, from)
+                {
+                    let base = to.trim_end_matches(['/', '\\']);
+                    record.made = Some(if rest.is_empty() { base.to_string() } else { format!("{base}/{}", rest.join("/")) });
+                }
+                Some((folder_key(&path), record))
             })
             .collect();
         // what was known at the old place goes, and so does what is left at the new one (also
@@ -264,7 +328,10 @@ impl Catalog {
         }
         let mut levels: HashMap<String, Level> = HashMap::new();
         let mut roots: Vec<String> = Vec::new();
-        for (dir, n) in dirs {
+        // folders made through LightCraft count 0 until photos arrive; read after the photos' own
+        // spellings, so a folder that has photos is named as they spell it
+        let made = self.folder_records.values().filter_map(|r| r.made.as_deref());
+        for (dir, n) in dirs.into_iter().chain(made.map(|spelled| (spelled, 0))) {
             let Some(placed) = place(dir) else { continue };
             let vkey = folder_key(&placed.mount);
             if !levels.contains_key(&vkey) {
diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs
--- a/crates/catalog/src/journal.rs
+++ b/crates/catalog/src/journal.rs
@@ -52,6 +52,7 @@
 //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
 //! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` |
 //! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` |
+//! | 10 | folders made in LightCraft keep their row | `FolderRecord.made` |
 //!
 //! Rules:
 //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -76,7 +77,7 @@ pub const LOG: &str = "catalog.log";
 const FORMAT: &str = "lightcraft-catalog";
 /// The catalog format this build writes (and the newest it reads). See the module docs →
 /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
-pub const VERSION: u32 = 9;
+pub const VERSION: u32 = 10;
 
 /// When [`Journal::wants_snapshot`] says it's time to compact the log.
 #[derive(Clone, Copy, Debug)]
diff --git a/crates/catalog/src/tests_folder_records.rs b/crates/catalog/src/tests_folder_records.rs
--- a/crates/catalog/src/tests_folder_records.rs
+++ b/crates/catalog/src/tests_folder_records.rs
@@ -107,7 +107,7 @@ fn a_path_that_names_no_folder_is_refused() {
     let mut c = library();
     let before = c.to_snapshot();
     for bad in ["", "relative/trip", ".", "a/.."] {
-        let op = Op::SetFolderRecord { folder: bad.into(), record: Some(FolderRecord { label: Some(ColorLabel::Red) }) };
+        let op = Op::SetFolderRecord { folder: bad.into(), record: Some(FolderRecord { label: Some(ColorLabel::Red), made: None }) };
         assert!(c.apply(op).is_err(), "{bad:?} names no folder");
     }
     assert_eq!(c.to_snapshot(), before);
@@ -254,3 +254,133 @@ fn a_library_saved_before_folder_records_opens_without_any() {
     let reopened = Catalog::from_snapshot(&old).unwrap();
     assert_eq!(reopened.folder_record("/pics/trip"), None);
 }
+
+/// The rows of the tree as `(path, count, own)`, depth first.
+fn tree_rows(c: &Catalog) -> Vec<(String, usize, usize)> {
+    fn walk(n: &FolderNode, out: &mut Vec<(String, usize, usize)>) {
+        out.push((n.path.clone(), n.count, n.own));
+        n.children.iter().for_each(|c| walk(c, out));
+    }
+    let mut out = Vec::new();
+    c.folder_tree().iter().for_each(|n| walk(n, &mut out));
+    out
+}
+
+#[test]
+fn a_made_folder_is_a_row_with_no_photos_that_undo_takes_away() {
+    let mut c = library();
+    let before = c.to_snapshot();
+    let op = c.folder_made_op("/pics/new");
+    let undo = c.apply(op).unwrap();
+    assert!(c.holds_made_folder("/pics//new/"), "however it is spelled");
+    assert!(tree_rows(&c).contains(&("/pics/new".to_string(), 0, 0)));
+    c.apply(undo).unwrap();
+    assert_eq!(c.to_snapshot(), before, "undo leaves nothing behind");
+    assert!(!tree_rows(&c).iter().any(|r| r.0 == "/pics/new"));
+}
+
+#[test]
+fn a_made_folder_is_one_row_before_and_after_it_gains_photos() {
+    let mut c = library();
+    let op = c.folder_made_op("/pics/new");
+    c.apply(op).unwrap();
+    add(&mut c, "/pics//new/x.jpg");
+    let rows: Vec<_> = tree_rows(&c).into_iter().filter(|r| r.0.ends_with("/new")).collect();
+    assert_eq!(rows.len(), 1, "{rows:?}");
+    assert_eq!((rows[0].1, rows[0].2), (1, 1));
+}
+
+#[test]
+fn a_made_folder_in_a_folder_with_no_photos_of_its_own_nests_there_and_adds_nothing_to_the_counts() {
+    let mut c = library();
+    let op = c.folder_made_op("/pics/trip/day9");
+    c.apply(op).unwrap();
+    let rows = tree_rows(&c);
+    let at = |p: &str| rows.iter().position(|r| r.0 == p).unwrap_or_else(|| panic!("{p}: {rows:?}"));
+    assert!(at("/pics/trip") < at("/pics/trip/day9"), "below its parent");
+    assert_eq!(rows[at("/pics/trip")].1, 2, "the parent counts photos only");
+    assert_eq!(rows[at("/pics/trip/day9")].1, 0);
+}
+
+#[test]
+fn a_made_folder_on_a_disk_the_library_has_no_photo_on_is_listed_under_that_disk() {
+    let mut c = library();
+    let op = c.folder_made_op("/Volumes/spare/new");
+    c.apply(op).unwrap();
+    let rows = tree_rows(&c);
+    assert!(rows.contains(&("/Volumes/spare".to_string(), 0, 0)) && rows.contains(&("/Volumes/spare/new".to_string(), 0, 0)), "{rows:?}");
+}
+
+#[test]
+fn the_made_flag_follows_a_moved_folder_and_keeps_the_label_it_has() {
+    let mut c = library();
+    let op = c.folder_made_op("/pics/new");
+    c.apply(op).unwrap();
+    label(&mut c, "/pics/new", Some(ColorLabel::Red));
+    label(&mut c, "/pics/new", None);
+    assert!(c.holds_made_folder("/pics/new"), "taking the label off keeps the folder's record");
+    let ops = c.folder_records_follow("/pics/new", "/archive/new");
+    c.apply(Op::Batch { ops }).unwrap();
+    assert!(!c.holds_made_folder("/pics/new") && c.holds_made_folder("/archive/new"));
+    let spelled = c.folder_record("/archive/new").and_then(|r| r.made.clone());
+    assert_eq!(spelled.as_deref(), Some("/archive/new"), "the row is spelled where the folder now is");
+}
+
+#[test]
+fn a_made_folders_spelling_follows_a_rename_that_keeps_its_identity() {
+    // (on Windows `new` → `New` is one folder: keys are lower case; here a spelling that reads the same)
+    let mut c = library();
+    let op = c.folder_made_op("/pics/new");
+    c.apply(op).unwrap();
+    let ops = c.folder_records_follow("/pics/new", "/pics/./new");
+    assert!(!ops.is_empty());
+    c.apply(Op::Batch { ops }).unwrap();
+    assert_eq!(c.folder_record("/pics/new").and_then(|r| r.made.clone()).as_deref(), Some("/pics/./new"));
+    assert!(c.folder_records_follow("/pics/./new", "/pics/./new").is_empty(), "the same path again changes nothing");
+}
+
+#[test]
+fn a_folder_above_disks_holds_none_of_their_made_folders() {
+    let mut c = library();
+    let op = c.folder_made_op("/Volumes/spare/new");
+    c.apply(op).unwrap();
+    assert!(c.holds_made_folder("/Volumes/spare") && c.holds_made_folder("/Volumes/spare/new"));
+    assert!(!c.holds_made_folder("/Volumes"), "what covers other disks is not offered for choosing or scanning");
+    assert!(c.folders_unmade_ops("/Volumes", false).is_empty());
+    assert_eq!(c.folders_unmade_ops("/Volumes", true).len(), 1, "removing a whole folder of disks forgets theirs too");
+}
+
+#[test]
+fn a_made_folder_survives_closing_and_reopening_and_an_older_record_reads_as_not_made() {
+    let mut c = library();
+    let op = c.folder_made_op("/pics/new");
+    c.apply(op).unwrap();
+    let reopened = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
+    assert!(reopened.holds_made_folder("/pics/new"));
+    assert!(tree_rows(&reopened).contains(&("/pics/new".to_string(), 0, 0)));
+    // a record saved by a build that knew only the label has no `made`, and an unmade one is
+    // saved just so
+    let json = serde_json::to_string(&FolderRecord { label: Some(ColorLabel::Red), made: None }).unwrap();
+    assert!(!json.contains("made"), "{json}");
+    let old: FolderRecord = serde_json::from_str(&json).unwrap();
+    assert!(old.made.is_none() && !old.is_empty());
+}
+
+#[test]
+fn forgetting_made_folders_keeps_a_label_and_undo_restores_the_record() {
+    let mut c = library();
+    for p in ["/pics/new", "/pics/new/inner", "/pics/newer"] {
+        let op = c.folder_made_op(p);
+        c.apply(op).unwrap();
+    }
+    label(&mut c, "/pics/new", Some(ColorLabel::Blue));
+    let before = c.to_snapshot();
+    let ops = c.folders_unmade_ops("/pics/new", false);
+    let undo = c.apply(Op::Batch { ops }).unwrap();
+    assert!(!c.holds_made_folder("/pics/new") && !c.holds_made_folder("/pics/new/inner"));
+    assert!(c.holds_made_folder("/pics/newer"), "a neighbor whose name starts the same stays");
+    assert_eq!(c.folder_color_label("/pics/new"), Some(ColorLabel::Blue));
+    assert!(c.folders_unmade_ops("/pics/trip", false).is_empty());
+    c.apply(undo).unwrap();
+    assert_eq!(c.to_snapshot(), before);
+}
diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs
--- a/crates/catalog/src/tests_format_version.rs
+++ b/crates/catalog/src/tests_format_version.rs
@@ -97,6 +97,34 @@ fn v6_preview_only_photo_loads_unstamped_and_is_upgraded() {
     assert_eq!(snapshot_version(&m), u64::from(VERSION));
 }
 
+/// A v9 library with a folder label (a record without `made`) opens as it was and is rewritten in
+/// the current format; a folder made afterwards is saved with it. (An older build refuses the new
+/// format by the rule `newer_snapshot_is_refused_untouched` tests.)
+#[test]
+fn v9_library_with_folder_records_is_upgraded_and_the_new_format_is_refused_by_older_builds() {
+    let (mut base, _, _) = legacy_parts();
+    let op = base.folder_label_op("/p", Some(ColorLabel::Red));
+    base.apply(op).unwrap();
+    let m = MemStore::new();
+    m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":9,\"seq\":3,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
+    m.set(LOG, Vec::new());
+    let (mut j, mut c, r) = Journal::open(Box::new(m.clone())).unwrap();
+    assert_eq!(r.upgraded_from, Some(9));
+    assert_eq!(snapshot_version(&m), u64::from(VERSION));
+    assert_eq!(c.folder_color_label("/p"), Some(ColorLabel::Red));
+    assert!(!c.holds_made_folder("/p"), "no folder was made in a v9 library");
+    let made = c.folder_made_op("/p/new");
+    c.apply(made.clone()).unwrap();
+    j.append(&[made]).unwrap();
+    j.snapshot(&c).unwrap();
+    drop(j);
+    let (_, reopened, _) = Journal::open(Box::new(m.clone())).unwrap();
+    assert!(reopened.holds_made_folder("/p/new"));
+    // a v9 build refuses a snapshot above 9
+    let v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
+    assert!(v["version"].as_u64().unwrap() > 9);
+}
+
 #[test]
 fn versionless_log_only_library_is_upgraded() {
     let mut c = Catalog::new();
@@ -238,8 +266,8 @@ fn op_variants_are_versioned() {
             Op::SetHistoryCursor { .. } => 8,
         }
     }
-    /// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`.
-    const NEWEST_FIELDS: u32 = 9;
+    /// v10: `FolderRecord.made`.
+    const NEWEST_FIELDS: u32 = 10;
     let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None });
     assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version");
 }
diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs
--- a/crates/engine/src/cmd/browse.rs
+++ b/crates/engine/src/cmd/browse.rs
@@ -290,7 +290,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "New Folder on Disk",
             [],
             None,
-            "{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); one undo step (undo removes it again while it is empty) → {path}",
+            "{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); it has a row in library.folders at once, with count 0, and keeps it as photos come and go; one undo step (undo removes the folder and its row again while it is empty) → {path}",
             always,
             create_folder
         ),
@@ -391,7 +391,8 @@ fn folder_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
     str_param(p, key).map(|x| x.trim_end_matches(['/', '\\'])).filter(|x| !x.is_empty())
 }
 
-/// `folder.create`: a new folder on disk inside `parent`; undo removes it while it is empty.
+/// `folder.create`: a new folder on disk inside `parent`, listed in the library's folders at once
+/// (empty, see [`lightcraft_catalog::FolderRecord::made`]); undo removes it while it is empty.
 fn create_folder(s: &mut Session, p: &Value) -> Result<Value> {
     const C: &str = "folder.create";
     let parent = folder_param(p, "parent").ok_or_else(|| bad(C, "missing parent"))?;
@@ -402,13 +403,16 @@ fn create_folder(s: &mut Session, p: &Value) -> Result<Value> {
     if !Path::new(parent).is_dir() {
         return Err(bad(C, format!("{parent} is not a folder (offline, moved or deleted?)")));
     }
-    let path = Path::new(parent).join(name).to_string_lossy().to_string();
+    // (the library keeps absolute paths only: a relative `parent` is taken from the working folder)
+    let parent = std::path::absolute(parent).map_err(|e| bad(C, format!("{parent}: {e}")))?;
+    let path = parent.join(name).to_string_lossy().to_string();
     if Path::new(&path).exists() {
         return Err(bad(C, format!("{path} already exists: choose another name")));
     }
     folder_on_disk("", &path).map_err(|e| bad(C, e))?;
     let undo = crate::FolderMove { from: path.clone(), to: String::new() };
-    if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), Op::Batch { ops: vec![] }, undo) {
+    let record = s.catalog.folder_made_op(&path);
+    if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), record, undo) {
         let _ = remove_empty_folder(&path);
         return Err(e);
     }
diff --git a/crates/engine/src/cmd/library/selection.rs b/crates/engine/src/cmd/library/selection.rs
--- a/crates/engine/src/cmd/library/selection.rs
+++ b/crates/engine/src/cmd/library/selection.rs
@@ -54,12 +54,18 @@ pub(super) fn album_targets_or_err(s: &Session, command: &str, targets: Vec<Phot
 /// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep
 /// showing `i/n` (issue #1493).
 pub(super) fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize> {
+    mark_deleted_with(s, ids, Vec::new(), label)
+}
+
+/// [`mark_deleted`] with `extra` ops in the same undo step (an empty `ids` still commits them).
+pub(super) fn mark_deleted_with(s: &mut Session, ids: &[PhotoId], extra: Vec<Op>, label: &str) -> Result<usize> {
     let mut ops: Vec<Op> = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
     let n = ops.len();
-    if n == 0 {
+    if n == 0 && extra.is_empty() {
         return Ok(0);
     }
     ops.extend(s.catalog.remove_from_stacks_ops(ids));
+    ops.extend(extra);
     s.commit(label, Op::Batch { ops })?;
     Ok(n)
 }
diff --git a/crates/engine/src/cmd/library/specs.rs b/crates/engine/src/cmd/library/specs.rs
--- a/crates/engine/src/cmd/library/specs.rs
+++ b/crates/engine/src/cmd/library/specs.rs
@@ -3,7 +3,7 @@ use super::gps::parse_gps;
 use super::import::{import_batch_done, import_params, list_auto_import_folder};
 use super::selection::{
     album_param, album_targets, album_targets_or_err, change_flag, change_rating, cull, flip, for_targets, has_library, mark_deleted,
-    opt_album_param, rotate, row_step, select_after_deletion, select_by, step, strs,
+    mark_deleted_with, opt_album_param, rotate, row_step, select_after_deletion, select_by, step, strs,
 };
 use super::*;
 
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
                         }
                         let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
                         let ids = s.catalog.query(&f, &Sort::default());
-                        if ids.is_empty() {
+                        if ids.is_empty() && !s.catalog.holds_made_folder(path) {
                             return Err(bad("library.source", format!("{path}: no photo in the library was imported from it")));
                         }
                         if covers_other_disks(s, path, &ids) {
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Library Folders",
             [],
             None,
-            "{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
+            "{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from (and the folders made with folder.create, empty), with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
             always,
             |s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default())
         ),
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Remove Folder from Library",
             [],
             None,
-            "{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}",
+            "{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted, and take a folder made with folder.create (empty or not) off the list (`removed` may be 0); one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}",
             always,
             |s, p| {
                 const C: &str = "library.removeFolder";
@@ -141,13 +141,15 @@ pub fn specs() -> Vec<CommandSpec> {
                 }
                 let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
                 let ids = s.catalog.query(&f, &Sort::default());
-                if ids.is_empty() {
+                // a folder made through LightCraft leaves the list too (undo brings the row back)
+                let unmade = s.catalog.folders_unmade_ops(path, bool_or(p, "disk", false));
+                if ids.is_empty() && unmade.is_empty() {
                     return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
                 }
                 if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) {
                     return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them")));
                 }
-                mark_deleted(s, &ids, "Remove Folder from Library")?;
+                mark_deleted_with(s, &ids, unmade, "Remove Folder from Library")?;
                 if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
                     s.filter.library_folder = None;
                 }
@@ -177,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Set Folder Color Label",
             [],
             None,
-            "{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}",
+            "{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, or one made with folder.create; see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}",
             always,
             |s, p| {
                 const C: &str = "folder.label";
@@ -188,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
                 };
                 // taking a label off always works, also once the folder's photos are gone
                 let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
-                if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {
+                if label.is_some() && !s.catalog.holds_made_folder(path) && s.catalog.query(&f, &Sort::default()).is_empty() {
                     return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
                 }
                 if label.is_none() && s.catalog.folder_record(path).is_none() {
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1143,7 +1143,7 @@ impl Session {
             // what the view shows now is what the next edit is followed from, and what it holds until it is
             // refreshed (taken before stacks are arranged, so a photo folded into a stack is held with it)
             self.base = (!whole || holds).then(|| (view, self.catalog.revision, visible.clone()));
-            if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() {
+            if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_is_listed() {
                 // the shown folder lost its last photo (deleted, moved, removed): everything, not
                 // an empty grid under the name of a folder that is gone from the sidebar
                 self.source = LibrarySource::All;
@@ -1242,11 +1242,13 @@ impl Session {
         whole
     }
 
-    /// Whether the library still holds a photo imported from the folder a `LibraryFolder` source
-    /// shows.
-    fn folder_holds_photos(&self) -> bool {
-        let f = Filter { library_folder: self.library_folder.clone(), ..Default::default() };
-        self.library_folder.is_some() && !self.catalog.query(&f, &Sort::default()).is_empty()
+    /// Whether the folder a `LibraryFolder` source shows is still in the folder list: the library
+    /// holds a photo imported from it, or it was made through LightCraft (and so stays listed
+    /// empty).
+    fn folder_is_listed(&self) -> bool {
+        let Some(folder) = self.library_folder.as_deref() else { return false };
+        let f = Filter { library_folder: Some(folder.to_string()), ..Default::default() };
+        self.catalog.holds_made_folder(folder) || !self.catalog.query(&f, &Sort::default()).is_empty()
     }
 
     /// Photos in the current source (folder, album, …) before the filter bar and search narrow
diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs
--- a/crates/engine/src/sync.rs
+++ b/crates/engine/src/sync.rs
@@ -176,7 +176,8 @@ fn invalid(msg: impl Into<String>) -> EngineError {
 }
 
 impl SyncInput {
-    /// The scan of folder `path`: a folder the library holds photos in, not the startup disk. A
+    /// The scan of folder `path`: a folder the library holds photos in (or one made through
+    /// LightCraft, which may still be empty), not the startup disk. A
     /// whole disk or share, or a folder that holds disks (`/Volumes`, `/mnt`…), only with `disk`
     /// (as for Remove Disk from Library: its missing photos can be a whole disk's).
     pub fn new(s: &mut Session, path: &str, disk: bool) -> Result<SyncInput> {
@@ -189,7 +190,7 @@ impl SyncInput {
         }
         let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
         let ids = s.catalog.query(&f, &Sort::default());
-        if ids.is_empty() {
+        if ids.is_empty() && !s.catalog.holds_made_folder(path) {
             return Err(invalid(format!("{path}: no photo in the library was imported from it")));
         }
         if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) {
diff --git a/crates/engine/src/tests/folders.rs b/crates/engine/src/tests/folders.rs
--- a/crates/engine/src/tests/folders.rs
+++ b/crates/engine/src/tests/folders.rs
@@ -619,6 +619,206 @@ fn a_folder_is_created_on_disk_and_undo_removes_it() {
     assert!(dir.0.join("picks/note.txt").is_file());
 }
 
+#[test]
+fn a_row_above_a_made_folder_stays_choosable_when_the_photos_beside_it_leave() {
+    let dir = Scratch::new("made-above");
+    std::fs::create_dir_all(dir.0.join("p/a")).unwrap();
+    let mut s = Session::new();
+    add_file(&mut s, &dir.path("p/a/x.jpg"), "X");
+    s.execute("folder.create", &json!({"parent": dir.path("p"), "name": "new"})).unwrap();
+    s.execute("library.removeFolder", &json!({"path": dir.path("p/a")})).unwrap();
+    assert_eq!(rows_for(&mut s, &dir.path("p")).iter().map(|r| r.0).collect::<Vec<_>>(), vec![0], "the row above the made folder is still listed");
+    let r = s.execute("library.source", &json!({"kind": "libraryFolder", "path": dir.path("p")}));
+    assert!(r.is_ok(), "{r:?}");
+    assert_eq!(s.visible().len(), 0);
+    assert_eq!(s.source, LibrarySource::LibraryFolder, "the folder shown is still in the list, so the view stays");
+}
+
+/// A folder made inside a relative `parent` (as an agent or the CLI may give it) is made and
+/// listed under its absolute path, like the folders of an absolute one.
+#[test]
+fn a_folder_made_inside_a_relative_parent_is_listed_under_its_absolute_path() {
+    /// Removes the folder the test makes in the working folder, however the test ends.
+    struct Cleanup(std::path::PathBuf);
+    impl Drop for Cleanup {
+        fn drop(&mut self) {
+            let _ = std::fs::remove_dir(&self.0);
+        }
+    }
+    let name = format!("lc-folders-relative-{}", std::process::id());
+    let made = std::path::absolute(".").unwrap().join(&name);
+    let _cleanup = Cleanup(made.clone());
+    let mut s = Session::new();
+    let r = s.execute("folder.create", &json!({"parent": ".", "name": name})).unwrap();
+    assert_eq!(std::path::Path::new(r["path"].as_str().unwrap()), made);
+    assert!(made.is_dir());
+    let rows = rows_for(&mut s, &made.to_string_lossy());
+    assert_eq!(rows.len(), 1, "the made folder is listed: {rows:?}");
+}
+
+#[test]
+fn a_made_folder_can_be_given_a_color_label_before_it_holds_a_photo() {
+    let dir = Scratch::new("made-label");
+    let mut s = Session::new();
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "b"})).unwrap();
+    s.execute("folder.label", &json!({"path": dir.path("b"), "label": "green"})).unwrap();
+    assert_eq!(listed_label(&mut s, &dir.path("b")).as_deref(), Some("green"));
+    s.execute("folder.label", &json!({"path": dir.path("nope"), "label": "green"})).unwrap_err();
+}
+
+#[test]
+fn removing_a_made_folder_from_the_library_takes_its_row_away_and_undo_brings_it_back() {
+    let dir = Scratch::new("made-remove");
+    let mut s = Session::new();
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "b"})).unwrap();
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "c"})).unwrap();
+    add_file(&mut s, &dir.path("c/x.jpg"), "X");
+    let r = s.execute("library.removeFolder", &json!({"path": dir.path("b")})).unwrap();
+    assert_eq!(r["removed"], 0);
+    assert!(rows_for(&mut s, &dir.path("b")).is_empty(), "no photos, so no row once it is removed");
+    assert!(dir.0.join("b").is_dir(), "no file is touched");
+    s.execute("library.removeFolder", &json!({"path": dir.path("c")})).unwrap();
+    assert!(rows_for(&mut s, &dir.path("c")).is_empty(), "a made folder with photos goes with them");
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert_eq!(rows_for(&mut s, &dir.path("c")).iter().map(|r| (r.0, r.1)).collect::<Vec<_>>(), vec![(1, 1)], "one step brings back photos and row");
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert_eq!(rows_for(&mut s, &dir.path("b")).len(), 1);
+    assert!(s.execute("library.removeFolder", &json!({"path": dir.path("nope")})).is_err(), "a folder the library never listed is still refused");
+}
+
+#[test]
+fn a_made_folder_can_be_chosen_empty_and_undoing_its_creation_leaves_the_choice() {
+    let dir = Scratch::new("made-source");
+    let mut s = Session::new();
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "b"})).unwrap();
+    let r = s.execute("library.source", &json!({"kind": "libraryFolder", "path": dir.path("b")}));
+    assert_eq!(r.as_ref().map(|r| r["count"].clone()).ok(), Some(json!(0)), "{r:?}");
+    assert_eq!(s.visible().len(), 0);
+    assert_eq!(s.source, LibrarySource::LibraryFolder, "an empty grid under the folder's own name, not everything");
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert_eq!(s.visible().len(), 0);
+    assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None), "the folder is gone");
+}
+
+/// Every row of `library.folders` for `path` (a folder spelled any way), at any depth, as
+/// `(count, own, parent path)`.
+fn rows_for(s: &mut Session, path: &str) -> Vec<(u64, u64, Option<String>)> {
+    fn walk(rows: &serde_json::Value, parent: Option<&str>, key: &str, out: &mut Vec<(u64, u64, Option<String>)>) {
+        for r in rows.as_array().into_iter().flatten() {
+            let p = r["path"].as_str().unwrap_or_default();
+            if lightcraft_catalog::query::folder_key(p) == key {
+                out.push((r["count"].as_u64().unwrap(), r["own"].as_u64().unwrap(), parent.map(str::to_string)));
+            }
+            walk(&r["children"], Some(p), key, out);
+        }
+    }
+    let rows = s.execute("library.folders", &json!({})).unwrap();
+    let mut out = Vec::new();
+    walk(&rows, None, &lightcraft_catalog::query::folder_key(path), &mut out);
+    out
+}
+
+fn parent_key(row: &(u64, u64, Option<String>)) -> Option<String> {
+    row.2.as_deref().map(lightcraft_catalog::query::folder_key)
+}
+
+#[test]
+fn a_folder_made_here_has_a_row_with_no_photos_at_once_and_undo_and_redo_take_it_and_bring_it_back() {
+    let dir = Scratch::new("made-row");
+    let mut s = Session::new();
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "b"})).unwrap();
+    assert_eq!(rows_for(&mut s, &dir.path("b")).iter().map(|r| (r.0, r.1)).collect::<Vec<_>>(), vec![(0, 0)], "one row, no photos");
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert!(rows_for(&mut s, &dir.path("b")).is_empty(), "undo takes the row with the folder");
+    s.execute("edit.redo", &json!({})).unwrap();
+    assert_eq!(rows_for(&mut s, &dir.path("b")).len(), 1, "redo brings it back");
+    assert!(s.catalog.folder_record(&dir.path("b")).is_some(), "the library records the folder it made");
+}
+
+#[test]
+fn a_made_folder_stays_one_row_when_it_gains_photos() {
+    let dir = Scratch::new("made-gains");
+    let mut s = Session::new();
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "b"})).unwrap();
+    add_file(&mut s, &dir.path("b/x.jpg"), "X");
+    let rows = rows_for(&mut s, &dir.path("b"));
+    assert_eq!(rows.iter().map(|r| (r.0, r.1)).collect::<Vec<_>>(), vec![(1, 1)]);
+}
+
+#[test]
+fn a_folder_made_inside_a_folder_with_no_photos_of_its_own_nests_there() {
+    let dir = Scratch::new("made-nested");
+    std::fs::create_dir_all(dir.0.join("a/sub")).unwrap();
+    let mut s = Session::new();
+    add_file(&mut s, &dir.path("a/sub/p.jpg"), "P");
+    s.execute("folder.create", &json!({"parent": dir.path("a"), "name": "new"})).unwrap();
+    let made = rows_for(&mut s, &dir.path("a/new"));
+    assert_eq!(made.len(), 1);
+    assert_eq!((made[0].0, parent_key(&made[0])), (0, Some(lightcraft_catalog::query::folder_key(&dir.path("a")))));
+    let a = rows_for(&mut s, &dir.path("a"));
+    assert_eq!(a.iter().map(|r| (r.0, r.1)).collect::<Vec<_>>(), vec![(1, 0)], "the parent counts what is inside it, once");
+}
+
+#[test]
+fn photos_moved_into_a_made_folder_leave_one_row_each_with_the_right_counts() {
+    let dir = Scratch::new("made-move");
+    std::fs::create_dir_all(dir.0.join("a")).unwrap();
+    let mut s = Session::new();
+    let ids: Vec<_> = ["1", "2", "3"].iter().map(|n| add_file(&mut s, &dir.path(&format!("a/{n}.jpg")), n)).collect();
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "b"})).unwrap();
+    s.execute("photo.moveToFolder", &json!({"ids": [ids[0].0, ids[1].0], "folder": dir.path("b")})).unwrap();
+    let counts = |s: &mut Session, p: &str| rows_for(s, &dir.path(p)).iter().map(|r| (r.0, r.1)).collect::<Vec<_>>();
+    assert_eq!(counts(&mut s, "b"), vec![(2, 2)]);
+    assert_eq!(counts(&mut s, "a"), vec![(1, 1)]);
+    s.execute("photo.moveToFolder", &json!({"ids": [ids[2].0], "folder": dir.path("b")})).unwrap();
+    assert_eq!(counts(&mut s, "b"), vec![(3, 3)]);
+    assert!(rows_for(&mut s, &dir.path("a")).is_empty(), "an emptied folder the library never made is a row only while it holds photos, as before");
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert_eq!(counts(&mut s, "b"), vec![(2, 2)]);
+    assert_eq!(counts(&mut s, "a"), vec![(1, 1)]);
+}
+
+#[test]
+fn a_made_folder_stays_a_row_when_its_photos_move_away() {
+    let dir = Scratch::new("made-empties");
+    std::fs::create_dir_all(dir.0.join("a")).unwrap();
+    let mut s = Session::new();
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "b"})).unwrap();
+    let id = add_file(&mut s, &dir.path("b/x.jpg"), "X");
+    s.execute("photo.moveToFolder", &json!({"ids": [id.0], "folder": dir.path("a")})).unwrap();
+    assert_eq!(rows_for(&mut s, &dir.path("b")).iter().map(|r| (r.0, r.1)).collect::<Vec<_>>(), vec![(0, 0)]);
+}
+
+#[test]
+fn a_folder_moved_into_a_made_folder_lands_under_it_and_the_made_folder_stays_one_row() {
+    let dir = Scratch::new("made-folder-in");
+    std::fs::create_dir_all(dir.0.join("c")).unwrap();
+    let mut s = Session::new();
+    add_file(&mut s, &dir.path("c/x.jpg"), "X");
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "b"})).unwrap();
+    s.execute("folder.move", &json!({"path": dir.path("c"), "into": dir.path("b")})).unwrap();
+    let b = rows_for(&mut s, &dir.path("b"));
+    assert_eq!(b.iter().map(|r| (r.0, r.1)).collect::<Vec<_>>(), vec![(1, 0)]);
+    let c = rows_for(&mut s, &dir.path("b/c"));
+    assert_eq!(c.len(), 1);
+    assert_eq!(parent_key(&c[0]), Some(lightcraft_catalog::query::folder_key(&dir.path("b"))));
+    assert!(rows_for(&mut s, &dir.path("c")).is_empty());
+}
+
+#[test]
+fn a_made_folder_that_is_renamed_or_moved_keeps_its_row_under_the_new_name() {
+    let dir = Scratch::new("made-rename");
+    std::fs::create_dir_all(dir.0.join("into")).unwrap();
+    let mut s = Session::new();
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "b"})).unwrap();
+    s.execute("folder.move", &json!({"path": dir.path("b"), "into": dir.path("into")})).unwrap();
+    assert!(rows_for(&mut s, &dir.path("b")).is_empty());
+    assert_eq!(rows_for(&mut s, &dir.path("into/b")).len(), 1, "the record went with the folder");
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert_eq!(rows_for(&mut s, &dir.path("b")).len(), 1);
+    assert!(rows_for(&mut s, &dir.path("into/b")).is_empty());
+}
+
 #[test]
 fn photos_move_to_another_folder_with_their_sidecars_and_back_on_undo() {
     let dir = Scratch::new("move-photos");
diff --git a/crates/engine/src/tests/sync.rs b/crates/engine/src/tests/sync.rs
--- a/crates/engine/src/tests/sync.rs
+++ b/crates/engine/src/tests/sync.rs
@@ -105,6 +105,19 @@ fn an_unchanged_folder_has_no_changes() {
     assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}");
 }
 
+#[test]
+fn a_folder_made_in_the_library_can_be_synchronized_before_it_holds_a_photo() {
+    let dir = Scratch::new("made-empty");
+    let mut s = Session::new().with_fs();
+    s.execute("folder.create", &json!({"parent": dir.path(""), "name": "fresh"})).unwrap();
+    write_png(&dir.path("fresh/a.png"), 7);
+    let r = scan(&mut s, &dir.path("fresh"));
+    assert_eq!(paths(&r, "new"), vec![dir.path("fresh/a.png")], "{r}");
+    s.execute("folder.synchronize", &json!({"path": dir.path("fresh")})).unwrap();
+    assert_eq!(s.catalog.len(), 1);
+    assert!(s.execute("folder.scanChanges", &json!({"path": dir.path("nowhere")})).is_err(), "a folder the library never listed is still refused");
+}
+
 #[cfg(windows)]
 #[test]
 fn importing_and_synchronizing_with_different_windows_separators_keeps_photo_identity() {
diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs
--- a/crates/ui-egui/src/dialogs/confirm.rs
+++ b/crates/ui-egui/src/dialogs/confirm.rs
@@ -57,7 +57,19 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
         Dialog::TextPrompt { value, command, params, key, .. } => {
             let mut p = params.clone();
             p[key.as_str()] = json!(value.trim());
-            app.run(command, p)
+            let r = app.run(command, p)?;
+            if command == "folder.create"
+                && let Some(path) = r.get("path").and_then(serde_json::Value::as_str)
+            {
+                // the new folder has its row in Folders at once: bring it into view. It is not
+                // chosen: that would empty the grid and drop the photos selected to move into it.
+                let tree = app.caches.folder_tree(&app.session.catalog);
+                if let Some(row) = crate::panels::left::folder_row_of(&tree, path) {
+                    app.ui.left_panel = true;
+                    app.ui.reveal_folder = Some(row.path.clone());
+                }
+            }
+            Ok(r)
         }
         Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => app.run(
             "photo.setCaptureTime",
diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs
--- a/crates/ui-egui/src/panels/left.rs
+++ b/crates/ui-egui/src/panels/left.rs
@@ -30,7 +30,7 @@ pub(crate) use local::{fs_cached, fs_cached_running, same_folder};
 use local::{local_now, local_rows};
 #[cfg(test)]
 pub(crate) use rows::{INDENT_STEP, MIN_INDENT_STEP};
-use rows::{LIBFOLDER_REVEALED, SCROLL_TO_ROW, row, set_tree_open};
+use rows::{LIBFOLDER_OPEN_ABOVE, LIBFOLDER_REVEALED, SCROLL_TO_ROW, row, set_tree_open};
 pub(crate) use rows::{content_width, indent_step, step_for};
 pub(crate) use sections::PlacedSection;
 pub use sections::section_drag_feedback;
@@ -130,6 +130,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
             if let Some(path) = app.ui.reveal_folder.take() {
                 app.ui.sidebar.reveal(SidebarSection::Folders);
                 ui.data_mut(|d| d.remove::<Option<String>>(egui::Id::unique(LIBFOLDER_REVEALED)));
+                ui.data_mut(|d| d.insert_temp(egui::Id::unique(LIBFOLDER_OPEN_ABOVE), path.clone()));
                 // …and the sidebar scrolls to it
                 ui.data_mut(|d| d.insert_temp(egui::Id::unique(SCROLL_TO_ROW), format!("libfolder:{path}").to_lowercase()));
             }
diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs
--- a/crates/ui-egui/src/panels/left/folders.rs
+++ b/crates/ui-egui/src/panels/left/folders.rs
@@ -1,6 +1,6 @@
 use super::albums::pointer_over_row;
 use super::local::{same_folder, subfolders};
-use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, out_of_sight, row, row_sensed, set_tree_open, tree_open, tree_toggle};
+use super::rows::{INDENT_STEP, LIBFOLDER_OPEN_ABOVE, LIBFOLDER_REVEALED, out_of_sight, row, row_sensed, set_tree_open, tree_open, tree_toggle};
 use super::*;
 
 /// A folder on disk with a disclosure triangle: click browses it, the triangle lists its
@@ -48,7 +48,7 @@ pub(super) fn folder_tree(
             app.toast(ui.ctx(), e);
         }
         crate::widgets::context_menu(&resp, |ui| {
-            // the library actions of the Folders tree, when this folder (or one inside it) holds library photos
+            // the library actions of the Folders tree, when this folder is one of its rows (or one inside it holds library photos)
             let library = app.caches.folder_tree(&app.session.catalog);
             let held = library_node_for(&library, path);
             if (transient || indent > 0.0)
@@ -273,20 +273,12 @@ pub(super) fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, an
 // "Folders" (drawn in `show`): where on disk the library's photos were imported from, with photo counts (see
 // `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a Local
 // folder: its photos and those of the folders inside it fill the grid. The triangle opens a
-// level. Only folders holding imported photos are listed; every folder on disk is under Local.
+// level. Only folders holding imported photos, and the ones made with New Folder on Disk…, are listed;
+// every folder on disk is under Local.
 
 /// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows
 /// above it so it is on screen; folding one by hand afterwards sticks until the choice changes.
 pub(super) fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) {
-    let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder);
-    let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty());
-    let seen = egui::Id::unique(LIBFOLDER_REVEALED);
-    let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key);
-    if ui.data(|d| d.get_temp::<Option<String>>(seen)) == Some(now.clone()) {
-        return;
-    }
-    ui.data_mut(|d| d.insert_temp(seen, now));
-    let Some(chosen) = chosen else { return };
     fn open_above(ui: &egui::Ui, nodes: &[FolderNode], chosen: &str) {
         for n in nodes {
             if lightcraft_catalog::query::folder_within(chosen, &n.path) && !same_folder(chosen, &n.path) {
@@ -296,6 +288,20 @@ pub(super) fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNo
             }
         }
     }
+    // a folder to bring into view without choosing it (the one just made): its rows above open, once
+    let pending = egui::Id::unique(LIBFOLDER_OPEN_ABOVE);
+    if let Some(path) = ui.data_mut(|d| d.remove_temp::<String>(pending)) {
+        open_above(ui, tree, &path);
+    }
+    let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder);
+    let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty());
+    let seen = egui::Id::unique(LIBFOLDER_REVEALED);
+    let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key);
+    if ui.data(|d| d.get_temp::<Option<String>>(seen)) == Some(now.clone()) {
+        return;
+    }
+    ui.data_mut(|d| d.insert_temp(seen, now));
+    let Some(chosen) = chosen else { return };
     open_above(ui, tree, &chosen);
 }
 
@@ -530,7 +536,8 @@ fn locate_missing_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNod
 
 /// The node Synchronize Folder… of a Local folder `path` works on, with the name its dialog shows:
 /// the Folders row for that very folder, else (a folder above rows) the folder itself, as a folder
-/// holding other folders. `None` when no library photo lies in `path` or inside it. Reads the
+/// holding other folders. `None` when no library photo lies in `path` or inside it and `path` is
+/// not itself a Folders row (a folder made through LightCraft stays a row while empty). Reads the
 /// cached Folders tree only; the disk is not touched.
 fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, String)> {
     use lightcraft_catalog::query::folder_within;
@@ -540,7 +547,8 @@ fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, Stri
     fn any_inside(tree: &[FolderNode], path: &str) -> bool {
         tree.iter().any(|n| (n.count > 0 && folder_within(&n.path, path)) || any_inside(&n.children, path))
     }
-    if let Some(n) = exact(tree, path).filter(|n| n.count > 0) {
+    // (a row with no photo is a folder made through LightCraft, or one above it: the scan finds what was put in it)
+    if let Some(n) = exact(tree, path) {
         return Some((n.clone(), lightcraft_catalog::folders::folder_label(&n.path)));
     }
     if !any_inside(tree, path) {
diff --git a/crates/ui-egui/src/panels/left/rows.rs b/crates/ui-egui/src/panels/left/rows.rs
--- a/crates/ui-egui/src/panels/left/rows.rs
+++ b/crates/ui-egui/src/panels/left/rows.rs
@@ -21,6 +21,8 @@ pub(super) const SCROLL_TO_ROW: &str = "left-scroll-to-row";
 /// Temp-data key of the Folders choice whose rows above were last opened ([`reveal_chosen`]):
 /// removed, they open again.
 pub(super) const LIBFOLDER_REVEALED: &str = "libfolder-revealed";
+/// The folder (a path) whose rows above it open once, without choosing it.
+pub(super) const LIBFOLDER_OPEN_ABOVE: &str = "libfolder-open-above";
 
 /// A row says how wide it needs to be.
 pub(super) fn note_width(ui: &egui::Ui, w: f32) {
diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs
--- a/crates/ui-egui/src/state.rs
+++ b/crates/ui-egui/src/state.rs
@@ -216,8 +216,8 @@ pub struct UiState {
     /// A keyword just made from the sidebar: Keywords opens down to it, once.
     #[serde(skip)]
     pub reveal_keyword: Option<String>,
-    /// A folder Photo ▸ Show in Folders went to (its Folders row's path): the sidebar's Folders
-    /// section opens down to it and scrolls to it, once.
+    /// A folder Photo ▸ Show in Folders went to, or one just made with New Folder on Disk… (its
+    /// Folders row's path): the sidebar's Folders section opens down to it and scrolls to it, once.
     #[serde(skip)]
     pub reveal_folder: Option<String>,
     /// An album or folder row being dragged in the sidebar (dropped on a folder to move it there).
diff --git a/crates/ui-egui/src/tests/folder_drop.rs b/crates/ui-egui/src/tests/folder_drop.rs
--- a/crates/ui-egui/src/tests/folder_drop.rs
+++ b/crates/ui-egui/src/tests/folder_drop.rs
@@ -346,3 +346,79 @@ fn a_question_left_open_while_another_library_opens_moves_nothing() {
     assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved");
     let _ = std::fs::remove_dir_all(&root);
 }
+
+/// Answer New Folder on Disk… with `name` inside `parent` (the dialog's own confirm).
+fn make_folder(h: &mut Headless, parent: &Path, name: &str) {
+    h.app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
+        title: "New Folder in “trip”".into(),
+        hint: "Folder name (created on disk)".into(),
+        value: name.into(),
+        command: "folder.create".into(),
+        params: json!({"parent": parent.to_string_lossy()}),
+        key: "name".into(),
+    });
+    h.step();
+    let r = h.request("ui.dialog.confirm", json!({}), std::time::Duration::from_secs(10));
+    assert_eq!(r["ok"], true, "{r}");
+    for _ in 0..4 {
+        h.step();
+    }
+}
+
+/// The photo count of every Folders row for `dir`.
+fn row_counts(h: &mut Headless, dir: &Path) -> Vec<usize> {
+    fn count(nodes: &[lightcraft_catalog::FolderNode], dir: &str, out: &mut Vec<usize>) {
+        for n in nodes {
+            if folder_key(Path::new(&n.path)) == dir {
+                out.push(n.count);
+            }
+            count(&n.children, dir, out);
+        }
+    }
+    let rows = h.app.caches.folder_tree(&h.app.session.catalog);
+    let mut counts = Vec::new();
+    count(&rows, &folder_key(dir), &mut counts);
+    counts
+}
+
+#[test]
+fn a_folder_made_in_the_sidebar_has_its_row_at_once_and_takes_dropped_photos() {
+    let (root, trip, _home) = folders("made");
+    let mut h = app(&[(trip.join("beach.png"), false)]);
+    let made = root.join("fresh");
+    // Given New Folder on Disk… answered with a name
+    make_folder(&mut h, &root, "fresh");
+    // Then the folder exists and its row is in Folders, with no photo in it, brought into view
+    assert!(made.is_dir());
+    assert!(folder_row(&h, &made).is_some(), "{:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:")).collect::<Vec<_>>());
+    assert_eq!(row_counts(&mut h, &made), vec![0]);
+    assert!(h.app.ui.reveal_folder.is_none(), "the request to show it was taken up");
+    // and photos dragged from the grid onto it move there
+    let beach = id_of(&h, "beach.png");
+    let target = folder_row(&h, &made).expect("the new row stays");
+    drop_beach(&mut h, beach, target);
+    h.step();
+    click(&mut h, "button:dialogOk");
+    assert!(made.join("beach.png").is_file() && !trip.join("beach.png").exists(), "moved on disk");
+    assert_eq!(row_counts(&mut h, &made), vec![1], "one row, one photo");
+    let _ = std::fs::remove_dir_all(&root);
+}
+
+#[test]
+fn making_a_folder_keeps_the_photos_selected_to_move_into_it() {
+    let (root, trip, home) = folders("made-selection");
+    let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
+    let made = root.join("fresh");
+    let (beach, garden) = (id_of(&h, "beach.png"), id_of(&h, "garden.png"));
+    let (a, b) = (lightcraft_catalog::PhotoId(beach), lightcraft_catalog::PhotoId(garden));
+    h.app.session.selection = lightcraft_engine::Selection { ids: vec![a, b], active: Some(b) };
+    make_folder(&mut h, &root, "fresh");
+    // Then the view and the selection are as they were: Move Selected Photos Here has something to move
+    assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All);
+    assert_eq!(h.app.session.selection.ids.len(), 2);
+    let r = h.app.run("photo.moveToFolder", json!({"folder": made.to_string_lossy()})).unwrap();
+    assert_eq!(r["moved"], 2, "{r}");
+    assert!(made.join("beach.png").is_file() && made.join("garden.png").is_file());
+    assert_eq!(row_counts(&mut h, &made), vec![2], "one row, both photos");
+    let _ = std::fs::remove_dir_all(&root);
+}
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -1,6 +1,6 @@
 # Lightroom parity checklist
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LRC-LIB-LIBFOLDERS: a folder made with New Folder on Disk… has a row in Folders at once, empty or not; earlier: LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
 
 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
 have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -703,7 +703,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
 | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) |
 | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/dialogs/settings.rs`, `crates/ui-egui/src/panels/left.rs`, `crates/ui-egui/src/panels/library_problem.rs` (`launch_plan`, `show_chooser`), `crates/ui-egui/src/tests/launch_library.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Remove from List ▸ one entry, or `app.recentLibraries {remove}`, which forgets only the path; Clear), the open library's name under My Photos and in the window title (issue #902); Open/New refused during imports and their folder scans; a library that can't be opened leaves the current one and its reference pin intact; a successful switch clears the catalog-local reference pin; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests/keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key; Settings ▸ General ▸ At launch: Open the last library (default) or Ask me, a window listing the recent libraries (name, folder, when last opened; missing, unreadable or no-longer-library ones greyed out with the reason, checked read-only — nothing is locked or written, so a library another program has open is reported, with who has it, when Open is pressed; the last one selected; Open, Open Another…, New Library…, Remove from List, Quit; ↑ ↓ Return Esc), also shown once when Alt/Option is held in the first 2 s after the window appears (macOS reports an Option key held since before launch only once it changes); `--library` and `LIGHTCRAFT_LIBRARY` come first and `lightcraft-cli` never asks (issue #1290) |
 | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
-| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests/panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes (16 pt while the deepest open row of any sidebar tree fits the panel with room for its name and count; narrower in bands of 14, 12, 10 and 8 pt when it would not, the same for every tree, chosen from the last frame; issue #2298): past 8 pt the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; a folder with none of these changes only says the library is up to date with it, with one Close and no choices, and closing it starts no job; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files stops when the scan is cancelled, and its depth/entry limits are only logged |
+| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests/panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count), and the folders made with New Folder on Disk… (which stay listed, empty, until Remove from Library); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes (16 pt while the deepest open row of any sidebar tree fits the panel with room for its name and count; narrower in bands of 14, 12, 10 and 8 pt when it would not, the same for every tree, chosen from the last frame; issue #2298): past 8 pt the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo (a folder made in LightCraft stays shown, empty), and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one unless it was made in LightCraft or holds one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; the new folder has a row at once, with 0 photos, brought into view without being chosen so the photos selected to move into it stay selected; it can be chosen, labeled and synchronized while empty and stays a row as photos come and go until Remove from Library; one undo step, undo removes the folder and its row while it is empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; a folder with none of these changes only says the library is up to date with it, with one Close and no choices, and closing it starts no job; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files stops when the scan is cancelled, and its depth/entry limits are only logged |
 | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |
 | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/dialogs/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/widgets/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/widgets/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents |
 | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |
@@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
 | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) |
 | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone |
 | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |
+| 2026-10-27 | minor | LRC-LIB-LIBFOLDERS: a folder made with New Folder on Disk… (`folder.create`) appears in Folders at once with 0 photos, is brought into view (not chosen, so the photo selection is kept), works as a move destination (Move Selected Photos Here, photos dragged onto its row, `photo.moveToFolder`, `folder.move`) and stays one row when it gains or loses photos; undo removes it; the library records it as a folder record (`made`, the same record as the color label; the catalog format is now 10: older builds refuse an upgraded library); status unchanged (✅) |
diff --git a/docs/whats-new.md b/docs/whats-new.md
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -1,5 +1,20 @@
 # What's new in LightCraft
 
+## 2026-10-27
+
+### A folder made with New Folder on Disk… appears in Folders at once
+- Right-click a folder, choose New Folder on Disk…, name it and Create: the new folder now has its own row in Folders
+  straight away, with 0 photos, and the sidebar scrolls to it (it is not chosen, so the photos you selected to move
+  into it stay selected). Before, no row appeared until a photo was in the folder, so there was
+  nothing to move photos into. Move Selected Photos Here and dragging photos onto the row work on the
+  empty folder, and it stays one row as it gains photos. If every photo later leaves it, the row stays
+  (it is yours) until you choose Remove from Library on it, which takes the row away, and Undo brings it back; Undo
+  of the creation removes the folder and its row, Redo brings both back. Renaming or moving the folder in
+  LightCraft takes the row along. A folder you rename or delete outside LightCraft keeps its row, empty, like a folder
+  whose photos are missing.
+  The catalog format is now version 10 (`FolderRecord.made`). Once this version has opened a library, older versions
+  refuse it, as with every format change.
+
 ## 2026-10-24
 
 ### Relative-date filters and smart albums follow the clock
