diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs
--- a/crates/ui-egui/src/colorpicker.rs
+++ b/crates/ui-egui/src/colorpicker.rs
@@ -170,21 +170,143 @@ fn remember_recent(app: &mut FigApp, c: Color) {
 }
 
 fn finish(app: &mut FigApp) {
+    finish_with(app, true);
+}
+
+/// Finish the picker's edit. `record`: the colour it ends on is a choice for the recent colours
+/// (not when the picker was only closed because its layers went away or changed).
+fn finish_with(app: &mut FigApp, record: bool) {
     if let Some(PickerTarget::PluginField { plugin, control, pending: Some(hex) }) = app.ui.picker.clone() {
         crate::plugins_ui::set_field(app, &plugin, &control, &hex);
         app.ui.picker = Some(PickerTarget::PluginField { plugin, control, pending: None });
     }
-    if app.session.ds().map(|d| d.interaction.is_some()).unwrap_or(false) {
-        if let Some(t) = app.ui.picker.clone()
+    // Any open interaction counts for the recent colours: editing text holds an "Edit text" one, and
+    // the colour a drag ends on is still a choice. Only a colour interaction is committed here.
+    let label = app.session.ds().ok().and_then(|d| d.interaction.as_ref().map(|interaction| interaction.label.clone()));
+    if let Some(label) = label {
+        if record
+            && let Some(t) = app.ui.picker.clone()
             && !matches!(t, PickerTarget::PluginField { .. })
             && let Some(c) = current_paint(app, &t).and_then(|p| p.solid_color())
         {
             remember_recent(app, c);
         }
-        let _ = app.session.commit_interaction();
+        if label == "Color" {
+            let _ = app.session.commit_interaction();
+        }
     }
 }
 
+/// Finish an image picker interaction in its owning document after the active tab changed.
+fn finish_document(app: &mut FigApp, uid: u64) {
+    if let Some(PickerTarget::PluginField { plugin, control, pending: Some(hex) }) = app.ui.picker.clone() {
+        crate::plugins_ui::set_field(app, &plugin, &control, &hex);
+    }
+    let Some(index) = app.session.docs.iter().position(|doc| doc.uid == uid) else { return };
+    if !app.session.docs[index].interaction.as_ref().is_some_and(|interaction| interaction.label == "Color") {
+        return;
+    }
+    let active = app.session.active;
+    app.session.active = index;
+    let _ = app.session.commit_interaction();
+    app.session.active = active;
+}
+
+/// The same layers, in any order.
+fn same_layers(a: &[NodeId], b: &[NodeId]) -> bool {
+    a.len() == b.len() && a.iter().all(|id| b.contains(id))
+}
+
+/// Close the picker, committing a colour interaction it still has open.
+fn close_picker(app: &mut FigApp) {
+    finish_with(app, false);
+    app.ui.picker = None;
+    app.ui.picker_context = None;
+}
+
+/// Keep an open image-adjustment picker attached to the selected image layer.
+/// Other picker targets retain their existing selection ownership.
+fn sync_picker_target(app: &mut FigApp) -> bool {
+    let Some(target) = app.ui.picker.clone() else {
+        app.ui.picker_context = None;
+        return false;
+    };
+    let Ok(ds) = app.session.ds() else {
+        close_picker(app);
+        return false;
+    };
+    let (uid, page) = (ds.uid, ds.page);
+    if let Some((_, owner_uid, _, true)) = app.ui.picker_context.clone()
+        && owner_uid != uid
+    {
+        finish_document(app, owner_uid);
+        app.ui.picker = None;
+        app.ui.picker_context = None;
+        return false;
+    }
+    if let Some((owner_target, owner_uid, owner_page, image_owner)) = app.ui.picker_context.clone()
+        && owner_target == target
+        && owner_uid == uid
+        && image_owner
+        && owner_page != page
+    {
+        close_picker(app);
+        return false;
+    }
+    let image_owner =
+        matches!(target, PickerTarget::Paint { .. }) && current_paint(app, &target).is_some_and(|paint| matches!(paint.kind, PaintKind::Image(_)));
+    app.ui.picker_context = Some((target.clone(), uid, page, image_owner));
+
+    let (fills, index, ids) = match &target {
+        PickerTarget::Paint { fills, index, ids } => (*fills, *index, ids.clone()),
+        _ => return true,
+    };
+    let original_is_image = ids.first().and_then(|id| app.session.doc().ok()?.node(*id)).is_some_and(|node| {
+        let paints = if fills { &node.fills } else { &node.strokes };
+        matches!(paints.get(index).map(|paint| &paint.kind), Some(PaintKind::Image(_)))
+    });
+    if !original_is_image {
+        return true;
+    }
+
+    let selected = app.session.selection();
+    // The popup was opened for these layers (several can share one image paint): keep editing them all.
+    if same_layers(&ids, &selected) {
+        return true;
+    }
+    if selected.len() != 1 {
+        close_picker(app);
+        return false;
+    }
+    let selected_id = selected[0];
+    let Some(node) = app.session.doc().ok().and_then(|doc| doc.node(selected_id)) else {
+        close_picker(app);
+        return false;
+    };
+    let target_image = |fills: bool, index: usize| {
+        let paints = if fills { &node.fills } else { &node.strokes };
+        matches!(paints.get(index).map(|paint| &paint.kind), Some(PaintKind::Image(_)))
+    };
+    let (fills, index) = if target_image(fills, index) {
+        (fills, index)
+    } else if let Some(index) = node.fills.iter().position(|paint| matches!(paint.kind, PaintKind::Image(_))) {
+        (true, index)
+    } else if let Some(index) = node.strokes.iter().position(|paint| matches!(paint.kind, PaintKind::Image(_))) {
+        (false, index)
+    } else {
+        close_picker(app);
+        return false;
+    };
+    let next = PickerTarget::Paint { fills, index, ids: selected };
+    if next == target {
+        return true;
+    }
+    finish_with(app, false);
+    app.ui.picker_context = Some((next.clone(), uid, page, true));
+    app.ui.picker = Some(next);
+    true
+}
+
 /// The selected gradient stop in the picker (shared with the on-canvas stops).
 pub fn stop_id() -> Id {
     Id::new("cp_stop")
@@ -424,16 +546,20 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
 }
 
 pub fn show(app: &mut FigApp, ctx: &egui::Context) {
+    if !sync_picker_target(app) {
+        return;
+    }
     let Some(target) = app.ui.picker.clone() else { return };
     // A layer's picker belongs to the selection it was opened for: once that changes, close it
     // rather than keep editing layers that are no longer selected.
     let stale = match &target {
-        PickerTarget::Paint { ids, .. } | PickerTarget::Effect { ids, .. } => *ids != app.session.selection(),
+        PickerTarget::Paint { ids, .. } | PickerTarget::Effect { ids, .. } => !same_layers(ids, &app.session.selection()),
         PickerTarget::TextRange { id, .. } => !app.session.selection().contains(id),
         _ => false,
     };
     let current = if stale { None } else { current_paint(app, &target) };
     let Some(mut paint) = current else {
+        finish_with(app, false);
         app.ui.picker = None;
         return;
     };
@@ -1172,4 +1298,332 @@ mod tests {
         write(&mut u.app, &target, &Paint::solid(c), false);
         assert_eq!(u.app.session.recent_colors, vec![c]);
     }
+
+    fn image_layer(session: &mut Session, filters: ImageFilters) -> NodeId {
+        image_layer_at(session, filters, 0.0)
+    }
+
+    fn image_layer_at(session: &mut Session, filters: ImageFilters, x: f64) -> NodeId {
+        let id: NodeId =
+            serde_json::from_value(session.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap()["id"].clone())
+                .unwrap();
+        let mut paint = Paint::image("test-image", ImageScaleMode::Fill);
+        if let PaintKind::Image(image) = &mut paint.kind {
+            image.filters = filters;
+        }
+        session.execute("fill.set", &json!({"ids": [id], "index": 0, "paint": paint})).unwrap();
+        id
+    }
+
+    #[test]
+    fn image_picker_follows_single_selected_image_and_reads_its_filters() {
+        let mut session = Session::new();
+        let first = image_layer(&mut session, ImageFilters::default());
+        let next_filters =
+            ImageFilters { exposure: 0.65, contrast: -0.2, saturation: 0.35, temperature: -0.45, tint: 0.15, highlights: -0.55, shadows: 0.25 };
+        let next = image_layer(&mut session, next_filters);
+        session.set_selection(vec![first]);
+        let mut app = FigApp::new(session, Box::new(crate::NoServices));
+        app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![first] });
+
+        app.session.set_selection(vec![next]);
+        assert!(sync_picker_target(&mut app));
+        assert_eq!(app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![next] }));
+        let PaintKind::Image(image) = current_paint(&app, app.ui.picker.as_ref().unwrap()).unwrap().kind else { panic!("image paint") };
+        assert_eq!(image.filters, next_filters);
+
+        let target = app.ui.picker.as_ref().unwrap().clone();
+        let mut changed = current_paint(&app, &target).unwrap();
+        let PaintKind::Image(image) = &mut changed.kind else { panic!("image paint") };
+        image.filters.saturation = -0.8;
+        write(&mut app, &target, &changed, false);
+        let first_paint = current_paint(&app, &PickerTarget::Paint { fills: true, index: 0, ids: vec![first] }).unwrap();
+        let next_paint = current_paint(&app, &target).unwrap();
+        let (PaintKind::Image(first_image), PaintKind::Image(next_image)) = (first_paint.kind, next_paint.kind) else { panic!("image paints") };
+        assert_eq!(first_image.filters, ImageFilters::default(), "the old image was not edited");
+        assert_eq!(next_image.filters.saturation, -0.8, "the new image received the edit");
+    }
+
+    #[test]
+    fn image_picker_for_two_selected_layers_stays_open_and_edits_both() {
+        let mut session = Session::new();
+        let first = image_layer(&mut session, ImageFilters::default());
+        let second = image_layer_at(&mut session, ImageFilters::default(), 30.0);
+        session.set_selection(vec![first, second]);
+        let mut app = FigApp::new(session, Box::new(crate::NoServices));
+        let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![first, second] };
+        app.ui.picker = Some(target.clone());
+
+        assert!(sync_picker_target(&mut app));
+        assert_eq!(app.ui.picker, Some(target.clone()));
+        let mut changed = current_paint(&app, &target).unwrap();
+        let PaintKind::Image(image) = &mut changed.kind else { panic!("image paint") };
+        image.filters.saturation = -0.8;
+        write(&mut app, &target, &changed, false);
+        for id in [first, second] {
+            let paint = current_paint(&app, &PickerTarget::Paint { fills: true, index: 0, ids: vec![id] }).unwrap();
+            let PaintKind::Image(image) = paint.kind else { panic!("image paint") };
+            assert_eq!(image.filters.saturation, -0.8, "both layers were edited");
+        }
+
+        app.session.set_selection(vec![first]);
+        assert!(sync_picker_target(&mut app));
+        assert_eq!(app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![first] }), "a different selection retargets");
+    }
+
+    #[test]
+    fn dragging_a_text_colour_while_editing_records_the_final_colour() {
+        let mut session = Session::new();
+        let text = NodeId(session.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()["id"].as_u64().unwrap());
+        session.execute("text.edit.begin", &json!({"id": text})).unwrap();
+        let mut app = FigApp::new(session, Box::new(crate::NoServices));
+        let target = PickerTarget::TextRange { id: text, start: 0, end: 5 };
+        app.ui.picker = Some(target.clone());
+        for hex in ["#110000", "#220000", "#330000"] {
+            write(&mut app, &target, &Paint::solid(Color::from_hex(hex).unwrap()), true);
+        }
+        assert!(app.session.recent_colors.is_empty(), "previews are not recorded");
+        assert_eq!(app.session.ds().unwrap().interaction.as_ref().map(|interaction| interaction.label.as_str()), Some("Edit text"));
+
+        finish(&mut app);
+        assert_eq!(app.session.recent_colors, vec![Color::from_hex("#330000").unwrap()]);
+        assert!(app.session.ds().unwrap().text_edit.is_some(), "the text edit goes on");
+    }
+
+    #[test]
+    fn document_switch_keeps_a_non_image_picker_open() {
+        let mut app = FigApp::new(Session::new(), Box::new(crate::NoServices));
+        app.ui.picker = Some(PickerTarget::PageBackground);
+        assert!(sync_picker_target(&mut app));
+        app.session.execute("file.new", &json!({"name": "Other"})).unwrap();
+
+        assert!(sync_picker_target(&mut app));
+        assert_eq!(app.ui.picker, Some(PickerTarget::PageBackground));
+    }
+
+    #[test]
+    fn selection_order_does_not_close_a_multi_layer_picker() {
+        let mut u = Ui::new(Session::new());
+        let first = image_layer(&mut u.app.session, ImageFilters::default());
+        let second = image_layer_at(&mut u.app.session, ImageFilters::default(), 30.0);
+        u.app.session.set_selection(vec![first, second]);
+        let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![first, second] };
+        u.app.ui.picker = Some(target.clone());
+        u.frame(vec![]);
+        u.app.session.set_selection(vec![second, first]);
+        u.frame(vec![]);
+        assert_eq!(u.app.ui.picker, Some(target), "the same layers in another order keep the popup open");
+    }
+
+    #[test]
+    fn closing_a_fill_picker_for_another_selection_records_no_recent_colour() {
+        let mut session = Session::new();
+        let first = NodeId(session.execute("shape.rectangle", &json!({"width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
+        let other = NodeId(session.execute("shape.rectangle", &json!({"x": 40, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
+        session.execute("fill.set", &json!({"ids": [first], "color": "#FF0000"})).unwrap();
+        session.set_selection(vec![first]);
+        let mut u = Ui::new(session);
+        u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![first] });
+        u.frame(vec![]);
+        // Pressing another layer starts a Move interaction and selects it.
+        u.app.session.begin_interaction("Move").unwrap();
+        u.app.session.set_selection(vec![other]);
+        u.frame(vec![]);
+        assert!(u.app.ui.picker.is_none(), "the picker closed");
+        assert!(u.app.session.recent_colors.is_empty(), "no colour was picked: {:?}", u.app.session.recent_colors);
+    }
+
+    #[test]
+    fn image_picker_closes_for_empty_or_non_image_selection() {
+        for select_image in [false, true] {
+            let mut session = Session::new();
+            let image = image_layer(&mut session, ImageFilters::default());
+            let other: NodeId = serde_json::from_value(
+                session.execute("shape.rectangle", &json!({"x": 30, "y": 0, "width": 20, "height": 20})).unwrap()["id"].clone(),
+            )
+            .unwrap();
+            session.set_selection(vec![image]);
+            let mut app = FigApp::new(session, Box::new(crate::NoServices));
+            app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![image] });
+            app.session.set_selection(if select_image { vec![other] } else { Vec::new() });
+
+            assert!(!sync_picker_target(&mut app));
+            assert!(app.ui.picker.is_none());
+        }
+    }
+
+    #[test]
+    fn retargeting_does_not_finish_a_canvas_interaction() {
+        let mut session = Session::new();
+        let first = image_layer(&mut session, ImageFilters::default());
+        let next = image_layer(&mut session, ImageFilters::default());
+        session.set_selection(vec![first]);
+        let mut app = FigApp::new(session, Box::new(crate::NoServices));
+        app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![first] });
+        app.session.begin_interaction("Move").unwrap();
+        app.session.set_selection(vec![next]);
+
+        assert!(sync_picker_target(&mut app));
+        assert_eq!(app.session.ds().unwrap().interaction.as_ref().map(|interaction| interaction.label.as_str()), Some("Move"));
+        assert_eq!(app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![next] }));
+    }
+
+    #[test]
+    fn image_picker_uses_first_image_slot_when_old_slot_is_missing() {
+        let mut session = Session::new();
+        let first = image_layer(&mut session, ImageFilters::default());
+        session.execute("fill.set", &json!({"ids": [first], "index": 0, "color": "#FF0000"})).unwrap();
+        session.execute("fill.add", &json!({"ids": [first], "paint": Paint::image("first-image-second-slot", ImageScaleMode::Fill)})).unwrap();
+        let next = image_layer(&mut session, ImageFilters { exposure: -0.35, ..ImageFilters::default() });
+        let second = Paint::image("second-image", ImageScaleMode::Fill);
+        session.execute("fill.set", &json!({"ids": [next], "index": 0, "paint": second})).unwrap();
+        session.execute("stroke.add", &json!({"ids": [next], "paint": Paint::image("stroke-image", ImageScaleMode::Fill)})).unwrap();
+        session.set_selection(vec![first]);
+        let mut app = FigApp::new(session, Box::new(crate::NoServices));
+        app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![first] });
+        assert!(sync_picker_target(&mut app));
+
+        app.session.set_selection(vec![next]);
+        assert!(sync_picker_target(&mut app));
+        assert_eq!(app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![next] }));
+        let PaintKind::Image(image) = current_paint(&app, app.ui.picker.as_ref().unwrap()).unwrap().kind else { panic!("image paint") };
+        assert_eq!(image.image, "second-image");
+    }
+
+    #[test]
+    fn image_picker_closes_when_document_changes_even_with_reused_node_ids() {
+        let mut session = Session::new();
+        let image = image_layer(&mut session, ImageFilters::default());
+        session.set_selection(vec![image]);
+        let mut app = FigApp::new(session, Box::new(crate::NoServices));
+        app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![image] });
+        assert!(sync_picker_target(&mut app));
+
+        app.session.execute("file.new", &json!({"name": "Other"})).unwrap();
+        let collision: NodeId = serde_json::from_value(
+            app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].clone(),
+        )
+        .unwrap();
+        assert_eq!(collision, image);
+        app.session.execute("fill.set", &json!({"ids": [collision], "color": "#FF0000"})).unwrap();
+
+        assert!(!sync_picker_target(&mut app));
+        assert!(app.ui.picker.is_none());
+        assert!(app.ui.picker_context.is_none());
+    }
+
+    #[test]
+    fn document_switch_finishes_color_edit_in_owner_without_touching_new_document() {
+        let mut session = Session::new();
+        let image = image_layer(&mut session, ImageFilters::default());
+        session.set_selection(vec![image]);
+        let mut app = FigApp::new(session, Box::new(crate::NoServices));
+        let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![image] };
+        app.ui.picker = Some(target.clone());
+        assert!(sync_picker_target(&mut app));
+
+        let mut paint = current_paint(&app, &target).unwrap();
+        if let PaintKind::Image(image) = &mut paint.kind {
+            image.filters.exposure = 0.8;
+        }
+        write(&mut app, &target, &paint, true);
+        let owner_uid = app.session.ds().unwrap().uid;
+        app.session.execute("file.new", &json!({"name": "Other"})).unwrap();
+        let new_uid = app.session.ds().unwrap().uid;
+        app.session.begin_interaction("Move").unwrap();
+
+        assert!(!sync_picker_target(&mut app));
+        assert!(app.ui.picker.is_none());
+        assert!(app.session.docs[0].interaction.is_none(), "old Color interaction was committed");
+        assert_eq!(app.session.docs[0].history.undo.last().map(|entry| entry.label.as_str()), Some("Color"));
+        assert_ne!(new_uid, owner_uid);
+        assert_eq!(app.session.ds().unwrap().uid, new_uid);
+        assert_eq!(app.session.ds().unwrap().interaction.as_ref().map(|interaction| interaction.label.as_str()), Some("Move"));
+
+        app.session.active = 0;
+        app.session.execute("edit.undo", &json!({})).unwrap();
+        let PaintKind::Image(restored) = app.session.doc().unwrap().node(image).unwrap().fills[0].kind.clone() else { panic!("image paint") };
+        assert_eq!(restored.filters.exposure, 0.0, "undo restored only the old image adjustment");
+        app.session.active = 1;
+        assert_eq!(app.session.ds().unwrap().interaction.as_ref().map(|interaction| interaction.label.as_str()), Some("Move"));
+    }
+
+    #[test]
+    fn page_switch_preserves_non_image_picker() {
+        for paint in [Paint::solid(Color::BLACK), Paint::image("test-image", ImageScaleMode::Fill)] {
+            let mut session = Session::new();
+            let created = session.execute("style.create", &json!({"type": "paint", "name": "Test paint", "value": [paint]})).unwrap();
+            let target = PickerTarget::Style { id: created["id"].as_str().unwrap().into(), index: 0 };
+            let mut app = FigApp::new(session, Box::new(crate::NoServices));
+            app.ui.picker = Some(target.clone());
+            assert_eq!(current_paint(&app, &target), Some(paint.clone()));
+            assert!(sync_picker_target(&mut app));
+            let page = serde_json::from_value::<NodeId>(app.session.execute("page.add", &json!({})).unwrap()["id"].clone()).unwrap();
+            app.session.execute("page.select", &json!({"id": page})).unwrap();
+
+            assert!(sync_picker_target(&mut app));
+            assert_eq!(app.ui.picker, Some(target.clone()));
+            assert_eq!(current_paint(&app, &target), Some(paint));
+        }
+    }
+
+    #[test]
+    fn selecting_and_dragging_an_image_layer_keeps_its_move_undoable() {
+        use egui::Event;
+
+        let mut session = Session::new();
+        let first = image_layer(&mut session, ImageFilters { exposure: 0.4, ..ImageFilters::default() });
+        let next = image_layer_at(&mut session, ImageFilters { exposure: -0.4, ..ImageFilters::default() }, 300.0);
+        session.set_selection(vec![first]);
+        let mut ui = Ui::new(session);
+        ui.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![first] });
+        ui.frame(vec![]);
+
+        let before = ui.app.session.doc().unwrap().world_bounds(next).unwrap().center();
+        let start = ui.app.to_screen(before.x, before.y);
+        let end = start + egui::vec2(60.0, 24.0);
+        ui.button(start, true);
+        ui.frame(vec![Event::PointerMoved(start + egui::vec2(30.0, 12.0))]);
+        ui.frame(vec![Event::PointerMoved(end)]);
+        ui.button(end, false);
+
+        let after = ui.app.session.doc().unwrap().world_bounds(next).unwrap().center();
+        assert!(after.x > before.x + 40.0, "the newly selected image moved");
+        let first_paint = current_paint(&ui.app, &PickerTarget::Paint { fills: true, index: 0, ids: vec![first] }).unwrap();
+        let next_paint = current_paint(&ui.app, &PickerTarget::Paint { fills: true, index: 0, ids: vec![next] }).unwrap();
+        let (PaintKind::Image(first_image), PaintKind::Image(next_image)) = (first_paint.kind, next_paint.kind) else { panic!("image paints") };
+        assert_eq!(first_image.filters.exposure, 0.4, "the old image stayed unchanged");
+        assert_eq!(next_image.filters.exposure, -0.4, "the new image stayed unchanged");
+
+        ui.app.session.execute("edit.undo", &json!({})).unwrap();
+        let restored = ui.app.session.doc().unwrap().world_bounds(next).unwrap().center();
+        assert!((restored.x - before.x).abs() < 0.01, "one undo restored the new image position");
+    }
+
+    #[test]
+    fn same_frame_canvas_click_keeps_image_picker_open_on_new_selection() {
+        use egui::{Event, PointerButton};
+
+        let mut session = Session::new();
+        let first = image_layer(&mut session, ImageFilters { exposure: 0.4, ..ImageFilters::default() });
+        let next = image_layer_at(&mut session, ImageFilters { exposure: -0.4, ..ImageFilters::default() }, 300.0);
+        session.set_selection(vec![first]);
+        let mut ui = Ui::new(session);
+        ui.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![first] });
+        ui.frame(vec![]);
+
+        let bounds = ui.app.session.doc().unwrap().world_bounds(next).unwrap();
+        let at = ui.app.to_screen(bounds.center().x, bounds.center().y);
+        ui.frame(vec![
+            Event::PointerMoved(at),
+            Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() },
+            Event::PointerMoved(at),
+            Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() },
+        ]);
+
+        assert_eq!(ui.app.session.selection(), vec![next], "the click selected the image");
+        assert_eq!(ui.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![next] }), "the popup stayed open and followed it");
+        let PaintKind::Image(image) = current_paint(&ui.app, ui.app.ui.picker.as_ref().unwrap()).unwrap().kind else { panic!("image paint") };
+        assert_eq!(image.filters.exposure, -0.4, "the new image's filter values loaded");
+    }
 }
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
@@ -432,6 +432,8 @@ pub struct UiState {
     /// The prototype preview pane floating over the canvas, when open.
     pub preview: Option<present::Pane>,
     pub picker: Option<colorpicker::PickerTarget>,
+    /// The picker target and document/page it belongs to, for safe retargeting across tabs.
+    pub(crate) picker_context: Option<(colorpicker::PickerTarget, u64, NodeId, bool)>,
     pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
     /// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the
     /// focused layer row or the canvas selection and takes the keyboard.
@@ -550,6 +552,7 @@ impl Default for UiState {
             present_view: present::PresentView::default(),
             preview: None,
             picker: None,
+            picker_context: None,
             context_menu: None,
             key_menu: false,
             key_box: None,
