diff --git a/contributors/people.toml b/contributors/people.toml
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -88,3 +88,7 @@ sync_github_name = false
 
 [people.webdevtodayjason]
 display_name = "Jason Brashear"
+
+[people.dotCooCoo]
+real_name = "Robert Lee"
+sync_github_name = true
diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs
--- a/crates/codecs/src/gop.rs
+++ b/crates/codecs/src/gop.rs
@@ -1097,21 +1097,52 @@ mod tests {
             let _ = tx.send(());
         });
         // A deadlock stops every request of a round for good. On a loaded machine the 3,840
-        // requests can take minutes, so the limit applies to the time between two answered ones.
-        let mut answered = 0;
+        // requests can take minutes, so the limit applies to the time since the last answered one.
+        if let Err(frames) = wait_while_answered(&rx, &done, Duration::from_secs(120)) {
+            panic!("frame requests deadlocked after {frames} frames");
+        }
+    }
+
+    /// Waits for `rx` while `done` keeps growing. Returns `Err` with the count in `done` once
+    /// `limit` has passed since the count last grew (or since the call, before it grows).
+    fn wait_while_answered(rx: &std::sync::mpsc::Receiver<()>, done: &AtomicUsize, limit: Duration) -> Result<(), usize> {
+        let mut answered = done.load(Ordering::Relaxed);
+        let mut last_answer = Instant::now();
         loop {
-            match rx.recv_timeout(Duration::from_secs(120)) {
-                Ok(()) => break,
+            match rx.recv_timeout(limit / 120) {
+                Ok(()) => return Ok(()),
                 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
                     let now = done.load(Ordering::Relaxed);
-                    assert!(now > answered, "frame requests deadlocked after {now} frames");
-                    answered = now;
+                    if now > answered {
+                        answered = now;
+                        last_answer = Instant::now();
+                    } else if last_answer.elapsed() >= limit {
+                        return Err(now);
+                    }
                 }
                 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("a frame request failed"),
             }
         }
     }
 
+    #[test]
+    fn a_stall_right_after_an_answer_is_reported_once_the_limit_has_passed() {
+        let (_tx, rx) = std::sync::mpsc::channel();
+        let done = Arc::new(AtomicUsize::new(0));
+        let answering = done.clone();
+        let (answer_tx, answer_rx) = std::sync::mpsc::channel();
+        let limit = Duration::from_secs(2);
+        std::thread::spawn(move || {
+            std::thread::sleep(Duration::from_millis(50));
+            let answer = Instant::now();
+            answering.fetch_add(1, Ordering::Relaxed);
+            let _ = answer_tx.send(answer);
+        });
+        assert_eq!(wait_while_answered(&rx, &done, limit), Err(1));
+        let waited = answer_rx.recv().expect("answer time").elapsed();
+        assert!(waited >= limit && waited < limit * 3 / 2, "the stall was reported {waited:?} after the answer");
+    }
+
     #[test]
     fn running_decoder_continues_through_a_nearby_sync_sample() {
         let s = samples(200, 30, 0, false);
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -1708,6 +1708,10 @@ impl FilmcraftApp {
             }
             "file.open" => self.pick(FileDialog::OpenProject, |app, paths| {
                 let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) };
+                // unsaved changes: Save / Don't Save / Cancel first
+                if panels::clip_dialogs::intercept_open(app, &path) {
+                    return Ok(json!({"dialog": "openProject"}));
+                }
                 app.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string())
             }),
             "graphics.newFromFile" => {
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -583,6 +583,10 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
     // File ▸ Open Recent
     if let Some(i) = id.strip_prefix("file.openRecent.") {
         let path = i.parse::<usize>().ok().and_then(|i| app.session.prefs.general.recent_projects.get(i).cloned()).ok_or("no such recent project")?;
+        // unsaved changes: Save / Don't Save / Cancel first
+        if crate::panels::clip_dialogs::intercept_open(app, &path) {
+            return Ok(json!({"dialog": "openProject"}));
+        }
         return invoke(app, ctx, "file.open", json!({"path": path}));
     }
     if id == "file.clearRecent" {
@@ -1246,6 +1250,8 @@ mod tests {
 
         let app = FilmcraftApp::new(filmcraft_engine::Session::default());
         let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 700.0)).build_ui_state(|ui, app| menu_bar(app, ui), app);
+        // the app's fonts, which draw the macOS shortcut symbols (⇧⌘S) egui's default fonts lack
+        crate::theme::install(&harness.ctx, &harness.state().tokens);
         harness.run_steps(3);
         // Nothing open: hovering a title does not open it.
         harness.get_by_label("Edit").hover();
diff --git a/crates/ui-egui/src/panels/clip_dialogs.rs b/crates/ui-egui/src/panels/clip_dialogs.rs
--- a/crates/ui-egui/src/panels/clip_dialogs.rs
+++ b/crates/ui-egui/src/panels/clip_dialogs.rs
@@ -1,7 +1,7 @@
 //! Edit / Clip / File menu dialogs (M3.10): Paste Attributes, Remove Attributes, Offline File,
 //! Make Subclip, Edit Subclip, Modify ▸ Audio Channels, Modify ▸ Timecode, Frame Hold Options,
-//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), and the Close Project and
-//! Quit "save changes?" prompts.
+//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), and the Close Project,
+//! Open Project and Quit "save changes?" prompts.
 //!
 //! The open dialog lives in `UiState::clip_dialog` (serde): the engine command it runs and the
 //! parameters being edited — the same JSON the command takes — so agents can open a dialog from
@@ -27,6 +27,8 @@
 //! - Clip Speed / Duration `speedDuration.*`: `speed`, `reverse`, `ripple`,
 //!   `interpolation.<frameSampling|frameBlending|opticalFlow>`;
 //! - Close Project `closeProject.save`, `closeProject.dontSave`, `closeProject.cancel`;
+//! - Open Project (asked once the project to open is chosen, see [`intercept_open`])
+//!   `openProject.save`, `openProject.dontSave`, `openProject.cancel`;
 //! - Quit (asked when the window is closed with unsaved changes, see [`intercept_quit`])
 //!   `quit.save`, `quit.dontSave`, `quit.cancel`.
 
@@ -61,6 +63,7 @@ fn meta(command: &str) -> Option<(&'static str, &'static str)> {
         "clip.fieldOptions" => (tl!("Field Options"), "fieldOptions"),
         "clip.speedDuration" => (tl!("Clip Speed / Duration"), "speedDuration"),
         "file.closeProject" => (tl!("Save Project"), "closeProject"),
+        "file.open" => (tl!("Save Project"), "openProject"),
         "app.quit" => (tl!("Save Project"), "quit"),
         _ => return None,
     })
