diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs
--- a/crates/ui-egui/src/edit_window/header.rs
+++ b/crates/ui-egui/src/edit_window/header.rs
@@ -455,11 +455,143 @@ fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
         let pan = m.pan.first().map_or("-".into(), |p| crate::widgets::pan_text(*p));
         // The readout is black in both themes, so it takes the dark palette's green.
         let ink = Tokens::DARK.counter_text;
-        for (y, label, value) in [(7.5, "vol", crate::widgets::db_text(m.volume_db)), (22.0, "pan", pan)] {
+        // The two rows are buttons: a click opens a fader or pan sliders, an Alt-click resets.
+        // The Mix window's strip has no stereo pan for a VCA or for a track on a multichannel output (it shows a surround panner), so the pan stays text.
+        // ponytail: surround-panned tracks are placed in the Mix window. Upgrade: `widgets::surround_panner` in the pan popup.
+        let pannable = !m.pan.is_empty() && track.kind != TrackKind::Vca && !crate::mix_window::surround_panned(app, track);
+        let vol_row = Rect::from_x_y_ranges(vr.x_range(), vr.min.y + 1.5..=vr.min.y + 14.0).shrink2(vec2(1.5, 0.0));
+        let pan_row = Rect::from_x_y_ranges(vr.x_range(), vr.min.y + 15.0..=vr.max.y - 1.5).shrink2(vec2(1.5, 0.0));
+        let vol_text = crate::widgets::db_text(m.volume_db);
+        let vol = readout_button(
+            &ui,
+            vol_row,
+            ui.scope_id().with(("vol", id.0)),
+            &format!("{} volume", track.name),
+            &vol_text,
+            "Volume: click for a fader, Alt-click for 0 dB",
+        );
+        let pan_resp = pannable.then(|| {
+            readout_button(
+                &ui,
+                pan_row,
+                ui.scope_id().with(("pan", id.0)),
+                &format!("{} pan", track.name),
+                &pan,
+                "Pan: click for sliders, Alt-click to reset",
+            )
+        });
+        let alt = ui.input(|i| i.modifiers.alt);
+        if vol.clicked() && alt && m.volume_db.abs() > f32::EPSILON {
+            // Merged, so a reset during an automation pass joins the pass's undo step; the merge ends by itself on the next frame.
+            let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": 0.0}), &format!("fader:{}", id.0));
+        }
+        if let Some(resp) = &pan_resp
+            && resp.clicked()
+            && alt
+        {
+            reset_pan(app, track);
+        }
+        for (y, label, value) in [(7.5, "vol", vol_text), (22.0, "pan", pan)] {
             ui.painter().text(pos2(vr.min.x + 7.0, vr.min.y + y), Align2::LEFT_CENTER, label, bold(10.0), ink);
             ui.painter().text(pos2(vr.max.x - 6.5, vr.min.y + y), Align2::RIGHT_CENTER, value, bold(10.0), ink);
         }
         ui.painter().hline(vr.min.x + 7.0..=vr.max.x - 6.0, vr.min.y + 14.5, Stroke::new(1.0, Color32::from_gray(35)));
+        readout_popup(&vol, !alt).show(|ui| volume_fader(app, ui, track));
+        if let Some(resp) = &pan_resp {
+            readout_popup(resp, !alt).show(|ui| pan_sliders(app, ui, track));
+        }
+    }
+}
+
+/// A readout row of the I/O column as a button, named `name` (the track's, so two tracks showing the same value differ) and its value. It lights up under the pointer, with the keyboard focus or while its popup is open,
+/// and paints nothing otherwise, so an untouched column looks the same as before.
+fn readout_button(ui: &Ui, r: Rect, id: egui::Id, name: &str, value: &str, tip: &str) -> egui::Response {
+    let resp = ui.interact(r, id, Sense::click()).on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand);
+    resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{name} {value}")));
+    if resp.hovered() || resp.has_focus() || egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp)) {
+        ui.painter().rect_filled(r, 1.5, Color32::from_gray(38));
+    }
+    resp
+}
+
+/// The popup of a readout button, to its right so it doesn't hide the tracks below. An Alt-click
+/// resets instead of opening (`toggle` false). Clicks inside it keep it open; Escape or a click outside closes it.
+fn readout_popup(resp: &egui::Response, toggle: bool) -> egui::Popup<'static> {
+    egui::Popup::from_response(resp)
+        .open_memory((toggle && resp.clicked()).then_some(egui::SetOpenCommand::Toggle))
+        .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
+        .align(egui::RectAlign::RIGHT_START)
+        .gap(2.0)
+}
+
+/// What a panner returns to: centre on a mono track, hard left or right on a stereo one, as on a new track.
+fn default_pan(track: &Track, index: usize) -> f32 {
+    soundcraft_model::Mixer::new(track.format).pan.get(index).copied().unwrap_or(0.0)
+}
+
+/// Put every panner of the track (L and R on a stereo one) back to its default, as one undo step. The merge ends by itself on the next frame.
+fn reset_pan(app: &mut SoundApp, track: &Track) {
+    let key = format!("pan-reset:{}", track.id.0);
+    for (i, now) in track.mixer.pan.iter().enumerate().take(2) {
+        let to = default_pan(track, i);
+        // Only a real change may start the merged step: a no-op first call would leave the next one folded into an older edit.
+        if (now - to).abs() > f32::EPSILON {
+            let _ = app.engine.execute_merged("mix.pan", &json!({"track": track.id.0, "pan": to, "index": i}), &key);
+        }
+    }
+}
+
+/// The volume popup: a fader like the Mix window's, over a readout that takes a typed value.
+/// A drag is one undo step and goes through [`Gesture::Fader`], so Touch and Latch automation write it.
+fn volume_fader(app: &mut SoundApp, ui: &mut Ui, track: &Track) {
+    let t = Tokens::current();
+    let id = track.id;
+    let db = track.mixer.volume_db;
+    let key = format!("fader:{}", id.0);
+    let (well, _) = ui.allocate_exact_size(vec2(56.0, 16.0), Sense::hover());
+    crate::widgets::inset(ui.painter(), well, t.counter_bg);
+    let readout_id = ui.scope_id().with(("io-vol", id.0));
+    let (_, typed) = crate::mix_window::editable_readout(
+        ui,
+        well,
+        readout_id,
+        crate::mix_window::Readout {
+            name: &format!("{} volume", track.name),
+            display: &crate::widgets::db_text(db),
+            edit_initial: &format!("{db:.1}"),
+            parse: crate::mix_window::parse_db,
+            reset: 0.0,
+        },
+    );
+    if let Some(db) = typed {
+        let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &key);
+    }
+    ui.add_space(4.0);
+    // ponytail: the fader is drawn, not focusable, so the keyboard sets the volume in the readout above it. Upgrade: arrow keys in `widgets::fader`.
+    let (slot, _) = ui.allocate_exact_size(vec2(56.0, 170.0), Sense::hover());
+    if let Some(moved) = crate::widgets::fader(ui, slot, db, ui.scope_id().with(("io-fader", id.0)), &format!("{} level", track.name)) {
+        app.gesture = Some(Gesture::Fader { track: id, start_db: db });
+        let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": moved}), &key);
+    }
+}
+
+/// The pan popup: one slider, or L and R on a stereo track. A drag is one undo step per panner.
+fn pan_sliders(app: &mut SoundApp, ui: &mut Ui, track: &Track) {
+    ui.spacing_mut().slider_width = 120.0;
+    let stereo = track.mixer.pan.len() > 1;
+    for (i, side) in ["L", "R"].into_iter().enumerate().take(track.mixer.pan.len()) {
+        let before = track.mixer.pan.get(i).copied().unwrap_or(0.0);
+        let mut pan = before;
+        // ponytail: no double-click reset on the sliders; the readout's Alt-click resets every panner at once.
+        // No step either: a slider rounds to it when drawn, which would rewrite a pan off the grid just by opening the popup.
+        let slider = egui::Slider::new(&mut pan, -1.0..=1.0)
+            .text(if stereo { side } else { "Pan" })
+            .custom_formatter(|v, _| crate::widgets::pan_text(v as f32))
+            .custom_parser(|s| crate::mix_window::parse_pan(s).map(f64::from));
+        ui.add(slider);
+        if (pan - before).abs() > f32::EPSILON {
+            let _ = app.engine.execute_merged("mix.pan", &json!({"track": track.id.0, "pan": pan, "index": i}), &format!("pan:{}:{i}", track.id.0));
+        }
     }
 }
 
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
@@ -20,7 +20,7 @@ pub use plugin_menu::{instrument_menu, plugin_info, plugin_menu};
 pub(crate) use routing::{input_name, route_name, route_popup};
 #[cfg(test)]
 use sends::dragged_send_db;
