diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs
--- a/crates/engine/src/transform_cmds.rs
+++ b/crates/engine/src/transform_cmds.rs
@@ -161,12 +161,25 @@ pub(crate) fn move_gray_selected(
     warp: &dyn Fn(&Surface, Rect) -> Result<Surface>,
     whole: &dyn Fn(&Surface) -> Result<Surface>,
 ) -> Result<Surface> {
-    let Some((lifted, mut out)) = split_gray_selected(s, sel)? else { return whole(s) };
+    let Some((lifted, out)) = split_gray_selected(s, sel)? else { return whole(s) };
+    lay_moved(out, &lifted, warp)
+}
+
+/// A lone mask or channel as Free Transform leaves it, for the live preview of the box: `rest`
+/// with `lifted` moved through `h` laid over it (both from [`split_gray_selected`]). Refused
+/// past the transform budget, as the commit is.
+pub fn moved_gray(rest: &Surface, lifted: &Surface, h: &Homography, interp: Interp) -> Result<Surface> {
+    check_budget(lifted.content_bounds(), h, "mask or channel", rest.format())?;
+    lay_moved(rest.clone(), lifted, &|l, src| Ok(warp_surface(l, src, h, interp)?))
+}
+
+/// `out` with the grey + alpha values of `lifted`, moved by `warp`, laid over it.
+fn lay_moved(mut out: Surface, lifted: &Surface, warp: &dyn Fn(&Surface, Rect) -> Result<Surface>) -> Result<Surface> {
     let src = lifted.content_bounds();
     if src.is_empty() {
         return Ok(out);
     }
-    let w = warp(&lifted, src)?;
+    let w = warp(lifted, src)?;
     let b = w.content_bounds();
     if !b.is_empty() {
         let moved = crate::allocation::read_region(&w, b, "compositing transformed mask pixels")?;
@@ -1374,6 +1387,41 @@ mod tests {
         assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "pixels untouched");
     }
 
+    /// The live preview's mask is the one the commit leaves, with and without a selection.
+    #[test]
+    fn moved_gray_matches_the_committed_mask() {
+        for selected in [false, true] {
+            let mut s = masked(&[Rect::new(10, 10, 20, 20)], false);
+            if selected {
+                s.execute("select.rect", json!({"x": 10, "y": 10, "width": 5, "height": 10})).unwrap();
+            }
+            let st = s.active().unwrap();
+            let id = st.active_layer;
+            let surf = lone_target(&st.doc, id, &json!({"target": "mask"})).unwrap().unwrap().clone();
+            let sel = st.doc.selection.clone().unwrap_or_else(|| {
+                let mut w = Surface::new(PixelFormat::GRAY8);
+                w.fill_rect(target_frame(&st.doc, &surf), &[1.0]);
+                w
+            });
+            let (lifted, rest) = split_gray_selected(&surf, &sel).unwrap().unwrap();
+            let h = Homography([1.0, 0.0, 7.0, 0.0, 1.0, 30.0, 0.0, 0.0, 1.0]);
+            let live = moved_gray(&rest, &lifted, &h, Interp::Bicubic).unwrap();
+            s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 7, 30], "target": "mask"})).unwrap();
+            let st = s.active().unwrap();
+            let committed = &st.doc.layer(id.unwrap()).unwrap().mask.as_ref().unwrap().surface;
+            let area = Rect::new(0, 0, 64, 64);
+            assert_eq!(live.read_region(area), committed.read_region(area), "selected: {selected}");
+        }
+        // Past the budget it is refused, not built.
+        let s = masked(&[Rect::new(10, 10, 20, 20)], false);
+        let surf = lone_target(&s.active().unwrap().doc, s.active().unwrap().active_layer, &json!({"target": "mask"})).unwrap().unwrap().clone();
+        let mut sel = Surface::new(PixelFormat::GRAY8);
+        sel.fill_rect(Rect::new(0, 0, 64, 64), &[1.0]);
+        let (lifted, rest) = split_gray_selected(&surf, &sel).unwrap().unwrap();
+        let huge = Homography([1e6, 0.0, 0.0, 0.0, 1e6, 0.0, 0.0, 0.0, 1.0]);
+        assert!(moved_gray(&rest, &lifted, &huge, Interp::Bilinear).is_err());
+    }
+
     #[test]
     fn targeted_linked_mask_moves_with_its_layer() {
         // Photoshop: a linked mask can't move without its layer.
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -1530,7 +1530,8 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
         && pv.doc.id == st.doc.id
         && st.doc.layer(photocraft_doc::LayerId(t.layer)).is_some()
     {
-        return (pv.doc.clone(), (1 << 40) + pv.session);
+        let shown = (pv.doc.clone(), crate::transform_tool::PREVIEW_BASE + pv.session);
+        return crate::transform_tool::live_mask_doc(app).unwrap_or(shown);
     }
     // Layer Style dialog: show its effects live (Cancel just drops the preview).
     let style = app.ui.dialogs.iter().find(|d| d.kind == crate::state::DialogKind::LayerStyle);
@@ -2010,6 +2011,13 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no
     {
         return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
     }
+    // Between a lone layer mask's Free Transform frames: where its moving values are.
+    if seen.0 == now.0
+        && let Some(st) = app.session.documents().get(idx)
+        && let Some(r) = crate::transform_tool::live_mask_damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
+    {
+        return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
+    }
     // Between an adjustment dialog's previews (and the document): the target's area.
     if seen.0 == now.0
         && let Some(st) = app.session.documents().get(idx)
diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs
--- a/crates/ui-egui/src/transform_tool.rs
+++ b/crates/ui-egui/src/transform_tool.rs
@@ -9,6 +9,7 @@
 //! cancels. Undo and Redo step through the session's own changes (`Steps`), not the document's
 //! history.
 
+use std::collections::VecDeque;
 use std::sync::Arc;
 
 use egui::{Color32, CursorIcon, Pos2, Stroke, pos2, vec2};
@@ -91,6 +92,29 @@ pub struct TransformPreview {
     committing: Option<JobId>,
     /// Content-Aware Scale's live preview (cas_transform.rs).
     pub(crate) content_aware: Option<crate::cas_transform::Live>,
+    /// A lone layer mask's live preview ([`live_mask_doc`]).
+    live_mask: Option<LiveMask>,
+}
+
+/// Preview key of a session's `doc` (`canvas::display_doc`): `PREVIEW_BASE + session`.
+pub(crate) const PREVIEW_BASE: u64 = 1 << 40;
+/// Preview keys of a lone layer mask's live frames: `LIVE_MASK_BASE | id`.
+const LIVE_MASK_BASE: u64 = 1 << 43;
+
+/// The live preview of a lone layer mask (#9650): the document with the mask where the box puts
+/// it now, so the canvas shows the layer masked as the commit will leave it, as Photoshop does,
+/// instead of the mask's values in grey over the layer. The canvas recomposites only where the
+/// moving values are ([`live_mask_damage`]).
+struct LiveMask {
+    /// The moving values, grey + alpha (`split_gray_selected`).
+    lifted: photocraft_raster::Surface,
+    /// The revision of the document the session began from.
+    revision: u64,
+    /// The box the latest frame was built for, and that frame (`None` when it couldn't be).
+    quad: Option<[[f64; 2]; 4]>,
+    shown: Option<(Arc<Document>, u64)>,
+    /// Where each frame shown so far differs from `doc` (the values vacated), by preview key.
+    areas: VecDeque<(u64, photocraft_geom::Rect)>,
 }
 
 /// What [`follow_edits`] needs to rebuild the preview document.
@@ -263,6 +287,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
         tool: app.ui.tool,
         path: None,
         content_aware: None,
+        live_mask: None,
     });
     app.ui.transform = Some(TransformSession {
         session,
@@ -363,6 +388,7 @@ pub(crate) fn begin_content_aware(
         tool: app.ui.tool,
         path: None,
         content_aware: Some(live),
+        live_mask: None,
         styled: None,
     });
     app.ui.transform = Some(TransformSession {
@@ -459,6 +485,10 @@ fn begin_lone(
     };
     let (lifted, rest) = tc::split_gray_selected(&surf, sel).map_err(|e| e.to_string())?.ok_or("this channel can't be transformed")?;
     crate::type_tool::commit(app);
+    // Alpha channels and the Quick Mask keep the grey preview: only a layer mask shows through
+    // a layer.
+    let revision = app.session.active().map_or(0, |st| st.revision);
+    let live_mask = (target == json!("mask")).then(|| LiveMask { lifted: lifted.clone(), revision, quad: None, shown: None, areas: VecDeque::new() });
     let layer = app.session.active().and_then(|st| st.active_layer).map_or(0, |l| l.0);
     let mut pd = (*doc).clone();
     let tp = json!({ "target": target });
@@ -489,6 +519,7 @@ fn begin_lone(
         tool: app.ui.tool,
         path: None,
         content_aware: None,
+        live_mask,
     });
     app.ui.transform = Some(TransformSession {
         session,
@@ -510,6 +541,62 @@ fn begin_lone(
     Ok(())
 }
 
+/// The document to show while a lone layer mask's box is open: the mask where the box puts it
+/// now ([`LiveMask`]), built again only when the box changes. `None` for other sessions, or
+/// when the frame can't be built (past the transform budget, a degenerate box): the canvas then
+/// shows the mask vacated and [`draw_content`] the grey values.
+pub(crate) fn live_mask_doc(app: &mut PhotocraftApp) -> Option<(Arc<Document>, u64)> {
+    let (t, pv) = (app.ui.transform.as_ref()?, app.transform_preview.as_ref()?);
+    let live = pv.live_mask.as_ref()?;
+    if live.quad == Some(t.quad) {
+        return live.shown.clone();
+    }
+    let key = LIVE_MASK_BASE | (app.ui.alloc_id() & (LIVE_MASK_BASE - 1));
+    let started = crate::gpu_canvas::now_ms();
+    let (t, pv) = (app.ui.transform.as_ref()?, app.transform_preview.as_mut()?);
+    let live = pv.live_mask.as_mut()?;
+    live.quad = Some(t.quad);
+    live.shown = None;
+    // Bilinear keeps a frame cheap while the box moves; the commit uses the chosen interpolation.
+    let interp = if t.interpolation == "nearest" { photocraft_algo::transform::Interp::Nearest } else { photocraft_algo::transform::Interp::Bilinear };
+    let h = Homography::rect_to_quad(t.rect, t.quad)?;
+    let mut doc = (*pv.doc).clone();
+    let params = json!({ "target": t.target.clone()? });
+    let surf = photocraft_engine::transform_cmds::lone_target_mut(&mut doc, Some(LayerId(t.layer)), &params).ok()??;
+    *surf = photocraft_engine::transform_cmds::moved_gray(surf, &live.lifted, &h, interp).ok()?;
+    let (min, max) = t.quad.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), q| ([lo[0].min(q[0]), lo[1].min(q[1])], [hi[0].max(q[0]), hi[1].max(q[1])]));
+    let edge = |v: f64| v.clamp(-1e9, 1e9) as i32;
+    let area =
+        photocraft_geom::Rect::new(edge(min[0].floor()), edge(min[1].floor()), edge(max[0].ceil()), edge(max[1].ceil())).inflate(2).intersect(&doc.bounds());
+    if live.areas.len() >= 128 {
+        live.areas.pop_front();
+    }
+    live.areas.push_back((key, area));
+    live.shown = Some((Arc::new(doc), key));
+    let shown = live.shown.clone();
+    app.perf.span("transform mask preview", crate::gpu_canvas::now_ms() - started);
+    shown
+}
+
+/// What changed between preview keys `seen` and `now` of document `doc` at `revision` while a lone
+/// layer mask's box is open: where the moving values are in either frame. `None` when either key
+/// isn't one of this session's (the canvas then recomposites everything).
+pub(crate) fn live_mask_damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, now: u64) -> Option<photocraft_geom::Rect> {
+    let pv = app.transform_preview.as_ref().filter(|pv| pv.doc.id == doc)?;
+    let live = pv.live_mask.as_ref().filter(|l| l.revision == revision)?;
+    let area = |key: u64| {
+        if key == PREVIEW_BASE + pv.session { Some(photocraft_geom::Rect::EMPTY) } else { live.areas.iter().find(|(k, _)| *k == key).map(|(_, r)| *r) }
+    };
+    let (a, b) = (area(seen)?, area(now)?);
+    Some(if a.is_empty() {
+        b
+    } else if b.is_empty() {
+        a
+    } else {
+        a.union(&b)
+    })
+}
+
 /// Edit › Free Transform Path: the box frames the path's anchors and handles and draws the
 /// path through it; OK commits one `path.transform` step.
 fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json::Value, path: photocraft_doc::vector::Path) -> Result<(), String> {
@@ -539,6 +626,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json::
         tool: app.ui.tool,
         path: Some(path),
         content_aware: None,
+        live_mask: None,
         above: None,
         follow: None,
         committing: None,
@@ -627,6 +715,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(
         tool: app.ui.tool,
         path: None,
         content_aware: None,
+        live_mask: None,
     });
     app.ui.transform = Some(TransformSession {
         session,
@@ -2231,6 +2320,8 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
         mesh.add_triangle(0, 1, 2);
         mesh.add_triangle(0, 2, 3);
         painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y)));