@@ -95,7 +98,8 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<
     if id == "file.saveAll" && app.session.path.is_none() {
         return Some(app.file_dialog("file.save", params));
     }
-    if (id == "file.closeProject" && !app.session.is_dirty()) || id == "app.quit" {
+    // Open Project asks once the project to open is chosen (`intercept_open`)
+    if (id == "file.closeProject" && !app.session.is_dirty()) || id == "app.quit" || id == "file.open" {
         return None;
     }
     meta(id)?;
@@ -116,11 +120,23 @@ pub fn intercept_quit(app: &mut FilmcraftApp) -> bool {
         return false;
     }
     if app.ui.clip_dialog.as_ref().is_none_or(|d| d.command != "app.quit") {
-        app.ui.clip_dialog = Some(ClipDialogDraft { command: "app.quit".into(), params: json!({}), info: Value::Null, error: String::new() });
+        app.ui.clip_dialog = Some(ClipDialogDraft { command: "app.quit".into(), params: json!({}), info: asked_over(&app.session), error: String::new() });
     }
     true
 }
 
+/// File ▸ Open Project once the project at `path` is chosen, File ▸ Open Recent and Home's Recent
+/// list: with unsaved changes Save / Don't Save / Cancel is asked first, as Close Project asks, and
+/// `path` opens after Save or Don't Save. Returns whether it asked.
+pub fn intercept_open(app: &mut FilmcraftApp, path: &str) -> bool {
+    if !app.session.is_dirty() {
+        return false;
+    }
+    app.ui.clip_dialog =
+        Some(ClipDialogDraft { command: "file.open".into(), params: json!({"path": path}), info: asked_over(&app.session), error: String::new() });
+    true
+}
+
 /// Initial parameters (and display info) of a command's dialog.
 fn defaults(app: &FilmcraftApp, id: &str) -> (Value, Value) {
     let s = &app.session;
@@ -230,10 +246,17 @@ fn defaults(app: &FilmcraftApp, id: &str) -> (Value, Value) {
                 json!({"duration": it.as_ref().map_or(0, |i| i.duration.0), "speed": speed.abs(), "fps": fps}),
             )
         }
+        "file.closeProject" => (json!({}), asked_over(s)),
         _ => (json!({}), Value::Null),
     }
 }
 
+/// The info of the Close Project, Open Project and Quit prompts: the project they are asked over
+/// (`Session::document_epoch`). When another project replaces it, the prompt closes.
+fn asked_over(s: &filmcraft_engine::Session) -> Value {
+    json!({"project": s.document_epoch})
+}
+
 fn format_name(f: AudioChannels) -> &'static str {
     match f {
         AudioChannels::Mono => "mono",
@@ -307,11 +330,17 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
         app.ui.clip_dialog = None;
         return;
     };