-use sends::{Readout, editable_readout, parse_db, parse_pan};
+pub(crate) use sends::{Readout, editable_readout, parse_db, parse_pan};
 pub(crate) use sends::{send_slot, send_window};
 pub(crate) use slots::{insert_slot, instrument_slot};
 
@@ -90,6 +90,12 @@ fn output_format(app: &SoundApp, track: &Track) -> ChannelFormat {
     }
 }
 
+/// Whether the strip pans a track with the surround panner, because its output is multichannel, instead of the stereo pans.
+pub(crate) fn surround_panned(app: &SoundApp, track: &Track) -> bool {
+    let out_fmt = output_format(app, track);
+    out_fmt.channels() > 2 && !out_fmt.is_ambisonic() && !matches!(track.kind, TrackKind::Master | TrackKind::Vca) && track.channels() <= 2
+}
+
 /// Speaker dots of a format, placed on the panner square.
 fn panner_speakers(fmt: ChannelFormat) -> Vec<PannerSpeaker> {
     let sp = fmt.speakers();
@@ -373,9 +379,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
     y += 22.0;
     // Pan: a surround panner when the output is multichannel, else the stereo knobs.
     let out_fmt = output_format(app, track);
-    let surround_out =
-        out_fmt.channels() > 2 && !out_fmt.is_ambisonic() && !matches!(track.kind, TrackKind::Master | TrackKind::Vca) && track.channels() <= 2;
-    if surround_out {
+    if surround_panned(app, track) {
         let size = (inner_w - 4.0).min(88.0);
         let sp = track.mixer.surround.unwrap_or_else(|| {
             SurroundPan::from_stereo(if track.mixer.pan.len() == 1 { track.mixer.pan.first().copied().unwrap_or(0.0) } else { 0.0 })
diff --git a/crates/ui-egui/src/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs
--- a/crates/ui-egui/src/mix_window/sends.rs
+++ b/crates/ui-egui/src/mix_window/sends.rs
@@ -224,22 +224,22 @@ pub(super) fn dragged_send_db(db: f32, dy: f32, fine: bool) -> f32 {
 }
 
 /// What an [`editable_readout`] shows and how a typed value is parsed.
-pub(super) struct Readout<'a> {
+pub(crate) struct Readout<'a> {
     /// Accessible name of the text field shown while editing.
-    pub(super) name: &'a str,
+    pub(crate) name: &'a str,
     /// The value painted while not editing.
-    pub(super) display: &'a str,
+    pub(crate) display: &'a str,
     /// The editor's initial text.
-    pub(super) edit_initial: &'a str,
-    pub(super) parse: fn(&str) -> Option<f32>,
+    pub(crate) edit_initial: &'a str,
+    pub(crate) parse: fn(&str) -> Option<f32>,
     /// The value an Alt-click sets.
-    pub(super) reset: f32,
+    pub(crate) reset: f32,
 }
 
 /// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away
 /// commits a valid value; Escape cancels, and so does the field losing focus while hidden.
 /// Alt-click returns `reset`. The interaction response remains available for dragging.
-pub(super) fn editable_readout(ui: &mut Ui, r: Rect, id: egui::Id, readout: Readout<'_>) -> (Option<egui::Response>, Option<f32>) {
+pub(crate) fn editable_readout(ui: &mut Ui, r: Rect, id: egui::Id, readout: Readout<'_>) -> (Option<egui::Response>, Option<f32>) {
     let Readout { name, display, edit_initial, parse, reset } = readout;
     let t = Tokens::current();
     let active_id = id.with("active");
@@ -296,7 +296,7 @@ pub(super) fn editable_readout(ui: &mut Ui, r: Rect, id: egui::Id, readout: Read
     (response, value)
 }
 
-pub(super) fn parse_db(text: &str) -> Option<f32> {
+pub(crate) fn parse_db(text: &str) -> Option<f32> {
     let cleaned = text.trim().to_ascii_lowercase().replace("db", "").replace(',', ".");
     if matches!(cleaned.trim(), "-inf" | "-infinity" | "off") {
         return Some(-144.0);
@@ -304,7 +304,7 @@ pub(super) fn parse_db(text: &str) -> Option<f32> {
     cleaned.trim().parse::<f32>().ok().filter(|v| v.is_finite()).map(|v| v.clamp(-144.0, 12.0))
 }
 
-pub(super) fn parse_pan(text: &str) -> Option<f32> {
+pub(crate) fn parse_pan(text: &str) -> Option<f32> {
     let cleaned = text.trim().to_ascii_lowercase().replace(',', ".");
     let (sign, number) = if let Some(v) = cleaned.strip_prefix('<').or_else(|| cleaned.strip_prefix('l')) {
         (-1.0, v)
diff --git a/crates/ui-egui/tests/it/edit_window_headers.rs b/crates/ui-egui/tests/it/edit_window_headers.rs
--- a/crates/ui-egui/tests/it/edit_window_headers.rs
+++ b/crates/ui-egui/tests/it/edit_window_headers.rs
@@ -1,12 +1,13 @@
-use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
+use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
 use egui_kittest::Harness;
 use egui_kittest::kittest::Queryable;
 use serde_json::json;
-use soundcraft_model::{Track, TrackId};
+use soundcraft_model::{AutoParam, Track, TrackId};
 use soundcraft_ui_egui::edit_window::HEADER_W;
-use soundcraft_ui_egui::{MainWindow, Services, SoundApp};
+use soundcraft_ui_egui::{Gesture, MainWindow, Services, SoundApp};
 
 const CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
+const ALT: Modifiers = Modifiers { alt: true, ..Modifiers::NONE };
 
 /// Feed one harness frame through the raw-input hook before stepping, so scripted input reaches the app.
 fn step(h: &mut Harness<'_, SoundApp>) {
@@ -181,3 +182,214 @@ fn only_a_sends_fader_icon_opens_its_level() {
     assert_eq!(h.state().ui.send_window, Some((snare, 0)));
     assert!(h.query_by_label("DESTINATION").is_none());
 }
+
+/// The I/O column's "vol" and "pan" rows, for a track whose row is `row`.
+fn vol_at(row: [f32; 4]) -> Pos2 {
+    pos2(row[0] + HEADER_W + 40.0, row[1] + 52.5)
+}
+
+fn pan_at(row: [f32; 4]) -> Pos2 {
+    pos2(row[0] + HEADER_W + 40.0, row[1] + 66.5)
+}
+
+fn undo_steps(h: &Harness<'_, SoundApp>) -> usize {
+    h.state().engine.undo_history().len()
+}
+
+fn track(h: &Harness<'_, SoundApp>, name: &str) -> Track {
+    h.state().engine.session().track_by_name(name).cloned().unwrap()
+}
+
+/// The open popup (the only foreground layer while one is open).
+fn popup_rect(h: &Harness<'_, SoundApp>) -> Rect {
+    let rects: Vec<Rect> = h.ctx.memory(|m| {
+        m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Foreground).filter_map(|l| m.area_rect(l.id)).collect()
+    });
+    assert_eq!(rects.len(), 1, "expected exactly one popup, found {}", rects.len());
+    rects[0]
+}
+
+/// Press at `from`, drag to `to` in four moves and release. `during` runs after each move, button down.
+fn drag(h: &mut Harness<'_, SoundApp>, from: Pos2, to: Pos2, mut during: impl FnMut(&mut Harness<'_, SoundApp>)) {
+    let modifiers = Modifiers::NONE;
+    h.state_mut()
+        .synthetic
+        .extend([Event::PointerMoved(from), Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }]);
+    for _ in 0..6 {
+        step(h);
+    }
+    for i in 1..=4 {
+        h.state_mut().synthetic.push(Event::PointerMoved(from + (to - from) * (i as f32 / 4.0)));
+        for _ in 0..3 {
+            step(h);
+        }
+        during(h);
+    }
+    h.state_mut().synthetic.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers });
+    for _ in 0..6 {
+        step(h);
+    }
+}
+
+#[test]
+fn alt_click_on_the_io_readouts_resets_volume_and_pan() {
+    let (mut h, id, row) = harness("Kick", "medium", "view.edit_io");
+    h.state_mut().run("mix.pan", json!({"track": id.0, "pan": 0.5})).unwrap();
+    assert!(kick(&h).mixer.volume_db != 0.0 && kick(&h).mixer.pan == [0.5]);
+    let before = undo_steps(&h);
+
+    press(&mut h, vol_at(row), PointerButton::Primary, ALT);
+    assert_eq!(kick(&h).mixer.volume_db, 0.0);
+    press(&mut h, pan_at(row), PointerButton::Primary, ALT);
+    assert_eq!(kick(&h).mixer.pan, [0.0]);
+
+    assert_eq!(undo_steps(&h), before + 2, "each reset is one undo step");
+    assert!(!egui::Popup::is_any_open(&h.ctx), "an Alt-click opened a popup instead of resetting");
+}
+
+#[test]
+fn alt_click_on_a_stereo_tracks_pan_resets_both_panners_in_one_step() {
+    let (mut h, id, row) = harness("Pad", "medium", "view.edit_io");
+    for (index, pan) in [(0, -0.25), (1, 0.75)] {
+        h.state_mut().run("mix.pan", json!({"track": id.0, "pan": pan, "index": index})).unwrap();
+    }
+    assert_eq!(track(&h, "Pad").mixer.pan, [-0.25, 0.75]);
+    let before = undo_steps(&h);
+
+    press(&mut h, pan_at(row), PointerButton::Primary, ALT);
+    assert_eq!(track(&h, "Pad").mixer.pan, [-1.0, 1.0], "a stereo track goes back to hard left and right, like a new one");
+    assert_eq!(undo_steps(&h), before + 1);
+    assert!(!egui::Popup::is_any_open(&h.ctx));
+}
+
+#[test]
+fn clicking_the_volume_readout_opens_a_fader_whose_drag_is_one_undo_step() {
+    let (mut h, id, _) = harness("Kick", "medium", "view.edit_io");
+    let db = kick(&h).mixer.volume_db;
+    let before = undo_steps(&h);
+    assert!(!egui::Popup::is_any_open(&h.ctx));
+
+    // The readout is a button named after its track and its value.
+    let readout = h.get_by_label("Kick volume -3.0").rect().center();
+    click(&mut h, readout);
+    assert!(egui::Popup::is_any_open(&h.ctx), "the click didn't open the fader");
+    assert_eq!(kick(&h).mixer.volume_db, db, "opening the fader changed the volume");
+
+    // The popup floats over the timeline: a drag in it must not also select or move anything there.
+    let edit = h.state().engine.session().edit.clone();
+    // The fader fills the bottom of the popup and moves by drag distance, so any point on it will do.
+    let popup = popup_rect(&h);
+    let from = pos2(popup.center().x, popup.max.y - 50.0);
+    let mut gestures = 0;
+    drag(&mut h, from, from - vec2(0.0, 60.0), |h| {
+        assert!(matches!(h.state().gesture, Some(Gesture::Fader { track, .. }) if track == id), "the drag isn't a Fader gesture");
+        gestures += 1;
+    });
+    assert_eq!(gestures, 4);
+    assert!(kick(&h).mixer.volume_db > db, "dragging up didn't raise the volume");
+    assert_eq!(undo_steps(&h), before + 1, "a whole drag is one undo step");
+    let after = h.state().engine.session().edit.clone();
+    assert!(after.selection == edit.selection && after.selected_tracks == edit.selected_tracks, "the drag leaked through to the timeline");
+    assert!(h.state().gesture.is_none(), "the gesture outlived the drag");
+    assert!(egui::Popup::is_any_open(&h.ctx), "releasing the fader closed it");
+
+    h.state_mut().engine.undo();
+    assert_eq!(kick(&h).mixer.volume_db, db, "one undo should restore the volume from before the drag");
+
+    click(&mut h, pos2(800.0, 990.0));
+    assert!(!egui::Popup::is_any_open(&h.ctx), "a click outside left the fader open");
+}
+
+#[test]
+fn the_popup_fader_writes_touch_automation() {
+    let (mut h, id, row) = harness("Kick", "medium", "view.edit_io");
+    h.state_mut().run("mix.automation_mode", json!({"track": id.0, "mode": "touch"})).unwrap();
+    click(&mut h, vol_at(row));
+    let popup = popup_rect(&h);
+    let from = pos2(popup.center().x, popup.max.y - 50.0);
+
+    h.state_mut().engine.transport.playing = true;
+    drag(&mut h, from, from - vec2(0.0, 60.0), |h| {
+        // Touch writes at the playhead, which has to keep moving for a new point to be due.
+        h.state_mut().engine.transport.position += 4800;
+    });
+    h.state_mut().engine.transport.playing = false;
+    step(&mut h);
+
+    let points = kick(&h).lane(&AutoParam::Volume).map_or(0, |lane| lane.points.len());
+    assert!(points > 0, "dragging the popup fader in Touch mode wrote no automation");
+}
+
+#[test]
+fn clicking_the_pan_readout_opens_one_slider_on_mono_and_two_on_stereo() {
+    for (name, panners) in [("Kick", 1), ("Pad", 2)] {
+        let (mut h, _, row) = harness(name, "medium", "view.edit_io");
+        let before = undo_steps(&h);
+        click(&mut h, pan_at(row));
+        assert!(egui::Popup::is_any_open(&h.ctx), "{name}: the click didn't open the sliders");
+        let sliders: Vec<_> = h.query_all_by_role(egui::accesskit::Role::Slider).collect();
+        assert_eq!(sliders.len(), panners, "{name}: wrong number of pan sliders");
+        assert_eq!(undo_steps(&h), before, "{name}: opening the sliders changed the session");
+
+        // Drag the last panner's slider (R on stereo) by 30 points: a slider follows the pointer.
+        let slider = sliders.last().unwrap().rect();
+        let (from, to) = (slider.center(), slider.center() + vec2(30.0, 0.0));
+        let index = panners - 1;
+        let pan = track(&h, name).mixer.pan.clone();
+        drag(&mut h, from, to, |_| {});
+        let moved = track(&h, name).mixer.pan;
+        assert_eq!(moved.len(), panners);
+        assert!(moved[index] != pan[index], "{name}: the slider didn't move the panner: {pan:?} -> {moved:?}");
+        assert_eq!(moved[..index], pan[..index], "{name}: moving R touched L");
+        assert_eq!(undo_steps(&h), before + 1, "{name}: a slider drag is one undo step");
+    }
+}
+
+#[test]
+fn a_value_typed_in_the_fader_popup_sets_the_volume() {
+    let (mut h, _, row) = harness("Kick", "medium", "view.edit_io");
+    click(&mut h, vol_at(row));
+    let popup = popup_rect(&h);
+    click(&mut h, pos2(popup.center().x, popup.min.y + 14.0));
+    let key = |key, modifiers| {
+        [
+            Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
+            Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
+        ]
+    };
+    h.state_mut().synthetic.extend(key(egui::Key::A, CMD));
+    h.state_mut().synthetic.push(Event::Text("-12".into()));
+    h.state_mut().synthetic.extend(key(egui::Key::Enter, Modifiers::NONE));
+    for _ in 0..10 {
+        step(&mut h);
+    }
+    assert_eq!(kick(&h).mixer.volume_db, -12.0);
+}
+
+#[test]
+fn opening_the_pan_popup_leaves_a_pan_off_the_percent_grid_alone() {
+    let (mut h, id, row) = harness("Kick", "medium", "view.edit_io");
+    h.state_mut().run("mix.pan", json!({"track": id.0, "pan": 0.3337})).unwrap();
+    let before = undo_steps(&h);
+    click(&mut h, pan_at(row));
+    assert!(egui::Popup::is_any_open(&h.ctx));
+    assert_eq!(kick(&h).mixer.pan, [0.3337], "opening the sliders rewrote the pan");
+    assert_eq!(undo_steps(&h), before, "opening the sliders left an undo step");
+    assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, "Pan").is_some(), "the lone pan slider has no name");
+}
+
+#[test]
+fn a_track_on_a_surround_output_keeps_a_text_only_pan() {
+    let (mut h, id, row) = harness("Kick", "medium", "view.edit_io");
+    h.state_mut().run("setup.main_format", json!({"format": "5.1"})).unwrap();
+    h.state_mut().run("mix.pan", json!({"track": id.0, "pan": 0.5})).unwrap();
+    for _ in 0..4 {
+        step(&mut h);
+    }
+    let before = undo_steps(&h);
+    click(&mut h, pan_at(row));
+    assert!(!egui::Popup::is_any_open(&h.ctx), "the Mix window pans this track with a surround panner, not these sliders");
+    press(&mut h, pan_at(row), PointerButton::Primary, ALT);
+    assert_eq!(kick(&h).mixer.pan, [0.5]);
+    assert_eq!(undo_steps(&h), before);
+}