+    } else if pv.live_mask.as_ref().is_some_and(|l| l.shown.is_some()) {
+        // A lone layer mask shows in the document itself (`live_mask_doc`).
     } else if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) {
         // A 24×24 grid keeps perspective previews straight.
         let n = 24;
@@ -6148,6 +6239,87 @@ mod tests {
         assert!(app.ui.transform.as_ref().unwrap().warp.is_none());
     }
 
+    /// #9650: the canvas shows the layer masked by the mask where the box puts it, rebuilt only
+    /// when the box changes, and recomposites only where the mask moves.
+    #[test]
+    fn a_lone_mask_previews_live_through_its_layer() {
+        let mut app = masked_app(false);
+        app.ui.mask_target = true;
+        begin(&mut app, &gpu_ctx()).unwrap();
+        let (id, revision) = {
+            let st = app.session.active().unwrap();
+            (st.doc.id, st.revision)
+        };
+        let layer = app.session.active().unwrap().active_layer.unwrap();
+        let mask_at = |d: &Document, x, y| d.layer(layer).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
+        // Green over the white Background where the mask hides the red square, none where it shows.
+        let shows = |d: &Document, x, y| photocraft_compose::render(d, photocraft_geom::Rect::new(x, y, x + 1, y + 1)).px[0][1] < 0.01;
+        // Untouched, the canvas shows the mask where it is, not vacated.
+        let (first, k0) = crate::canvas::display_doc(&mut app, 0);
+        assert!(mask_at(&first, 4, 4) < 0.01 && mask_at(&first, 30, 4) > 0.99);
+        assert!(!shows(&first, 12, 12) && shows(&first, 20, 12), "x < 16 of the square is hidden");
+        let xf = ViewXform {
+            rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(128.0, 128.0)),
+            zoom: 2.0,
+            center: [32.0, 32.0],
+            flip: false,
+            rotation: 0.0,
+            aspect: 1.0,
+        };
+        assert!(preview_meshes(&app, &xf).is_empty(), "no grey mesh over the layer");
+        // 6 px right: the hidden part follows the box, the pixels stay.
+        let t = app.ui.transform.as_mut().unwrap();
+        t.quad = t.quad.map(|[x, y]| [x + 6.0, y]);
+        let (moved, k1) = crate::canvas::display_doc(&mut app, 0);
+        assert_ne!(k0, k1);
+        assert!(!shows(&moved, 20, 12) && shows(&moved, 23, 12), "hidden up to x 22 now");
+        assert!(mask_at(&moved, 2, 4) > 0.99, "the vacated strip reveals");
+        assert_eq!(moved.layer(layer).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(8, 8, 24, 24), "pixels untouched");
+        // The same box again builds nothing.
+        let (again, k2) = crate::canvas::display_doc(&mut app, 0);
+        assert_eq!(k1, k2);
+        assert!(Arc::ptr_eq(&moved, &again));
+        // Damage: where the mask's values are in either frame, not the whole canvas.
+        assert_eq!(live_mask_damage(&app, id, revision, k0, k1), Some(photocraft_geom::Rect::new(0, 0, 24, 64)));
+        let vacated = PREVIEW_BASE + app.transform_preview.as_ref().unwrap().session;
+        assert_eq!(live_mask_damage(&app, id, revision, vacated, k1), Some(photocraft_geom::Rect::new(4, 0, 24, 64)));
+        assert_eq!(live_mask_damage(&app, id, revision + 1, k0, k1), None, "another revision");
+        assert_eq!(live_mask_damage(&app, id, revision, 12345, k1), None, "a key of no frame");
+        // A box the engine refuses falls back to the grey values over the vacated mask.
+        let t = app.ui.transform.as_mut().unwrap();
+        t.quad = [[0.0, 0.0], [1e7, 0.0], [1e7, 1e7], [0.0, 1e7]];
+        let (_, k3) = crate::canvas::display_doc(&mut app, 0);
+        assert_eq!(k3, vacated);
+        assert!(!preview_meshes(&app, &xf).is_empty(), "the grey values show instead");
+        // The commit leaves the mask the preview showed.
+        let t = app.ui.transform.as_mut().unwrap();
+        t.quad = corners(t.rect).map(|[x, y]| [x + 6.0, y]);
+        commit(&mut app);
+        assert!(!app.ui.status_error, "{}", app.ui.status);
+        let st = app.session.active().unwrap();
+        for x in [2, 20, 23, 40] {
+            assert!((mask_at(&st.doc, x, 4) - mask_at(&moved, x, 4)).abs() < 0.01, "x {x}");
+        }
+    }
+
+    /// An alpha channel keeps the grey preview: it masks no layer.
+    #[test]
+    fn a_lone_alpha_channel_keeps_the_grey_preview() {
+        let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
+        app.run("channel.new", json!({"name": "Alpha", "fill": "black"})).unwrap();
+        app.session
+            .edit("Fixture", |doc, _| {
+                doc.channels[0].surface.fill_rect(photocraft_geom::Rect::new(4, 4, 20, 20), &[1.0]);
+                Ok(())
+            })
+            .unwrap();
+        app.session.active_mut().unwrap().channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::Alpha(0);
+        begin(&mut app, &gpu_ctx()).unwrap();
+        assert_eq!(app.ui.transform.as_ref().unwrap().target, Some(json!({"channel": 0})));
+        assert!(app.transform_preview.as_ref().unwrap().live_mask.is_none());
+        assert!(live_mask_doc(&mut app).is_none());
+    }
+
     #[test]
     fn linked_mask_target_transforms_the_layer() {
         let mut app = masked_app(true);
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
--- a/crates/ui-egui/tests/it/main.rs
+++ b/crates/ui-egui/tests/it/main.rs
@@ -29,6 +29,7 @@ mod pattern_preview_canvas;
 mod radial_blur_center;
 mod remove_channel_target;
 mod system_locale;
+mod transform_lone_mask_live;
 mod ui_scale_canvas;
 mod variables_data_sets;
 mod workspace_presets;
diff --git a/crates/ui-egui/tests/it/transform_lone_mask_live.rs b/crates/ui-egui/tests/it/transform_lone_mask_live.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/tests/it/transform_lone_mask_live.rs
@@ -0,0 +1,136 @@
+//! #9650: while Free Transform moves a targeted unlinked layer mask, the canvas shows the layer
+//! masked by the mask where the box puts it, as Photoshop does, not the mask's values in grey
+//! over the unmasked layer.
+//!
+//! The real app runs offscreen on wgpu with real egui pointer events. Skips when no GPU adapter
+//! exists (like `drag_preview_canvas.rs`). Set `PHOTOCRAFT_TRANSFORM_MASK_EVIDENCE_DIR` to save
+//! the frames as PNGs.
+
+use egui::{Modifiers, PointerButton, Pos2};
+use photocraft_ui_egui::PhotocraftApp;
+use photocraft_ui_egui::canvas::ViewXform;
+use photocraft_ui_egui::control::{ControlRequest, handle};
+use serde_json::{Value, json};
+
+type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
+
+fn harness() -> Option<Harness> {
+    let built = std::panic::catch_unwind(|| {
+        egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_max_steps(64).wgpu().build_eframe(|cc| {
+            PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
+            let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
+            app.session.prefs.edit(|p| p.interface.language = "en".into());
+            if let Some(rs) = cc.wgpu_render_state.as_ref() {
+                app.set_wgpu(rs.clone());
+            }
+            app
+        })
+    });
+    match built {
+        Ok(h) => Some(h),
+        Err(_) => {
+            eprintln!("skipping: no GPU adapter");
+            None
+        }
+    }
+}
+
+fn control(h: &mut Harness, method: &str, params: Value) {
+    let ctx = h.ctx.clone();
+    let (req, _rx) = ControlRequest::new(method, params);
+    handle(h.state_mut(), &ctx, &req);
+    h.run_steps(3);
+}
+
+fn press(h: &mut Harness, p: Pos2, pressed: bool) {
+    h.event(egui::Event::PointerMoved(p));
+    h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
+    h.step();
+}
+
+/// The screen colours at document points `at` (y 175) in a frame of the app, saved as
+/// `transform-mask-{name}.png` when evidence is asked for.
+fn colors(h: &mut Harness, name: &str, at: &[f32]) -> Vec<[u8; 3]> {
+    let xf = ViewXform::active(h.state()).expect("a view");
+    let ppp = h.ctx.pixels_per_point();
+    let img = h.render().expect("render");
+    if let Some(dir) = std::env::var_os("PHOTOCRAFT_TRANSFORM_MASK_EVIDENCE_DIR") {
+        let dir = std::path::PathBuf::from(dir);
+        std::fs::create_dir_all(&dir).expect("evidence directory");
+        img.save(dir.join(format!("transform-mask-{name}.png"))).expect("save evidence");
+    }
+    at.iter()
+        .map(|x| {
+            let p = xf.to_screen(*x, 175.0);
+            let px = img.get_pixel((p.x * ppp) as u32, (p.y * ppp) as u32).0;
+            [px[0], px[1], px[2]]
+        })
+        .collect()
+}
+
+/// Checks the frame `name`: white (hidden by the mask) or blue at each x.
+fn check(h: &mut Harness, name: &str, want: &[(f32, bool)]) {
+    let xs: Vec<f32> = want.iter().map(|w| w.0).collect();
+    for ((x, hidden), c) in want.iter().zip(colors(h, name, &xs)) {
+        assert!(if *hidden { white(c) } else { blue(c) }, "{name}: x {x} reads {c:?}");
+    }
+}
+
+fn white(c: [u8; 3]) -> bool {
+    c.iter().all(|v| *v > 235)
+}
+
+fn blue(c: [u8; 3]) -> bool {
+    c[2] > 180 && c[0] < 80 && c[1] < 80
+}
+
+#[test]
+fn a_lone_mask_moves_live_through_its_layer() {
+    let _gpu = photocraft_testkit::gpu_lock();
+    let Some(mut h) = harness() else { return };
+    h.run_steps(4);
+    {
+        // A blue layer over 100..300 × 100..250 on white, its unlinked mask hiding x 100..200.
+        let app = h.state_mut();
+        app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).expect("new");
+        app.run("layer.new.layer", json!({})).expect("layer");
+        app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 150})).expect("select");
+        app.run("edit.fill", json!({"color": "#0000ff"})).expect("fill");
+        app.run("select.deselect", json!({})).expect("deselect");
+        app.run("layer.layerMask.revealAll", json!({})).expect("mask");
+        app.session
+            .edit("Fixture", |doc, a| {
+                let m = doc.layer_mut(a.expect("layer")).expect("layer").mask.as_mut().expect("mask");
+                m.surface.fill_rect(photocraft_geom::Rect::new(100, 0, 200, 300), &[0.0]);
+                m.linked = false;
+                Ok(())
+            })
+            .expect("fixture");
+        app.sync_views();
+        app.ui.mask_target = true;
+        app.ui.extras.snap = false;
+        app.ui.view.show.smart_guides = false;
+    }
+    control(&mut h, "ui.set", json!({"tool": "move", "zoom": 1.0, "center": [200, 150]}));
+    control(&mut h, "ui.menu.invoke", json!({"id": "edit.freeTransform"}));
+    assert_eq!(h.state().ui.transform.as_ref().and_then(|t| t.target.clone()), Some(json!("mask")), "the box moves the mask");
+    // The mask hides x 100..200 when the box opens.
+    check(&mut h, "open", &[(150.0, true), (250.0, false)]);
+    let xf = ViewXform::active(h.state()).expect("a view");
+    // Drag the box 60 px right (from below the reference point, which a press would move
+    // instead): the hidden band follows it to x 160..260.
+    let (from, to) = (xf.to_screen(150.0, 220.0), xf.to_screen(210.0, 220.0));
+    press(&mut h, from, true);
+    for i in 1..=8 {
+        h.event(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
+        h.step();
+    }
+    h.run_steps(2);
+    let moved = [(130.0, false), (180.0, true), (240.0, true), (280.0, false)];
+    check(&mut h, "dragged", &moved);
+    press(&mut h, to, false);
+    h.key_press(egui::Key::Enter);
+    h.run_steps(4);
+    assert!(h.state().ui.transform.is_none(), "applied");
+    check(&mut h, "applied", &moved);
+}
diff --git a/docs/scorecard.md b/docs/scorecard.md
--- a/docs/scorecard.md
+++ b/docs/scorecard.md
@@ -14,7 +14,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
 | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) |
 | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present |
 | [Distribution](#distribution) | 5 | 5 | 1 |  |
-| [Open bugs](#open-bugs) | 31 | 1 | 0 |  |
+| [Open bugs](#open-bugs) | 32 | 1 | 0 |  |
 
 ## Performance
 
@@ -298,6 +298,7 @@ Never-crash attribute on 30 of 30 crate roots.
 | BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/engine/tests/it/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both |
 | BUG-205-1 | Free Transform with a selection moves only the selected part of a linked mask | done | [#205](https://github.com/storytold/photocraft/issues/205) | linked_mask_with_a_selection_moves_only_the_selected_region (transform_cmds.rs; 5a5facc, #242) |
 | BUG-205-2 | Live transform preview draws the layer with its mask applied | done | [#205](https://github.com/storytold/photocraft/issues/205) | transform_tool.rs preview_mask + preview_applies_the_linked_mask (5a5facc, #242) |
+| BUG-205-6 | Live transform preview of a targeted unlinked mask shows the layer masked by it as the box moves it | done | [#9650](https://github.com/storytold/photocraft/issues/9650) | transform_tool.rs live_mask_doc + a_lone_mask_previews_live_through_its_layer; tests/it/transform_lone_mask_live.rs drags it in the offscreen app |
 | BUG-205-3 | edit.transform honours target mask: transforms only a targeted, unlinked mask | done | [#205](https://github.com/storytold/photocraft/issues/205) | targeted_unlinked_mask_transforms_alone and targeted_linked_mask_moves_with_its_layer (transform_cmds.rs) plus the through-the-box tests (transform_tool.rs; 5a5facc, #242) |
 | BUG-205-4 | Test: a linked raster mask with no selection moves with the pixels | done | [#205](https://github.com/storytold/photocraft/issues/205) | linked_raster_mask_moves_with_the_pixels (transform_cmds.rs): a reveal-all mask with a hidden half, linked, no selection; asserts the hidden half travels with the pixels and nothing hidden is left behind |
 | BUG-205-5 | Tests: linked mask + selection stays aligned; unlinked stays; targeted mask only | done | [#205](https://github.com/storytold/photocraft/issues/205) | All three in transform_cmds.rs: linked_mask_with_a_selection_moves_only_the_selected_region checks the mask value under each moved pixel, unlinked_mask_stays_put covers it with and without a selection, targeted_unlinked_mask_transforms_alone moves the mask while the layer bounds stay |
diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml
--- a/scorecard/bugs.toml
+++ b/scorecard/bugs.toml
@@ -104,6 +104,13 @@ status = "done"
 issue = 205
 note = "transform_tool.rs preview_mask + preview_applies_the_linked_mask (5a5facc, #242)"
 
+[[item]]
+id = "BUG-205-6"
+target = "Live transform preview of a targeted unlinked mask shows the layer masked by it as the box moves it"
+status = "done"
+issue = 9650
+note = "transform_tool.rs live_mask_doc + a_lone_mask_previews_live_through_its_layer; tests/it/transform_lone_mask_live.rs drags it in the offscreen app"
+
 [[item]]
 id = "BUG-205-3"
 target = "edit.transform honours target mask: transforms only a targeted, unlinked mask"
