diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs
--- a/apps/soundcraft/src/main.rs
+++ b/apps/soundcraft/src/main.rs
@@ -79,7 +79,7 @@ impl eframe::App for App {
                 "{}{} — SoundCraft — {}",
                 self.0.engine.session().name,
                 if self.0.engine.is_dirty() { " *" } else { "" },
-                if self.0.ui.window == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" }
+                if self.0.main_window_shows() == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" }
             )
         } else {
             format!("{} — SoundCraft", self.0.engine.session().name)
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
@@ -80,6 +80,7 @@ pub struct UiState {
     /// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color.
     pub edit_views: Vec<String>,
     /// How strongly Edit window tracks take on their colour, 0..1 (0: only the colour strip).
+    #[serde(deserialize_with = "saved::track_tint")]
     pub track_tint: f32,
     /// Open plugin windows (track, slot).
     pub plugin_windows: Vec<(TrackId, usize)>,
@@ -104,6 +105,12 @@ pub struct UiState {
     /// The Mix window lives in its own native window (Window › Mix in Separate Window); the main
     /// window then always shows Edit.
     pub mix_separate: bool,
+    /// The separate Mix window's outer position and inner size (logical pixels) as last seen; it
+    /// reopens there. No position: the system places it.
+    #[serde(deserialize_with = "saved::mix_window_pos")]
+    pub mix_window_pos: Option<[f32; 2]>,
+    #[serde(deserialize_with = "saved::mix_window_size")]
+    pub mix_window_size: [f32; 2],
     pub show_io_setup: bool,
     pub show_shortcuts: bool,
     pub show_clip_effects: bool,
@@ -124,11 +131,51 @@ pub struct UiState {
     pub theme: theme::ThemeMode,
     /// The main window's size (logical points) and maximized state, and the interface scale, as
     /// the desktop app last saw them; it restores them at launch.
+    #[serde(deserialize_with = "saved::window_size")]
     pub window_size: [f32; 2],
     pub window_maximized: bool,
+    #[serde(deserialize_with = "saved::ui_scale")]
     pub ui_scale: f32,
 }
 
+/// Reading saved sizes and scales. JSON reads a number too big for an `f32` (1e39) as infinity,
+/// which serde_json writes back as `null`, and the whole preferences file would no longer load:
+/// such values, and ones out of range, are replaced as they are read (preferences and `ui.set`).
+mod saved {
+    use serde::{Deserialize, Deserializer};
+
+    /// `v` as an `f32`, if it is a finite one.
+    fn finite(v: f64) -> Option<f32> {
+        (v.is_finite() && v.abs() <= f64::from(f32::MAX)).then_some(v as f32)
+    }
+
+    fn pair([x, y]: [f64; 2]) -> Option<[f32; 2]> {
+        Some([finite(x)?, finite(y)?])
+    }
+
+    pub fn track_tint<'de, D: Deserializer<'de>>(d: D) -> Result<f32, D::Error> {
+        Ok(finite(f64::deserialize(d)?).map_or(0.5, |v| v.clamp(0.0, 1.0)))
+    }
+
+    pub fn ui_scale<'de, D: Deserializer<'de>>(d: D) -> Result<f32, D::Error> {
+        Ok(finite(f64::deserialize(d)?).map_or(1.0, |v| v.clamp(0.75, 1.5)))
+    }
+
+    pub fn window_size<'de, D: Deserializer<'de>>(d: D) -> Result<[f32; 2], D::Error> {
+        Ok(pair(<[f64; 2]>::deserialize(d)?).unwrap_or([1600.0, 1000.0]))
+    }
+
+    pub fn mix_window_pos<'de, D: Deserializer<'de>>(d: D) -> Result<Option<[f32; 2]>, D::Error> {
+        let pos = Option::<[f64; 2]>::deserialize(d)?.and_then(pair);
+        Ok(crate::mix_window::sane_placement(pos, crate::mix_window::DEFAULT_SIZE).0)
+    }
+
+    pub fn mix_window_size<'de, D: Deserializer<'de>>(d: D) -> Result<[f32; 2], D::Error> {
+        let size = pair(<[f64; 2]>::deserialize(d)?).unwrap_or(crate::mix_window::DEFAULT_SIZE);
+        Ok(crate::mix_window::sane_placement(None, size).1)
+    }
+}
+
 impl Default for UiState {
     fn default() -> Self {
         UiState {
@@ -168,6 +215,8 @@ impl Default for UiState {
             show_playback_engine: false,
             audio: soundcraft_playback::AudioConfig::default(),
             mix_separate: false,
+            mix_window_pos: None,
+            mix_window_size: mix_window::DEFAULT_SIZE,
             show_io_setup: false,
             show_shortcuts: false,
             show_clip_effects: false,
@@ -297,6 +346,12 @@ pub struct SoundApp {
     pub palette: palette::PaletteState,
     /// Bring the separate Mix window to the front on the next frame.
     pub(crate) focus_mix: bool,
+    /// Bring the main (Edit) window to the front on the next frame.
+    pub(crate) focus_edit: bool,
+    /// The separate Mix window has keyboard focus (as of its last frame).
+    pub(crate) mix_focused: bool,
+    /// Where the separate Mix window opened, while it is open (see `mix_window::show_separate`).
+    pub(crate) mix_placement: Option<mix_window::Placement>,
     /// Scroll Into View: the track each window brings to its top (Edit) or left (Mix) when it
     /// next draws, so a window not on screen catches up when it is shown.
     pub(crate) edit_scroll_into_view: Option<TrackId>,
@@ -414,6 +469,9 @@ impl SoundApp {
             extra: extra_windows::ExtraState::default(),
             palette: palette::PaletteState::default(),
             focus_mix: false,
+            focus_edit: false,
+            mix_focused: false,
+            mix_placement: None,
             edit_scroll_into_view: None,
             mix_scroll_into_view: None,
             synthetic: Vec::new(),
@@ -1322,18 +1380,26 @@ impl SoundApp {
             self.arrange_request = false;
             ctx.memory_mut(|m| m.reset_areas());
         }
+        // The main window has the keys, so the Mix window no longer does (it learns that only on its next pass).
+        if ctx.input(|i| i.focused) {
+            self.mix_focused = false;
+        }
         shortcuts::handle(self, &ctx);
         self.sample_drop_targets.clear();
         if !self.native_menu_bar {
             menus::menu_bar(self, ui);
         }
-        // With the Mix window in its own native window, the main window keeps Edit.
-        match if self.ui.mix_separate { MainWindow::Edit } else { self.ui.window } {
+        match self.main_window_shows() {
             MainWindow::Edit => edit_window::show(self, ui),
             MainWindow::Mix => mix_window::show(self, ui),
         }
         if self.ui.mix_separate {
             mix_window::show_separate(self, &ctx);
+        } else {
+            mix_window::separate_hidden(self);
+        }
+        if std::mem::take(&mut self.focus_edit) {
+            ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
         }
         panels::floating(self, &ctx);
         windows::show(self, &ctx);
@@ -1355,6 +1421,11 @@ impl SoundApp {
         }
     }
 
+    /// What the main window shows: with the Mix window in its own native window, it keeps Edit.
+    pub fn main_window_shows(&self) -> MainWindow {
+        if self.ui.mix_separate { MainWindow::Edit } else { self.ui.window }
+    }
+
     /// UI state as JSON (control channel `ui.inspect`).
     pub fn inspect(&self, ctx: &egui::Context) -> Value {
         let size = ctx.content_rect().size();
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
@@ -12,7 +12,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
     ("window.mix", "Mix", "Window > Mix", Some("Cmd+=")),
     ("window.edit", "Edit", "Window > Edit", Some("Cmd+=")),
     ("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")),
-    ("window.mix_separate", "Mix in Separate Window", "", None),
+    ("window.mix_separate", "Mix in Separate Window", "Window > Mix in Separate Window", None),
     ("window.transport", "Transport", "Window > Transport", Some("Cmd+1")),
     ("window.big_counter", "Big Counter", "Window > Big Counter", Some("Cmd+3")),
     ("window.memory_locations", "Memory Locations", "Window > Memory Locations", Some("Cmd+5")),
@@ -203,6 +203,15 @@ pub fn tree() -> Vec<MenuNode> {
         insert_path(&mut roots, line);
     }
     insert_path(&mut roots, "Window > Reset Panel Layout");
+    // Not in the observed menu catalog; it belongs with Mix and Edit.
+    insert_path(&mut roots, "Window > Mix in Separate Window");
+    if let Some(window) = roots.iter_mut().find(|n| n.label == "Window")
+        && let Some(from) = window.children.iter().position(|n| n.path == "Window > Mix in Separate Window")
+        && let Some(edit) = window.children.iter().position(|n| n.path == "Window > Edit")
+    {
+        let item = window.children.remove(from);
+        window.children.insert((edit + 1).min(window.children.len()), item);
+    }
     // Not in the observed catalog either; Pro Tools lists it right under Identify Sync Point.
     if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
         let at = clip.children.iter().position(|n| n.label == "Identify Sync Point").map_or(clip.children.len(), |i| i + 1);
@@ -257,18 +266,6 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
                             ui.separator();
                             ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
                         }
-                        if root.label == "Window" {
-                            ui.separator();
-                            let mut separate = app.ui.mix_separate;
-                            if ui
-                                .checkbox(&mut separate, "Mix in Separate Window")
-                                .on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
-                                .changed()
-                            {
-                                let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
-                                ui.close();
-                            }
-                        }
                     });
                 }
                 let title = format!(
@@ -317,7 +314,11 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
     let label = if checked { format!("✔ {label}") } else { label };
     let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or("");
     let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac()));
-    if ui.add_enabled(enabled, btn).clicked()
+    let mut response = ui.add_enabled(enabled, btn);
+    if id.as_deref() == Some("window.mix_separate") {
+        response = response.on_hover_text("Show Edit and Mix at once, e.g. one per monitor");
+    }
+    if response.clicked()
         && let Some(id) = id
     {
         invoke_menu(app, &id, &n.path);
@@ -364,6 +365,7 @@ fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool {
         Some("view.main_counter") => path.rsplit(" > ").next().map(ruler_id).is_some_and(|r| e.main_counter.id() == r),
         Some("window.mix") => app.ui.window == MainWindow::Mix,
         Some("window.edit") => app.ui.window == MainWindow::Edit,
+        Some("window.mix_separate") => app.ui.mix_separate,
         Some("window.track_list") => app.ui.show_tracks_list,
         Some("window.clip_list_view") | Some("window.clip_list") => app.ui.show_clip_list,
         Some("window.narrow_mix") => app.ui.narrow_mix,
@@ -529,16 +531,19 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
         }
         "window.edit" => {
             app.ui.window = MainWindow::Edit;
+            app.focus_edit = app.ui.mix_separate;
             json!({"window": "edit"})
         }
         "window.toggle_mix_edit" => {
             if app.ui.mix_separate {
-                // Both are on screen: bring the Mix window forward instead.
-                app.focus_mix = true;
+                // Both are on screen: bring the other one forward instead (Edit from the Mix window).
+                app.focus_edit = app.mix_focused;
+                app.focus_mix = !app.mix_focused;
+                json!({"window": if app.mix_focused { MainWindow::Edit } else { MainWindow::Mix }})
             } else {
                 app.ui.window = if app.ui.window == MainWindow::Edit { MainWindow::Mix } else { MainWindow::Edit };
+                json!({"window": app.ui.window})
             }
-            json!({"window": app.ui.window})
         }
         "window.transport" => toggle(&mut app.ui.show_transport),
         "window.big_counter" => toggle(&mut app.ui.show_big_counter),
diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs
--- a/crates/ui-egui/src/mix_window.rs
+++ b/crates/ui-egui/src/mix_window.rs
@@ -19,28 +19,122 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
     crate::docking::show(app, ui, crate::MainWindow::Mix);
 }
 
+/// The separate Mix window's size when nothing sensible was saved (logical pixels).
+pub const DEFAULT_SIZE: [f32; 2] = [1100.0, 720.0];
+const MIN_SIZE: [f32; 2] = [480.0, 320.0];
+
+/// The separate Mix window's viewport.
+pub fn viewport_id() -> egui::ViewportId {
+    egui::ViewportId::from_hash_of("soundcraft.mix")
+}
+
+/// Where the separate Mix window opened (points). It is handed to egui unchanged on every frame:
+/// a builder that changed would move the window back to it.
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct Placement {
+    pos: Option<egui::Pos2>,
+    size: egui::Vec2,
+    min_size: egui::Vec2,
+    /// Frames drawn in the native window since it opened.
+    passes: u32,
+}
+
+/// A saved Mix window position and size, or the defaults where the preferences hold nonsense
+/// (NaN, negative or tiny sizes, positions far off any desktop such as Windows' -32000 for a
+/// minimized window). Logical pixels.
+pub fn sane_placement(pos: Option<[f32; 2]>, [w, h]: [f32; 2]) -> (Option<[f32; 2]>, [f32; 2]) {
+    let size = if w.is_finite() && h.is_finite() && w >= MIN_SIZE[0] && h >= MIN_SIZE[1] { [w.min(16_384.0), h.min(16_384.0)] } else { DEFAULT_SIZE };
+    (pos.filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 16_384.0)), size)
+}
+
 /// The Mix window in its own native window (Window › Mix in Separate Window), so Edit and Mix can
 /// be seen at once, e.g. one per monitor. Closing that window brings Mix back into the main one.
-/// Backends without multiple windows show it as a floating egui window instead.
+/// It reopens where it was last left. Keyboard shortcuts typed into it are handled here, with the
+/// Mix window as their context. Backends without multiple windows show it as a floating egui
+/// window instead, whose keys the main window already handles.
 pub fn show_separate(app: &mut SoundApp, ctx: &egui::Context) {
     let title = format!("{}  —  Mix", app.engine.session().name);
-    let builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([1100.0, 720.0]).with_min_inner_size([480.0, 320.0]);
-    ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("soundcraft.mix"), builder, |ui, _class| {
+    let zoom = ctx.zoom_factor();
+    let placement = *app.mix_placement.get_or_insert_with(|| {
+        let (pos, [w, h]) = sane_placement(app.ui.mix_window_pos, app.ui.mix_window_size);
+        let min_size = egui::vec2(MIN_SIZE[0], MIN_SIZE[1]) / zoom;
+        Placement { pos: pos.map(|[x, y]| egui::pos2(x, y) / zoom), size: egui::vec2(w, h) / zoom, min_size, passes: 0 }
+    });
+    let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size(placement.size).with_min_inner_size(placement.min_size);
+    if let Some(pos) = placement.pos {
+        builder = builder.with_position(pos);
+    }
+    let main_rect = ctx.input(|i| i.viewport().outer_rect);
+    ctx.show_viewport_immediate(viewport_id(), builder, |ui, class| {
         ui.set_style(ui.ctx().global_style());
+        let ctx = ui.ctx().clone();
+        if class == egui::ViewportClass::Immediate {
+            app.mix_focused = ctx.input(|i| i.focused);
+            track_placement(app, &ctx, main_rect);
+            crate::shortcuts::handle_in(app, &ctx, crate::MainWindow::Mix);
+        } else {
+            app.mix_focused = false;
+        }
         if std::mem::take(&mut app.focus_mix) {
-            ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus);
+            ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
         }
         show(app, ui);
-        if ui.ctx().input(|i| i.viewport().close_requested()) {
+        if ctx.input(|i| i.viewport().close_requested()) {
             separate_closed(app);
         }
     });
 }
 
+/// Remembers the native Mix window's position and size for the preferences, and on its second
+/// frame (when the system has placed it) fixes a placement that no longer fits: a window on no
+/// monitor (a saved position on a display that is gone) moves next to the main window, and one
+/// bigger than its monitor is maximized.
+fn track_placement(app: &mut SoundApp, ctx: &egui::Context, main_rect: Option<egui::Rect>) {
+    let Some(placement) = app.mix_placement.as_mut() else { return };
+    placement.passes = placement.passes.saturating_add(1);
+    let (outer, inner, monitor, minimized, maximized, fullscreen) = ctx.input(|i| {
+        let v = i.viewport();
+        (v.outer_rect, v.inner_rect, v.monitor_size, v.minimized.unwrap_or(false), v.maximized.unwrap_or(false), v.fullscreen.unwrap_or(false))
+    });
+    if placement.passes == 2 {
+        if outer.is_some() && monitor.is_none() {
+            let to = main_rect.map_or(egui::pos2(48.0, 48.0), |r| r.min + egui::vec2(48.0, 48.0));
+            ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(to));
+            return;
+        }
+        if inner.zip(monitor).is_some_and(|(r, m)| (r.size() - m).max_elem() > 0.0) {
+            ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
+            return;
+        }
+    }
+    // A minimized, maximized or full-screen window keeps the placement it had before.
+    if minimized || maximized || fullscreen {
+        return;
+    }
+    // Logical pixels (points × zoom). Restored, they are read at the scale the new window starts
+    // with: with monitors at different scales (Windows, X11) a window saved on the other monitor
+    // reopens somewhat offset.
+    let zoom = ctx.zoom_factor();
+    if let Some(r) = outer {
+        app.ui.mix_window_pos = Some((r.min.to_vec2() * zoom).round().into());
+    }
+    if let Some(r) = inner {
+        app.ui.mix_window_size = (r.size() * zoom).round().into();
+    }
+}
+
 /// The separate Mix window was closed: Mix goes back into the main window.
 pub fn separate_closed(app: &mut SoundApp) {
     app.ui.mix_separate = false;
     app.ui.window = crate::MainWindow::Mix;
+    separate_hidden(app);
+}
+
+/// The separate Mix window is not on screen: it no longer has the keys, and opens afresh from the
+/// saved placement next time.
+pub(crate) fn separate_hidden(app: &mut SoundApp) {
+    app.mix_focused = false;
+    app.mix_placement = None;
 }
 
 pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -54,7 +54,17 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
     mac && m.command && m.ctrl
 }
 
+/// The main window's keys (see [`handle_in`]).
 pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
+    let shown = app.main_window_shows();
+    handle_in(app, ctx, shown);
+}
+
+/// Shortcuts typed into the window whose pass `ctx` is in, which shows `window`. Each native
+/// window gets only its own key presses, so a press is handled once, by the window that has the
+/// keyboard focus; Edit-only keys (nudge, Grid/Nudge value, Commands Keyboard Focus) act only
+/// there when that window shows Edit.
+pub fn handle_in(app: &mut SoundApp, ctx: &egui::Context, window: MainWindow) {
     // Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
     ctx.options_mut(|o| o.zoom_with_keyboard = false);
     if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
@@ -72,7 +82,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
     // Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees).
     let mac = ctx.os() == egui::os::OperatingSystem::Mac;
     for (key, mods) in events {
-        if fixed(app, key, mods, mac) {
+        if fixed(app, key, mods, mac, window) {
             continue;
         }
         if let Some(id) = command_for(key, mods, mac) {
@@ -98,7 +108,7 @@ fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> {
 }
 
 /// Keys with context-dependent meaning. Returns true when handled.
-fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
+fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool, window: MainWindow) -> bool {
     let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
     match key {
         Key::Space if !m.command && !m.alt => {
@@ -123,7 +133,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
             true
         }
         // Shift+=/- step the Grid value and Alt+Shift+=/- the Nudge value; macOS sends Alt+Shift+= as =, not +.
-        Key::Plus | Key::Equals | Key::Minus if m.shift && !m.command && !m.ctrl && app.ui.window == MainWindow::Edit => {
+        Key::Plus | Key::Equals | Key::Minus if m.shift && !m.command && !m.ctrl && window == MainWindow::Edit => {
             let step = if key == Key::Minus { -1 } else { 1 };
             let _ = app.run(if m.alt { "view.nudge" } else { "view.grid" }, json!({"step": step}));
             true
@@ -165,7 +175,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
         // `=` is what a bare press of the unlabeled +/- key reports (it has no shift held, so it
         // can never be `Key::Plus`); `Key::Plus` is kept for a keyboard that does send it (e.g. a
         // numpad +). `!m.alt` leaves Alt+= to `window.event_list`'s registered shortcut.
-        Key::Plus | Key::Equals | Key::Minus if !m.command && !m.alt && app.ui.window == MainWindow::Edit => {
+        Key::Plus | Key::Equals | Key::Minus if !m.command && !m.alt && window == MainWindow::Edit => {
             let dir = if key == Key::Minus { -1 } else { 1 };
             let _ = app.run("edit.nudge", json!({"direction": dir}));
             true
@@ -213,7 +223,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
         k if !m.command
             && !m.alt
             && !m.ctrl
-            && app.ui.window == MainWindow::Edit
+            && window == MainWindow::Edit
             && app.engine.session().edit.keyboard_focus == "commands"
             && commands_focus(app, k, m.shift) =>
         {
diff --git a/crates/ui-egui/tests/it/mix_separate.rs b/crates/ui-egui/tests/it/mix_separate.rs
--- a/crates/ui-egui/tests/it/mix_separate.rs
+++ b/crates/ui-egui/tests/it/mix_separate.rs
@@ -1,9 +1,12 @@
 //! Window › Mix in Separate Window: Edit stays in the main window, and closing the Mix window
-//! brings Mix back into it.
-use egui::vec2;
+//! brings Mix back into it. Keys typed into either window act there, and the Mix window reopens
+//! where it was left.
+use egui::{Key, Modifiers, vec2};
 use egui_kittest::Harness;
 use serde_json::json;
 use soundcraft_ui_egui::{MainWindow, Services, SoundApp, mix_window};
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
 
 #[test]
 fn mix_in_a_separate_window_toggles_and_closing_it_returns_to_the_main_window() {
@@ -35,3 +38,284 @@ fn mix_in_a_separate_window_toggles_and_closing_it_returns_to_the_main_window()
     app.run("window.mix_separate", json!({"value": false})).unwrap();
     assert!(!app.ui.mix_separate);
 }
+
+#[test]
+fn mix_in_separate_window_is_a_checked_item_of_the_window_menu() {
+    use soundcraft_ui_egui::menus;
+    let path = "Window > Mix in Separate Window";
+    let tree = menus::tree();
+    let window = tree.iter().find(|n| n.label == "Window").unwrap();
+    let at = window.children.iter().position(|n| n.path == path).expect("in the Window menu (native and in-window)");
+    assert_eq!(window.children.get(at.wrapping_sub(1)).map(|n| n.path.as_str()), Some("Window > Edit"));
+    assert_eq!(menus::command_for_path(path, &menus::ui_aliases()).as_deref(), Some("window.mix_separate"));
+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+    assert_eq!(menus::item_state(&app, path, Some("window.mix_separate")), (true, false));
+    menus::invoke_menu(&mut app, "window.mix_separate", path);
+    assert!(app.ui.mix_separate);
+    assert_eq!(menus::item_state(&app, path, Some("window.mix_separate")), (true, true));
+    menus::invoke_menu(&mut app, "window.mix_separate", path);
+    assert!(!app.ui.mix_separate);
+}
+
+/// A main window and the native Mix window, as a multi-window backend drives them: each window's
+/// pass gets only its own input (`set_immediate_viewport_renderer` is how eframe does it).
+struct TwoWindows {
+    ctx: egui::Context,
+    /// Input for the Mix window's next pass, and what it reported.
+    mix_events: Rc<RefCell<Vec<egui::Event>>>,
+    mix_info: Rc<RefCell<egui::ViewportInfo>>,
+    mix_focused: Rc<Cell<bool>>,
+    mix_commands: Rc<RefCell<Vec<egui::ViewportCommand>>>,
+    mix_builder: Rc<RefCell<Option<egui::ViewportBuilder>>>,
+    /// A widget in the Mix window holds the keyboard focus (a text field being typed into).
+    mix_field_focused: Rc<Cell<bool>>,
+}
+
+impl TwoWindows {
+    fn new() -> Self {
+        let ctx = egui::Context::default();
+        ctx.set_os(egui::os::OperatingSystem::Mac);
+        ctx.set_embed_viewports(false);
+        let w = TwoWindows {
+            ctx,
+            mix_events: Rc::default(),
+            mix_info: Rc::default(),
+            mix_focused: Rc::new(Cell::new(true)),
+            mix_commands: Rc::default(),
+            mix_builder: Rc::default(),
+            mix_field_focused: Rc::default(),
+        };
+        let (events, info, focused, builder) = (w.mix_events.clone(), w.mix_info.clone(), w.mix_focused.clone(), w.mix_builder.clone());
+        let field = w.mix_field_focused.clone();
+        egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| {
+            let id = viewport.ids.this;
+            *builder.borrow_mut() = Some(viewport.builder.clone());
+            let input = egui::RawInput {
+                viewport_id: id,
+                viewports: std::iter::once((id, info.borrow().clone())).collect(),
+                events: events.take(),
+                focused: focused.get(),
+                ..Default::default()
+            };
+            ctx.run_ui(input, |ui| {
+                if field.get() {
+                    ui.memory_mut(|m| m.request_focus(egui::Id::new("mix text field")));
+                }
+                (viewport.viewport_ui_cb)(ui)
+            })
+            .textures_delta
+            .clear();
+        });
+        w
+    }
+
+    /// One frame of the main window, with `events` typed into it; returns the main window's commands.
+    fn frame(&self, app: &mut SoundApp, events: Vec<egui::Event>) -> Vec<egui::ViewportCommand> {
+        let root =
+            egui::ViewportInfo { outer_rect: Some(egui::Rect::from_min_size(egui::pos2(100.0, 80.0), vec2(1600.0, 1000.0))), ..Default::default() };
+        let input = egui::RawInput {
+            events,
+            viewports: std::iter::once((egui::ViewportId::ROOT, root)).collect(),
+            focused: !self.mix_focused.get(),
+            ..Default::default()
+        };
+        let mut out = self.ctx.run_ui(input, |ui| {
+            app.logic(ui.ctx());
+            app.ui(ui);
+        });
+        out.textures_delta.clear();
+        // The main window's pass carries the commands of the windows drawn inside it.
+        if let Some(mix) = out.viewport_output.remove(&mix_window::viewport_id()) {
+            self.mix_commands.borrow_mut().extend(mix.commands);
+        }
+        out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()
+    }
+
+    fn type_in_mix(&self, app: &mut SoundApp, key: Key, modifiers: Modifiers) -> Vec<egui::ViewportCommand> {
+        self.mix_focused.set(true);
+        *self.mix_events.borrow_mut() = key_press(key, modifiers);
+        self.frame(app, Vec::new())
+    }
+
+    fn type_in_main(&self, app: &mut SoundApp, key: Key, modifiers: Modifiers) -> Vec<egui::ViewportCommand> {
+        self.mix_focused.set(false);
+        self.frame(app, key_press(key, modifiers))
+    }
+}
+
+fn key_press(key: Key, modifiers: Modifiers) -> Vec<egui::Event> {
+    [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec()
+}
+
+fn separate_app() -> SoundApp {
+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+    app.run("window.mix_separate", json!({"value": true})).unwrap();
+    app
+}
+
+#[test]
+fn shortcuts_work_in_the_window_that_has_the_keys_and_run_once() {
+    let w = TwoWindows::new();
+    let mut app = separate_app();
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
+    // Cmd+1 (Transport) typed into the Mix window toggles it exactly once; so does the main window.
+    assert!(!app.ui.show_transport);
+    w.type_in_mix(&mut app, Key::Num1, cmd);
+    assert!(app.ui.show_transport, "a shortcut typed into the separate Mix window runs");
+    w.type_in_main(&mut app, Key::Num1, cmd);
+    assert!(!app.ui.show_transport, "and one typed into the main window runs once, not twice");
+
+    // Nudge (bare =) is an Edit window key: not from the Mix window, but from the main window,
+    // which shows Edit even after Window › Mix brought the Mix window forward.
+    let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
+    app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
+    let start = |app: &SoundApp| app.engine.session().find_clip(kick).unwrap().1.start;
+    let original = start(&app);
+    app.run("window.mix", json!({})).unwrap();
+    w.type_in_mix(&mut app, Key::Equals, Modifiers::NONE);
+    assert_eq!(start(&app), original, "Edit-only keys don't act from the Mix window");
+    w.type_in_main(&mut app, Key::Equals, Modifiers::NONE);
+    assert!(start(&app) > original, "the main window shows Edit, so its nudge key works");
+
+    // Cmd+= goes to the other window: Edit from the Mix window, Mix from the main one.
+    let focus = |c: &[egui::ViewportCommand]| c.contains(&egui::ViewportCommand::Focus);
+    w.mix_commands.borrow_mut().clear();
+    assert!(focus(&w.type_in_mix(&mut app, Key::Equals, cmd)), "Cmd+= in the Mix window brings Edit forward");
+    w.type_in_main(&mut app, Key::Equals, cmd);
+    w.frame(&mut app, Vec::new());
+    assert!(focus(&w.mix_commands.borrow()), "Cmd+= in the main window brings Mix forward");
+    assert!(app.ui.mix_separate);
+}
+
+#[test]
+fn a_text_field_in_the_mix_window_keeps_its_keys() {
+    let w = TwoWindows::new();
+    let mut app = separate_app();
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
+    w.mix_field_focused.set(true);
+    w.type_in_mix(&mut app, Key::Num1, cmd);
+    assert!(!app.ui.show_transport, "typing into a Mix window field runs no shortcut");
+    // The field goes away (egui drops the focus of a widget no longer drawn).
+    w.mix_field_focused.set(false);
+    for _ in 0..2 {
+        w.frame(&mut app, Vec::new());
+    }
+    w.type_in_mix(&mut app, Key::Num1, cmd);
+    assert!(app.ui.show_transport);
+}
+
+#[test]
+fn a_mix_window_drawn_inside_the_main_one_handles_its_keys_once() {
+    // Backends without native windows draw it as a window inside the main one, which shares the
+    // main window's input: the main window's pass handles the keys, the Mix window's must not.
+    let mut app = separate_app();
+    let ctx = egui::Context::default();
+    ctx.set_os(egui::os::OperatingSystem::Mac);
+    let frame = |app: &mut SoundApp, events: Vec<egui::Event>| {
+        ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
+            app.logic(ui.ctx());
+            app.ui(ui);
+        })
+        .textures_delta
+        .clear();
+    };
+    for _ in 0..3 {
+        frame(&mut app, Vec::new());
+    }
+    frame(&mut app, key_press(Key::Num1, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }));
+    assert!(app.ui.show_transport, "Cmd+1 toggled Transport once, not on and off again");
+}
+
+#[test]
+fn the_mix_window_reopens_where_it_was_left() {
+    reopens_where_it_was_left(1.0);
+    reopens_where_it_was_left(1.5);
+}
+
+fn reopens_where_it_was_left(zoom: f32) {
+    let w = TwoWindows::new();
+    w.ctx.set_zoom_factor(zoom);
+    let mut app = separate_app();
+    assert_eq!(app.ui.mix_window_pos, None);
+    *w.mix_info.borrow_mut() = egui::ViewportInfo {
+        outer_rect: Some(egui::Rect::from_min_size(egui::pos2(1800.0, 120.0), vec2(900.0, 640.0))),
+        inner_rect: Some(egui::Rect::from_min_size(egui::pos2(1800.0, 148.0), vec2(900.0, 612.0))),
+        monitor_size: Some(vec2(1920.0, 1080.0)),
+        ..Default::default()
+    };
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    // Saved in logical pixels: points × zoom.
+    assert_eq!(app.ui.mix_window_pos, Some([1800.0 * zoom, 120.0 * zoom]));
+    assert_eq!(app.ui.mix_window_size, [900.0 * zoom, 612.0 * zoom]);
+    assert!(!w.mix_commands.borrow().iter().any(|c| matches!(c, egui::ViewportCommand::OuterPosition(_))), "an on-screen window stays put");
+
+    // Saved with the preferences, restored at the next launch.
+    let ui: soundcraft_ui_egui::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();
+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+    app.ui = ui;
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let builder = w.mix_builder.borrow().clone().unwrap();
+    assert_eq!(builder.position, Some(egui::pos2(1800.0, 120.0)));
+    assert_eq!(builder.inner_size, Some(vec2(900.0, 612.0)));
+}
+
+#[test]
+fn a_mix_window_on_a_display_that_is_gone_moves_next_to_the_main_window() {
+    let w = TwoWindows::new();
+    let mut app = separate_app();
+    app.ui.mix_window_pos = Some([5000.0, 300.0]);
+    // The system puts the window where it was, but on no monitor.
+    *w.mix_info.borrow_mut() = egui::ViewportInfo {
+        outer_rect: Some(egui::Rect::from_min_size(egui::pos2(5000.0, 300.0), vec2(900.0, 640.0))),
+        inner_rect: Some(egui::Rect::from_min_size(egui::pos2(5000.0, 328.0), vec2(900.0, 612.0))),
+        monitor_size: None,
+        ..Default::default()
+    };
+    for _ in 0..3 {
+        w.frame(&mut app, Vec::new());
+    }
+    let moves: Vec<_> = w.mix_commands.borrow().iter().filter(|c| matches!(c, egui::ViewportCommand::OuterPosition(_))).cloned().collect();
+    assert_eq!(moves, [egui::ViewportCommand::OuterPosition(egui::pos2(148.0, 128.0))]);
+}
+
+#[test]
+fn nonsense_mix_window_placements_fall_back_to_the_defaults() {
+    assert_eq!(mix_window::sane_placement(Some([10.0, 20.0]), [900.0, 600.0]), (Some([10.0, 20.0]), [900.0, 600.0]));
+    assert_eq!(mix_window::sane_placement(Some([f32::NAN, 0.0]), [f32::INFINITY, 600.0]), (None, mix_window::DEFAULT_SIZE));
+    assert_eq!(mix_window::sane_placement(Some([1e9, 0.0]), [10.0, -5.0]), (None, mix_window::DEFAULT_SIZE));
+    assert_eq!(mix_window::sane_placement(None, [1e9, 1e9]), (None, [16_384.0, 16_384.0]));
+    // Preferences from before the Mix window was remembered.
+    let ui: soundcraft_ui_egui::UiState = serde_json::from_value(json!({"mix_separate": true})).unwrap();
+    assert_eq!((ui.mix_window_pos, ui.mix_window_size), (None, mix_window::DEFAULT_SIZE));
+}
+
+#[test]
+fn saved_numbers_too_big_for_the_preferences_get_their_defaults() {
+    // JSON reads 1e39 into an f32 as infinity, which would be written back as `null` and make
+    // the whole preferences file unreadable.
+    let saved =
+        json!({"mix_window_pos": [1e39, 0], "mix_window_size": [1e39, 600], "track_tint": 1e39, "window_size": [1e39, 1e39], "ui_scale": -1e39});
+    let ui: soundcraft_ui_egui::UiState = serde_json::from_value(saved.clone()).unwrap();
+    assert_eq!((ui.mix_window_pos, ui.mix_window_size), (None, mix_window::DEFAULT_SIZE));
+    assert_eq!((ui.track_tint, ui.window_size, ui.ui_scale), (0.5, [1600.0, 1000.0], 1.0));
+    let again: soundcraft_ui_egui::UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap();
+    assert_eq!(again.mix_window_size, mix_window::DEFAULT_SIZE, "the preferences round-trip");
+
+    // The control channel's ui.set goes through the same reading.
+    let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+    app.run("ui.set", saved).unwrap();
+    assert_eq!((app.ui.mix_window_pos, app.ui.mix_window_size, app.ui.track_tint), (None, mix_window::DEFAULT_SIZE, 0.5));
+    app.run("ui.set", json!({"track_tint": 2.0, "mix_window_pos": [-32000, -32000]})).unwrap();
+    assert_eq!((app.ui.track_tint, app.ui.mix_window_pos), (1.0, None), "out of range: clamped, or the minimized-window sentinel dropped");
+    app.run("ui.set", json!({"show_transport": true})).expect("later ui.set calls still round-trip");
+}
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
 # UI parity
 
-> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (separate Mix window: keys, placement, Window menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1
 
 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
 windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
@@ -43,7 +43,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
 | Window | Status | Notes |
 |---|---|---|
 | Edit window: rulers, track headers, view columns, Clip List, Groups, Tracks list | ✅ | Track Pin (new in 2026.4) ✅ (`track.pin`) |
-| Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Mix and Edit on screen at once (Window › Mix in Separate Window, its own native window) ✅; Delay Compensation view and HEAT missing |
+| Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Mix and Edit on screen at once (Window › Mix in Separate Window, its own native window, a checked item of the Window menu, also the macOS menu bar) ✅: shortcuts work in whichever window has the keys (Edit-only keys such as nudge only in the Edit window; Cmd+= brings the other window forward), and it reopens where it was left (moved next to Edit if its display is gone, as far as the system reports that: macOS yes, Windows no; with monitors at different scales on Windows/X11 it can reopen somewhat offset); Delay Compensation view and HEAT missing |
 | MIDI Editor, Score Editor, Event List | 🟡 | Dock height bug fixed (#113); notation editing shallow |
 | Transport, Big Counter, Memory Locations, Automation, Video, Universe, Renderer, UI Customization | ✅ present | |
 | Workspace / sound browser with previews | ❌ | G-16 |
@@ -69,6 +69,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Separate Mix window: shortcuts work while it has the keys, its position and size are saved, and it is in the Window menu on the macOS menu bar. Readiness estimate unchanged |
 | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
 | 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
 | 2026-10-13 | minor | Drag across checkboxes (#399) |