+    // a save prompt asked over a project that another one has replaced since (a script opened,
+    // created or closed to it) closes without acting on the new project
+    if d.info["project"].as_u64().is_some_and(|p| p != app.session.document_epoch) {
+        app.ui.clip_dialog = None;
+        return;
+    }
     let mut elems: Elems = Vec::new();
     let mut action: Option<&'static str> = None;
     let project_name = app.session.project.name.clone();
     // a never-saved project is saved through the Save dialog
-    let can_save = app.session.path.is_some() || app.hooks.pick_save.is_some();
+    let can_save = app.session.path.is_some() || crate::native_dialogs::FileDialog::SaveProject { name: String::new() }.available(&app.hooks);
     // the title is translated; the window keeps one id whatever the interface language
     let id = egui::Id::unique(("clip-dialog", pre));
     egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(360.0).centered(ctx).show(ctx, |ui| {
@@ -541,7 +570,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
                     }
                 }
             }
-            "file.closeProject" | "app.quit" => {
+            "file.closeProject" | "file.open" | "app.quit" => {
                 ui.label(tlf!("Save changes to “{project_name}” before closing?", project_name));
                 ui.add_space(8.0);
                 ui.horizontal(|ui| {
@@ -607,6 +636,16 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
             }
         }
         Some("dontSave") | Some("save") => {
+            // Open Project ▸ Save on a never-saved project: the chosen project opens once the Save
+            // dialog's file is saved, which can be a later frame (Linux)
+            if action == Some("save") && d.command == "file.open" && app.session.path.is_none() {
+                app.ui.clip_dialog = None;
+                if let Err(e) = save_then_open(app, d.clone()) {
+                    app.ui.status = e;
+                    app.ui.clip_dialog = Some(d);
+                }
+                return;
+            }
             if action == Some("save") {
                 let saved = if app.session.path.is_some() {
                     app.session.execute("file.save", json!({})).map(|_| ()).map_err(|e| e.to_string())
@@ -624,6 +663,16 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
                     return;
                 }
             }
+            // Open Project opens the chosen project; when that fails, this project stays open with
+            // its unsaved changes
+            if d.command == "file.open" {
+                match app.session.execute("file.open", d.params.clone()) {
+                    Ok(_) => edit_after_open(app, &d),
+                    Err(e) => app.ui.status = e.to_string(),
+                }
+                app.ui.clip_dialog = None;
+                return;
+            }
             // Quit ▸ Don't Save discards the changes: closing the project also clears their recovery
             // snapshot, so the next launch doesn't offer them back
             let close = d.command != "app.quit" || app.session.is_dirty();
@@ -642,6 +691,51 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
     app.ui.clip_dialog = Some(d);
 }
 
+/// Open Project ▸ Save on a never-saved project: the Save dialog, then `prompt`'s project opens
+/// once the file is saved. The dialog's answer runs when it arrives (the host may answer in a later
+/// frame). A canceled dialog or a failed save shows the prompt again. An answer that arrives after
+/// another project was opened, created or closed to does nothing, and one that arrives while another
+/// prompt is shown saves but leaves the opening to that prompt. Returns an error when no dialog can
+/// be shown.
+fn save_then_open(app: &mut FilmcraftApp, prompt: ClipDialogDraft) -> Result<Value, String> {
+    let name = format!("{}.fcproj", app.session.project.name);
+    // the project the dialog saves (not one opened, created or closed to meanwhile)
+    let project = app.session.document_epoch;
+    app.pick(crate::native_dialogs::FileDialog::SaveProject { name }, move |app, paths| {
+        if app.session.document_epoch != project {
+            return Ok(Value::Null);
+        }
+        let Some(path) = paths.into_iter().next() else {
+            if app.ui.clip_dialog.is_none() {
+                app.ui.clip_dialog = Some(prompt);
+            }
+            return Ok(Value::Null);
+        };
+        if let Err(e) = app.session.execute("file.saveAs", json!({"path": path})) {
+            app.ui.status = e.to_string();
+            if app.ui.clip_dialog.is_none() {
+                app.ui.clip_dialog = Some(prompt);
+            }
+            return Ok(Value::Null);
+        }
+        if app.ui.clip_dialog.is_none() {
+            match app.session.execute("file.open", prompt.params.clone()) {
+                Ok(_) => edit_after_open(app, &prompt),
+                Err(e) => app.ui.status = e.to_string(),
+            }
+        }
+        Ok(Value::Null)
+    })
+}
+
+/// A Recent row on Home asked (`editAfterOpen`): the project it chose is open, so Home switches to
+/// Edit, as it does when no prompt was needed.
+fn edit_after_open(app: &mut FilmcraftApp, prompt: &ClipDialogDraft) {
+    if prompt.info["editAfterOpen"].as_bool() == Some(true) {
+        app.ui.mode = crate::state::Mode::Edit;
+    }
+}
+
 /// The parameters sent on OK (dialog-only fields dropped or converted).
 fn finish(d: &ClipDialogDraft) -> Value {
     let mut p = d.params.clone();
diff --git a/crates/ui-egui/src/panels/import_mode.rs b/crates/ui-egui/src/panels/import_mode.rs
--- a/crates/ui-egui/src/panels/import_mode.rs
+++ b/crates/ui-egui/src/panels/import_mode.rs
@@ -109,7 +109,13 @@ fn recent(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, y: f32) -> f32
     }
     if let Some(path) = open {
         let ctx = ui.ctx().clone();
-        if crate::menus::invoke(app, &ctx, "file.open", json!({"path": path})).is_ok() {
+        // unsaved changes: Save / Don't Save / Cancel first, asked over the project they were made in;
+        // Home switches to Edit once the chosen project opens, and stays after Cancel
+        if crate::panels::clip_dialogs::intercept_open(app, &path) {
+            if let Some(info) = app.ui.clip_dialog.as_mut().and_then(|d| d.info.as_object_mut()) {
+                info.insert("editAfterOpen".into(), json!(true));
+            }
+        } else if crate::menus::invoke(app, &ctx, "file.open", json!({"path": path})).is_ok() {
             app.ui.mode = crate::state::Mode::Edit;
         }
     }
diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs
--- a/crates/ui-egui/src/panels/mixer.rs
+++ b/crates/ui-egui/src/panels/mixer.rs
@@ -232,8 +232,11 @@ pub(crate) fn open_show_hide_tracks(app: &mut FilmcraftApp, ctx: &egui::Context)
 }
 
 /// Meter Input(s) Only (`mixer.meterInputOnly`, `{"on":bool?}`): sets the option, or toggles it
-/// without `on`.
+/// without `on`. Parameters that are neither an object nor null are an error.
 pub(crate) fn set_meter_input_only(app: &mut FilmcraftApp, params: &Value) -> Result<Value, String> {
+    if !params.is_object() && !params.is_null() {
+        return Err("command parameters must be an object".into());
+    }
     let on = match params.get("on") {
         None | Some(Value::Null) => !app.ui.mixer_meter_input_only,
         Some(v) => v.as_bool().ok_or("`on` must be true or false")?,
diff --git a/crates/ui-egui/tests/it/captions_font_ui.rs b/crates/ui-egui/tests/it/captions_font_ui.rs
--- a/crates/ui-egui/tests/it/captions_font_ui.rs
+++ b/crates/ui-egui/tests/it/captions_font_ui.rs
@@ -20,7 +20,9 @@ impl Driver {
         session.execute("file.openDemoProject", json!({})).expect("demo project");
         let (tx, rx) = channel();
         let app = FilmcraftApp::new(session).with_control(rx);
-        let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
+        // the font list names every installed family, and some names are in scripts the interface
+        // fonts do not cover (macOS lists "इनाईमाथी" for InaiMathi): draw those as boxes
+        let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).allow_missing_glyphs().build_eframe(move |_cc| app);
         let mut d = Driver { harness, tx };
         d.frames(4);
         d
diff --git a/crates/ui-egui/tests/it/keyboard_ui.rs b/crates/ui-egui/tests/it/keyboard_ui.rs
--- a/crates/ui-egui/tests/it/keyboard_ui.rs
+++ b/crates/ui-egui/tests/it/keyboard_ui.rs
@@ -306,6 +306,24 @@ fn audio_track_mixer_keys_open_show_hide_and_toggle_meter_input_only() {
     assert_eq!(d.app().ui.focused, filmcraft_ui_egui::dock::PanelKind::AudioTrackMixer);
 }
 
+/// `mixer.meterInputOnly` refuses parameters that are not an object and leaves the option as it
+/// was; null parameters toggle it.
+#[test]
+fn meter_input_only_refuses_parameters_that_are_not_an_object() {
+    let mut d = Driver::new();
+    assert!(!d.app().ui.mixer_meter_input_only);
+    for params in [json!(true), json!(1), json!("on"), json!([true])] {
+        let (req, reply) = ControlRequest::new("ui.menu.invoke", json!({"id": "mixer.meterInputOnly", "params": params}));
+        d.tx.send(req).unwrap();
+        d.frames(3);
+        let response = reply.try_recv().expect("the command is answered");
+        assert_eq!(response["ok"], false, "{params}: {response}");
+        assert!(!d.app().ui.mixer_meter_input_only, "{params} leaves Meter Input(s) Only off");
+    }
+    let r = d.ok("ui.menu.invoke", json!({"id": "mixer.meterInputOnly", "params": null}));
+    assert_eq!(r, json!({"meterInputOnly": true}));
+}
+
 /// Off macOS the menus show `Ctrl+…`, so that is what agents send: a `Ctrl` chord through
 /// `ui.key` (and `ctrl` in `ui.click` modifiers) must act like the physical key, which egui-winit
 /// reports as `ctrl` + `command`. Bindings are written `Cmd+…`, so this failed to match (#245).
diff --git a/crates/ui-egui/tests/open_project_prompt_ui.rs b/crates/ui-egui/tests/open_project_prompt_ui.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/tests/open_project_prompt_ui.rs
@@ -0,0 +1,439 @@
+//! Headless UI tests of File ▸ Open Project, File ▸ Open Recent and Home's Recent list with unsaved
+//! changes: once the project to open is chosen, the Save Project prompt asks Cancel / Don't Save /
+//! Save before it opens.
+
+use std::path::PathBuf;
+use std::sync::mpsc::{Sender, channel};
+use std::sync::{Arc, Mutex};
+
+use egui_kittest::Harness;
+use filmcraft_engine::Session;
+use filmcraft_engine::project::{ClipId, Label};
+use filmcraft_ui_egui::FilmcraftApp;
+use filmcraft_ui_egui::control::ControlRequest;
+use filmcraft_ui_egui::native_dialogs::FileDialog;
+use filmcraft_ui_egui::state::Mode;
+use serde_json::{Value, json};
+
+struct Driver {
+    harness: Harness<'static, FilmcraftApp>,
+    tx: Sender<ControlRequest>,
+}
+
+impl Driver {
+    fn new() -> Self {
+        let mut s = Session::default();
+        s.execute("file.openDemoProject", json!({})).unwrap();
+        let (tx, rx) = channel();
+        let app = FilmcraftApp::new(s).with_control(rx);
+        let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
+        let mut d = Driver { harness, tx };
+        d.frames(4);
+        d
+    }
+
+    fn frames(&mut self, n: usize) {
+        for _ in 0..n {
+            // the control channel's clicks and keys enter through the input hook
+            let ctx = self.harness.ctx.clone();
+            let mut raw = std::mem::take(self.harness.input_mut());
+            eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
+            *self.harness.input_mut() = raw;
+            self.harness.step();
+        }
+    }
+
+    fn call(&mut self, method: &str, params: Value) -> Value {
+        let (req, reply) = ControlRequest::new(method, params.clone());
+        self.tx.send(req).unwrap();
+        for _ in 0..600 {
+            self.frames(1);
+            if let Ok(v) = reply.try_recv() {
+                return v;
+            }
+        }
+        panic!("no reply to {method} {params}");
+    }
+
+    fn ok(&mut self, method: &str, params: Value) -> Value {
+        let v = self.call(method, params.clone());
+        assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
+        v["result"].clone()
+    }
+
+    fn exec(&mut self, command: &str, params: Value) -> Value {
+        self.ok("engine.execute", json!({"command": command, "params": params}))
+    }
+
+    fn menu(&mut self, id: &str) -> Value {
+        let r = self.ok("ui.menu.invoke", json!({"id": id}));
+        self.frames(3);
+        r
+    }
+
+    fn click(&mut self, id: &str) {
+        self.ok("ui.click", json!({"id": id}));
+        self.frames(3);
+    }
+
+    fn has(&mut self, id: &str) -> bool {
+        let v = self.ok("ui.elements", json!({"prefix": id}));
+        v.as_array().unwrap().iter().any(|e| e["id"] == json!(id))
+    }
+
+    fn app(&mut self) -> &mut FilmcraftApp {
+        self.harness.state_mut()
+    }
+
+    fn path(&mut self) -> Option<String> {
+        self.app().session.path.clone()
+    }
+
+    /// Make an unsaved change: the first clip turns yellow. Returns the clip.
+    fn edit(&mut self) -> ClipId {
+        let c = self.app().session.active_sequence().unwrap().video_tracks[0].items[0].id;
+        self.exec("timeline.select", json!({"clips": [c.0]}));
+        self.menu("edit.label.yellow");
+        assert!(self.app().session.is_dirty());
+        c
+    }
+
+    /// The label of `clip` in the open project.
+    fn label(&mut self, clip: ClipId) -> Label {
+        let p = &self.app().session.project;
+        p.sequences().filter_map(|i| p.sequence(i.id)).find_map(|q| q.find_item(clip).map(|(_, it)| it.label)).unwrap()
+    }
+
+    /// File ▸ Open Project… with the Open dialog answering `path`.
+    fn open_project(&mut self, path: &str) -> Value {
+        let path = path.to_string();
+        self.app().hooks.pick_open_project = Some(Box::new(move || Some(path.clone())));
+        self.menu("file.open")
+    }
+
+    /// Where `path` is in the recent projects (File ▸ Open Recent, Home's Recent list).
+    fn recent(&mut self, path: &str) -> usize {
+        self.app().session.prefs.general.recent_projects.iter().position(|p| p == path).unwrap()
+    }
+}
+
+/// A folder for one test holding the demo project saved as `current.fcproj` and `other.fcproj`.
+/// The session is left on `current.fcproj` with nothing unsaved.
+fn projects(d: &mut Driver, test: &str) -> (PathBuf, String, String) {
+    let dir = std::env::temp_dir().join(format!("fc-open-project-prompt-{}-{test}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::create_dir_all(&dir).unwrap();
+    let current = dir.join("current.fcproj").to_string_lossy().into_owned();
+    let other = dir.join("other.fcproj").to_string_lossy().into_owned();
+    d.exec("file.saveAs", json!({"path": other}));
+    d.exec("file.saveAs", json!({"path": current}));
+    assert!(!d.app().session.is_dirty());
+    (dir, current, other)
+}
+
+#[test]
+fn open_project_with_unsaved_changes_asks_first_and_cancel_keeps_the_project() {
+    let mut d = Driver::new();
+    let (dir, current, other) = projects(&mut d, "cancel");
+    let clip = d.edit();
+    let r = d.open_project(&other);
+    assert_eq!(d.path(), Some(current.clone()), "nothing opens before an answer");
+    assert_eq!(r["dialog"], "openProject");
+    assert!(d.has("openProject.save") && d.has("openProject.dontSave") && d.has("openProject.cancel"));
+    d.click("openProject.cancel");
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!(d.path(), Some(current));
+    assert!(d.app().session.is_dirty());
+    assert_eq!(d.label(clip), Label::Yellow, "the change is kept");
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn open_project_save_saves_the_changes_then_opens_the_chosen_project() {
+    let mut d = Driver::new();
+    let (dir, current, other) = projects(&mut d, "save");
+    let clip = d.edit();
+    d.open_project(&other);
+    d.click("openProject.save");
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!(d.path(), Some(other));
+    assert!(!d.app().session.is_dirty());
+    // the change was written to the project it was made in
+    d.exec("file.open", json!({"path": current}));
+    assert_eq!(d.label(clip), Label::Yellow);
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn open_project_dont_save_opens_the_chosen_project_without_saving() {
+    let mut d = Driver::new();
+    let (dir, current, other) = projects(&mut d, "dontSave");
+    let first = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].clone();
+    assert_ne!(first.label, Label::Yellow);
+    let clip = d.edit();
+    // a project that can't be opened leaves the current one open with its changes
+    let missing = dir.join("missing.fcproj").to_string_lossy().into_owned();
+    d.open_project(&missing);
+    d.click("openProject.dontSave");
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert!(d.app().ui.status.contains("missing.fcproj"), "{}", d.app().ui.status);
+    assert_eq!(d.path(), Some(current.clone()));
+    assert!(d.app().session.is_dirty());
+    assert_eq!(d.label(clip), Label::Yellow);
+    d.open_project(&other);
+    d.click("openProject.dontSave");
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!(d.path(), Some(other));
+    assert!(!d.app().session.is_dirty());
+    // the project the change was made in is as it was saved
+    d.exec("file.open", json!({"path": current}));
+    assert_eq!(d.label(clip), first.label);
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn open_project_asks_only_with_unsaved_changes_and_a_scripted_open_does_not_ask() {
+    let mut d = Driver::new();
+    let (dir, current, other) = projects(&mut d, "clean");
+    let r = d.open_project(&other);
+    assert!(d.app().ui.clip_dialog.is_none(), "nothing unsaved: no prompt");
+    assert_eq!(r["path"], json!(other));
+    assert_eq!(d.path(), Some(other.clone()));
+    d.edit();
+    assert_eq!(d.open_project(&current)["dialog"], "openProject");
+    assert_eq!(d.path(), Some(other));
+    d.click("openProject.cancel");
+    // `file.open` with a path (agents, MCP) opens without asking
+    d.exec("file.open", json!({"path": current}));
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!(d.path(), Some(current));
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn open_recent_with_unsaved_changes_asks_first() {
+    let mut d = Driver::new();
+    let (dir, current, other) = projects(&mut d, "openRecent");
+    let clip = d.edit();
+    let i = d.recent(&other);
+    let r = d.menu(&format!("file.openRecent.{i}"));
+    assert_eq!(r["dialog"], "openProject");
+    assert_eq!(d.path(), Some(current.clone()), "nothing opens before an answer");
+    d.click("openProject.cancel");
+    assert_eq!(d.path(), Some(current.clone()));
+    assert_eq!(d.label(clip), Label::Yellow, "the change is kept");
+    let i = d.recent(&other);
+    d.menu(&format!("file.openRecent.{i}"));
+    d.click("openProject.save");
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!(d.path(), Some(other));
+    assert!(!d.app().session.is_dirty());
+    // the change was written to the project it was made in
+    d.exec("file.open", json!({"path": current}));
+    assert_eq!(d.label(clip), Label::Yellow);
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn home_recent_with_unsaved_changes_asks_first() {
+    let mut d = Driver::new();
+    let (dir, current, other) = projects(&mut d, "homeRecent");
+    let first = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].clone();
+    let clip = d.edit();
+    d.click("header.home");
+    let i = d.recent(&other);
+    d.click(&format!("import.recent.{i}"));
+    assert_eq!(d.path(), Some(current.clone()), "nothing opens before an answer");
+    assert_eq!(d.app().ui.mode, Mode::Import, "Home stays while it asks");
+    // Cancel: Home stays, with this project and its changes
+    d.click("openProject.cancel");
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!((d.path(), d.app().ui.mode), (Some(current.clone()), Mode::Import));
+    assert!(d.app().session.is_dirty());
+    d.click(&format!("import.recent.{i}"));
+    assert!(d.has("openProject.dontSave"));
+    d.click("openProject.dontSave");
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!(d.path(), Some(other));
+    assert_eq!(d.app().ui.mode, Mode::Edit);
+    assert!(!d.app().session.is_dirty());
+    // the project the change was made in is as it was saved
+    d.exec("file.open", json!({"path": current}));
+    assert_eq!(d.label(clip), first.label);
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn home_recent_save_on_a_never_saved_project_switches_to_edit_once_it_opens() {
+    let mut d = Driver::new();
+    let (dir, _, other) = projects(&mut d, "homeNeverSaved");
+    d.app().session.path = None;
+    d.edit();
+    let saved = dir.join("saved.fcproj").to_string_lossy().into_owned();
+    d.app().hooks.pick_save = Some(Box::new(move |_| Some(saved.clone())));
+    d.click("header.home");
+    let i = d.recent(&other);
+    d.click(&format!("import.recent.{i}"));
+    assert_eq!(d.app().ui.mode, Mode::Import);
+    d.click("openProject.save");
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!(d.path(), Some(other));
+    assert_eq!(d.app().ui.mode, Mode::Edit);
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn open_project_save_on_a_never_saved_project_uses_the_save_dialog() {
+    let mut d = Driver::new();
+    let (dir, _, other) = projects(&mut d, "neverSaved");
+    d.app().session.path = None;
+    let clip = d.edit();
+    let saved = dir.join("saved.fcproj").to_string_lossy().into_owned();
+    let mut answers = vec![None, Some(saved.clone())];
+    d.app().hooks.pick_save = Some(Box::new(move |_| if answers.is_empty() { None } else { answers.remove(0) }));
+    d.open_project(&other);
+    // the Save dialog is canceled: back to the prompt, nothing opens
+    d.click("openProject.save");
+    assert!(d.app().ui.clip_dialog.is_some());
+    assert_eq!(d.path(), None);
+    assert!(d.app().session.is_dirty());
+    // saved to the chosen file, then the chosen project opens
+    d.click("openProject.save");
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!(d.path(), Some(other));
+    d.exec("file.open", json!({"path": saved}));
+    assert_eq!(d.label(clip), Label::Yellow);
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn open_project_asks_when_the_open_dialog_answers_in_a_later_frame() {
+    let mut d = Driver::new();
+    let (dir, current, other) = projects(&mut d, "runner");
+    d.edit();
+    // a host that shows file dialogs without blocking (the desktop app on Linux)
+    let answer: Arc<Mutex<Option<Sender<Vec<String>>>>> = Arc::default();
+    let a = answer.clone();
+    d.app().hooks.run_dialog = Some(Box::new(move |_| {
+        let (tx, rx) = channel();
+        *a.lock().unwrap() = Some(tx);
+        rx
+    }));
+    assert_eq!(d.menu("file.open"), json!({"dialog": "open"}));
+    answer.lock().unwrap().take().unwrap().send(vec![other.clone()]).unwrap();
+    d.frames(3);
+    assert!(d.has("openProject.dontSave"));
+    assert_eq!(d.path(), Some(current));
+    d.click("openProject.dontSave");
+    assert_eq!(d.path(), Some(other));
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn open_project_save_waits_for_a_save_dialog_answered_later() {
+    let mut d = Driver::new();
+    let (dir, _, other) = projects(&mut d, "saveLater");
+    d.app().session.path = None;
+    let clip = d.edit();
+    let saved = dir.join("saved.fcproj").to_string_lossy().into_owned();
+    // a host that shows file dialogs without blocking (the desktop app on Linux), answered by hand
+    let asked: Arc<Mutex<Vec<(FileDialog, Sender<Vec<String>>)>>> = Arc::default();
+    let a = asked.clone();
+    d.app().hooks.run_dialog = Some(Box::new(move |dialog| {
+        let (tx, rx) = channel();
+        a.lock().unwrap().push((dialog, tx));
+        rx
+    }));
+    let answer = |d: &mut Driver, paths: Vec<String>| {
+        let (dialog, tx) = asked.lock().unwrap().remove(0);
+        tx.send(paths).unwrap();
+        d.frames(3);
+        dialog
+    };
+    d.menu("file.open");
+    assert!(matches!(answer(&mut d, vec![other.clone()]), FileDialog::OpenProject));
+    // the Save dialog is canceled: back to the prompt, nothing opens
+    d.click("openProject.save");
+    assert!(matches!(answer(&mut d, Vec::new()), FileDialog::SaveProject { .. }));
+    assert!(d.has("openProject.save"));
+    assert_eq!(d.path(), None);
+    // the save fails: the prompt stays, with the reason in the status line
+    let blocker = dir.join("not-a-folder");
+    std::fs::write(&blocker, b"").unwrap();
+    d.click("openProject.save");
+    answer(&mut d, vec![blocker.join("saved.fcproj").to_string_lossy().into_owned()]);
+    assert!(d.has("openProject.save"));
+    assert!(!d.app().ui.status.is_empty());
+    assert_eq!(d.path(), None);
+    assert!(d.app().session.is_dirty());
+    // the file is picked a few frames later: saved, then the chosen project opens
+    d.click("openProject.save");
+    d.frames(3);
+    assert_eq!(d.path(), None, "nothing opens before the answer");
+    answer(&mut d, vec![saved.clone()]);
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!(d.path(), Some(other.clone()));
+    assert!(!d.app().session.is_dirty());
+    d.exec("file.open", json!({"path": saved}));
+    assert_eq!(d.label(clip), Label::Yellow, "the change was saved");
+    // another project opened while the Save dialog is open: the late answer saves and opens nothing
+    d.app().session.path = None;
+    d.exec("file.newBin", json!({"name": "Late"}));
+    assert!(d.app().session.is_dirty());
+    let late = dir.join("late.fcproj").to_string_lossy().into_owned();
+    d.menu("file.open");
+    answer(&mut d, vec![other.clone()]);
+    d.click("openProject.save");
+    d.exec("file.open", json!({"path": saved}));
+    answer(&mut d, vec![late.clone()]);
+    assert!(!std::path::Path::new(&late).exists());
+    assert_eq!(d.path(), Some(saved));
+    assert!(d.app().ui.clip_dialog.is_none());
+    // the same for a new project changed while the Save dialog is open
+    d.app().session.path = None;
+    d.exec("file.newBin", json!({"name": "Late"}));
+    d.menu("file.open");
+    answer(&mut d, vec![other]);
+    d.click("openProject.save");
+    d.exec("file.newProject", json!({"name": "New"}));
+    d.exec("file.newBin", json!({"name": "Changed"}));
+    answer(&mut d, vec![late.clone()]);
+    assert!(!std::path::Path::new(&late).exists());
+    assert_eq!(d.path(), None);
+    assert!(d.app().session.is_dirty());
+    assert!(d.app().ui.clip_dialog.is_none());
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn a_save_prompt_closes_when_a_script_replaces_its_project() {
+    let mut d = Driver::new();
+    let (dir, current, other) = projects(&mut d, "replaced");
+    // Open Project
+    d.edit();
+    d.open_project(&other);
+    d.exec("file.open", json!({"path": other}));
+    d.exec("file.newBin", json!({"name": "Changed"}));
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert!(!d.has("openProject.dontSave"));
+    assert_eq!(d.path(), Some(other.clone()));
+    assert!(d.app().session.is_dirty(), "the other project's change is kept");
+    // Close Project
+    d.menu("file.closeProject");
+    assert!(d.has("closeProject.dontSave"));
+    d.exec("file.open", json!({"path": current}));
+    d.exec("file.newBin", json!({"name": "Changed"}));
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert_eq!(d.path(), Some(current));
+    assert!(d.app().session.is_dirty());
+    // Quit
+    d.harness.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
+    d.frames(3);
+    assert!(d.has("quit.dontSave"));
+    d.exec("file.newProject", json!({"name": "New"}));
+    d.exec("file.newBin", json!({"name": "Changed"}));
+    assert!(d.app().ui.clip_dialog.is_none());
+    assert!(!d.app().quit_confirmed);
+    assert!(d.app().session.is_dirty());
+    let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/docs/project-files.md b/docs/project-files.md
--- a/docs/project-files.md
+++ b/docs/project-files.md
@@ -109,6 +109,10 @@ one. If a step fails, the temp file is removed and the old file is untouched.
 | `file.open {path}` | File ▸ Open Project… (⌘O) | returns `{schemaVersion, migrated}` |
 
 In the UI, Save / Save As / Save a Copy without a `path` show a file dialog.
+Open Project… shows one too. In the desktop app, when the current project has unsaved changes, it
+then asks *"Save changes to 'X' before closing?"* with **Cancel**, **Don't Save** and **Save**, and
+the chosen project opens after Save or Don't Save. File ▸ Open Recent and the Recent list on Home
+ask the same way. `file.open` with a `path` opens the project without asking.
 
 ## Auto Save
 
